Public: anyone can read this; the crew can change it.
A calendar file (.ics) in a repository renders as a list of its events #10379
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10328-a-calendar-file-ics-in-a-repository-rend → main 6 commits fork 2 threads · 2 unresolved
8 files
changed +335
−3
docs/features/ics.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Calendar files (.ics) as a list of events | |
| 2 | ||
| 3 | Open a `.ics` file in a repository, such as a calendar export, and read its events as a table: start, end, summary, location, in file order. | |
| 4 | ||
| 5 | - **Web:** the blob page draws the table on the server (`src/web/render/ics.ts`, `parseIcs`). `?view=source`, Raw and Download work as for any file. | |
| 6 | - **Terminal:** the workspace opens a `.ics` as the same table (`src/tui/screens/workspace.ts`, sharing `parseIcs` and `EVENT_CAP`); `s` flips to the source. The status line reads `N events`. | |
| 7 | - Folded lines are unfolded and `\,` `\;` `\n` escapes are undone. UTC times show as `UTC`, `TZID` times keep their zone name, all-day events show dates and the last day (the file's end is exclusive). | |
| 8 | - The first 500 events (`EVENT_CAP`) are shown; the rest are counted. | |
| 9 | - A file with no `BEGIN:VCALENDAR` is not a calendar: it shows as source, with a one-line reason on the web. | |
| 10 | - All text is escaped on output; the parser is pure and never throws. |
src/tui/screens/workspace.ts +26 −3
| @@ -53,6 +53,7 @@ import { fileKind, NO_BYTES } from "../../web/render/kinds.ts"; | ||
| 53 | 53 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 54 | 54 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| 55 | 55 | import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; |
| 56 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; | |
| 56 | 57 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 57 | 58 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| 58 | 59 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -314,6 +315,25 @@ function subtitleTable(source: string): { table: Table; unread: number } { | ||
| 314 | 315 | }; |
| 315 | 316 | } |
| 316 | 317 | |
| 318 | /** `.ics`: a calendar, drawn as a table of its events. */ | |
| 319 | function isIcsPath(path: string): boolean { | |
| 320 | return /\.ics$/i.test(path); | |
| 321 | } | |
| 322 | ||
| 323 | /** The events of a calendar as a table; null when the file is not a calendar. Shares the web's parser. */ | |
| 324 | function icsTable(source: string): Table | null { | |
| 325 | const cal = parseIcs(source); | |
| 326 | if ("error" in cal) return null; | |
| 327 | const shown = cal.events.slice(0, EVENT_CAP); | |
| 328 | return { | |
| 329 | header: ["Start", "End", "Summary", "Location"], | |
| 330 | rows: shown.map((e) => [e.start, e.end, e.summary, e.location]), | |
| 331 | more: cal.events.length - shown.length, | |
| 332 | delimiter: ",", | |
| 333 | columns: 4, | |
| 334 | }; | |
| 335 | } | |
| 336 | ||
| 317 | 337 | /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */ |
| 318 | 338 | function isMarkdownPath(path: string): boolean { |
| 319 | 339 | const name = path.slice(path.lastIndexOf("/") + 1); |
| @@ -468,6 +488,9 @@ class Tab { | ||
| 468 | 488 | this.table = subs.table; |
| 469 | 489 | this.unread = subs.unread; |
| 470 | 490 | this.grid = layoutTable(this.table); |
| 491 | } else if (isIcsPath(this.path)) { | |
| 492 | this.table = icsTable(new TextDecoder().decode(source)); | |
| 493 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 471 | 494 | } else if (isTablePath(this.path)) { |
| 472 | 495 | // The decoded source, not `plain`: `expandTabs` has eaten every tab there. |
| 473 | 496 | const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined; |
| @@ -2521,7 +2544,7 @@ export function workspaceScreen( | ||
| 2521 | 2544 | ? "" |
| 2522 | 2545 | : `${t.path} · ${ |
| 2523 | 2546 | t.tabular && t.table !== null |
| 2524 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path)) | |
| 2547 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path)) | |
| 2525 | 2548 | : `${plural( |
| 2526 | 2549 | t.reading ? t.doc.length : t.plain.length, |
| 2527 | 2550 | "line", |
| @@ -2855,9 +2878,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 2855 | 2878 | } |
| 2856 | 2879 | |
| 2857 | 2880 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 2858 | function tableStatus(table: Table, unread = 0, subs = false): string { | |
| 2881 | function tableStatus(table: Table, unread = 0, subs = false, events = false): string { | |
| 2859 | 2882 | const rows = table.rows.length + table.more; |
| 2860 | return `${plural(rows, subs ? "cue" : "row")}${ | |
| 2883 | return `${plural(rows, subs ? "cue" : events ? "event" : "row")}${ | |
| 2861 | 2884 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 2862 | 2885 | }${ |
| 2863 | 2886 | table.more > 0 ? `, first ${table.rows.length} shown` : "" |
src/web/render/ics.ts added +181
| @@ -0,0 +1,181 @@ | ||
| 1 | /** | |
| 2 | * A calendar file (iCalendar `.ics`, RFC 5545) as a list of its events: start, | |
| 3 | * end (or "all day"), summary and location, in file order. Lines are unfolded | |
| 4 | * (§3.1), VEVENT blocks read, DTSTART/DTEND taken with or without TZID, and | |
| 5 | * TEXT escapes (`\,` `\;` `\\` `\n`) undone. Every value is escaped on output. | |
| 6 | * A file that is not a calendar, or whose blocks do not close, falls back to | |
| 7 | * its source. | |
| 8 | */ | |
| 9 | ||
| 10 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 11 | import { fileSize } from "../ui.ts"; | |
| 12 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 13 | ||
| 14 | /** Events drawn, whatever the file holds. */ | |
| 15 | export const EVENT_CAP = 500; | |
| 16 | ||
| 17 | export interface IcsEvent { | |
| 18 | /** `2026-03-01 09:00`, `2026-03-01`, with a zone note when the file gave one. */ | |
| 19 | readonly start: string; | |
| 20 | /** Empty when the file gave no end. For an all-day event, the last day. */ | |
| 21 | readonly end: string; | |
| 22 | readonly allDay: boolean; | |
| 23 | readonly summary: string; | |
| 24 | readonly location: string; | |
| 25 | } | |
| 26 | ||
| 27 | /** Parsed events, or why the file is not a calendar. */ | |
| 28 | export type Calendar = { readonly events: IcsEvent[] } | { readonly error: string }; | |
| 29 | ||
| 30 | /** RFC 5545 §3.1: a line that starts with a space or tab continues the last. */ | |
| 31 | export function unfold(source: string): string[] { | |
| 32 | const out: string[] = []; | |
| 33 | for (const line of source.replace(/^/, "").split(/\r\n|\n|\r/)) { | |
| 34 | if ((line.startsWith(" ") || line.startsWith("\t")) && out.length > 0) out[out.length - 1] += line.slice(1); | |
| 35 | else out.push(line); | |
| 36 | } | |
| 37 | return out; | |
| 38 | } | |
| 39 | ||
| 40 | /** §3.3.11: undo `\\ \; \, \n \N`; an unknown escape keeps its character. */ | |
| 41 | export const unescapeText = (v: string): string => | |
| 42 | v.replace(/\\([\s\S])/g, (_, c: string) => (c === "n" || c === "N" ? "\n" : c)); | |
| 43 | ||
| 44 | interface Prop { | |
| 45 | readonly name: string; | |
| 46 | readonly params: ReadonlyMap<string, string>; | |
| 47 | readonly value: string; | |
| 48 | } | |
| 49 | ||
| 50 | /** `NAME;PARAM=v;PARAM="a:b":value`, split at the first colon outside quotes. */ | |
| 51 | function property(line: string): Prop | undefined { | |
| 52 | let quoted = false; | |
| 53 | let colon = -1; | |
| 54 | for (let i = 0; i < line.length; i++) { | |
| 55 | const c = line[i]; | |
| 56 | if (c === '"') quoted = !quoted; | |
| 57 | else if (c === ":" && !quoted) { | |
| 58 | colon = i; | |
| 59 | break; | |
| 60 | } | |
| 61 | } | |
| 62 | if (colon <= 0) return undefined; | |
| 63 | const [name, ...rest] = line.slice(0, colon).split(";"); | |
| 64 | const params = new Map<string, string>(); | |
| 65 | for (const p of rest) { | |
| 66 | const eq = p.indexOf("="); | |
| 67 | if (eq > 0) params.set(p.slice(0, eq).toUpperCase(), p.slice(eq + 1).replace(/^"|"$/g, "")); | |
| 68 | } | |
| 69 | return { name: name!.toUpperCase(), params, value: line.slice(colon + 1) }; | |
| 70 | } | |
| 71 | ||
| 72 | interface When { | |
| 73 | readonly text: string; | |
| 74 | readonly dateOnly: boolean; | |
| 75 | /** Days since the epoch, for an all-day end that is exclusive. */ | |
| 76 | readonly day: number; | |
| 77 | } | |
| 78 | ||
| 79 | const DATE_TIME = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/; | |
| 80 | ||
| 81 | function when(p: Prop): When | undefined { | |
| 82 | const m = DATE_TIME.exec(p.value.trim()); | |
| 83 | if (m === null) return undefined; | |
| 84 | const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; | |
| 85 | if (mo < 1 || mo > 12 || d < 1 || d > 31) return undefined; | |
| 86 | const day = Math.floor(Date.UTC(y, mo - 1, d) / 86_400_000); | |
| 87 | const date = `${m[1]}-${m[2]}-${m[3]}`; | |
| 88 | if (m[4] === undefined || p.params.get("VALUE") === "DATE") return { text: date, dateOnly: true, day }; | |
| 89 | if (Number(m[4]) > 23 || Number(m[5]) > 59) return undefined; | |
| 90 | const zone = m[7] === "Z" ? " UTC" : p.params.has("TZID") ? ` ${p.params.get("TZID")}` : ""; | |
| 91 | return { text: `${date} ${m[4]}:${m[5]}${zone}`, dateOnly: false, day }; | |
| 92 | } | |
| 93 | ||
| 94 | const isoDay = (day: number): string => new Date(day * 86_400_000).toISOString().slice(0, 10); | |
| 95 | ||
| 96 | /** Parse a calendar's text. Pure; never throws. */ | |
| 97 | export function parseIcs(source: string): Calendar { | |
| 98 | const lines = unfold(source); | |
| 99 | if (!lines.some((l) => l.trim().toUpperCase() === "BEGIN:VCALENDAR")) | |
| 100 | return { error: "not a calendar: no BEGIN:VCALENDAR" }; | |
| 101 | const events: IcsEvent[] = []; | |
| 102 | let props: Prop[] | undefined; | |
| 103 | // Components inside an event (VALARM) have their own SUMMARY and the like. | |
| 104 | let depth = 0; | |
| 105 | for (const line of lines) { | |
| 106 | const t = line.trim().toUpperCase(); | |
| 107 | if (t === "BEGIN:VEVENT") { | |
| 108 | if (props !== undefined) return { error: "an event begins inside another" }; | |
| 109 | props = []; | |
| 110 | depth = 0; | |
| 111 | continue; | |
| 112 | } | |
| 113 | if (props === undefined) continue; | |
| 114 | if (t.startsWith("BEGIN:")) { | |
| 115 | depth++; | |
| 116 | continue; | |
| 117 | } | |
| 118 | if (t === "END:VEVENT" && depth === 0) { | |
| 119 | const ev = toEvent(props); | |
| 120 | if (ev === undefined) return { error: "an event has no readable DTSTART" }; | |
| 121 | events.push(ev); | |
| 122 | props = undefined; | |
| 123 | continue; | |
| 124 | } | |
| 125 | if (t.startsWith("END:")) { | |
| 126 | depth--; | |
| 127 | continue; | |
| 128 | } | |
| 129 | if (depth === 0) { | |
| 130 | const p = property(line); | |
| 131 | if (p !== undefined) props.push(p); | |
| 132 | } | |
| 133 | } | |
| 134 | if (props !== undefined) return { error: "an event is never closed (no END:VEVENT)" }; | |
| 135 | return { events }; | |
| 136 | } | |
| 137 | ||
| 138 | function toEvent(props: readonly Prop[]): IcsEvent | undefined { | |
| 139 | const get = (n: string) => props.find((p) => p.name === n); | |
| 140 | const startProp = get("DTSTART"); | |
| 141 | const start = startProp === undefined ? undefined : when(startProp); | |
| 142 | if (start === undefined) return undefined; | |
| 143 | const endProp = get("DTEND"); | |
| 144 | const end = endProp === undefined ? undefined : when(endProp); | |
| 145 | let endText = end?.text ?? ""; | |
| 146 | if (start.dateOnly) { | |
| 147 | // DTEND of a date is exclusive: show the last day, and nothing for one day. | |
| 148 | const last = end?.dateOnly ? end.day - 1 : undefined; | |
| 149 | endText = last !== undefined && last > start.day ? isoDay(last) : ""; | |
| 150 | } | |
| 151 | return { | |
| 152 | start: start.text, | |
| 153 | end: endText, | |
| 154 | allDay: start.dateOnly, | |
| 155 | summary: unescapeText(get("SUMMARY")?.value ?? ""), | |
| 156 | location: unescapeText(get("LOCATION")?.value ?? ""), | |
| 157 | }; | |
| 158 | } | |
| 159 | ||
| 160 | export function ics(input: RenderInput): Rendered { | |
| 161 | const cal = parseIcs(new TextDecoder().decode(input.body)); | |
| 162 | if ("error" in cal) return { content: html``, lines: 0, fallback: `This file is not drawn as a calendar: ${cal.error}.` }; | |
| 163 | if (cal.events.length === 0) return { content: html``, lines: 0, fallback: "This calendar holds no events." }; | |
| 164 | let rows = ""; | |
| 165 | for (const e of cal.events.slice(0, EVENT_CAP)) { | |
| 166 | const end = escapeHtml(e.end); | |
| 167 | const start = e.allDay ? `${escapeHtml(e.start)} <span class="muted">all day</span>` : escapeHtml(e.start); | |
| 168 | rows += `<tr><td>${start}</td><td>${end}</td><td>${escapeHtml(e.summary).replace(/\n/g, "<br>")}</td><td>${escapeHtml(e.location).replace(/\n/g, "<br>")}</td></tr>`; | |
| 169 | } | |
| 170 | const more = | |
| 171 | cal.events.length > EVENT_CAP | |
| 172 | ? `<p class="muted small">Showing ${EVENT_CAP.toLocaleString()} of ${cal.events.length.toLocaleString()} events, ${(cal.events.length - EVENT_CAP).toLocaleString()} more. The rest is in the raw file.</p>` | |
| 173 | : ""; | |
| 174 | const name = input.path.slice(input.path.lastIndexOf("/") + 1); | |
| 175 | const content = html`<table class="table" data-dense aria-label="${name} events"> | |
| 176 | <thead><tr><th scope="col">Start</th><th scope="col">End</th><th scope="col">Summary</th><th scope="col">Location</th></tr></thead> | |
| 177 | <tbody>${raw(rows)}</tbody> | |
| 178 | </table>${raw(more)}`; | |
| 179 | const n = cal.events.length; | |
| 180 | return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "event" : "events"} · ${fileSize(input.size)}` }; | |
| 181 | } |
src/web/render/kinds.ts +3
| @@ -28,6 +28,7 @@ export type FileKind = | ||
| 28 | 28 | | "patch" |
| 29 | 29 | | "log" |
| 30 | 30 | | "subtitles" |
| 31 | | "calendar" | |
| 31 | 32 | | "text" |
| 32 | 33 | | "binary"; |
| 33 | 34 | |
| @@ -55,6 +56,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 55 | 56 | patch: 1 * MB, |
| 56 | 57 | log: 1 * MB, |
| 57 | 58 | subtitles: 5 * MB, |
| 59 | calendar: 5 * MB, | |
| 58 | 60 | text: 1 * MB, |
| 59 | 61 | binary: 0, |
| 60 | 62 | }; |
| @@ -160,6 +162,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 160 | 162 | ansi: "log", |
| 161 | 163 | srt: "subtitles", |
| 162 | 164 | vtt: "subtitles", |
| 165 | ics: "calendar", | |
| 163 | 166 | }; |
| 164 | 167 | |
| 165 | 168 | /** |
src/web/render/renderers.ts +2
| @@ -12,6 +12,7 @@ import { config } from "./config.ts"; | ||
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | 13 | import { csv } from "./csv.ts"; |
| 14 | 14 | import { font } from "./font.ts"; |
| 15 | import { ics } from "./ics.ts"; | |
| 15 | 16 | import { image } from "./image.ts"; |
| 16 | 17 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 17 | 18 | import { log, hasEscape } from "./log.ts"; |
| @@ -66,6 +67,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 66 | 67 | patch, |
| 67 | 68 | log, |
| 68 | 69 | subtitles, |
| 70 | calendar: ics, | |
| 69 | 71 | archive, |
| 70 | 72 | font, |
| 71 | 73 | }; |
test/render-ics.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** Calendar files as a list of events: folding, all-day, TZID, escapes, hostile text, a bad file. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { EVENT_CAP, ics, parseIcs } from "../src/web/render/ics.ts"; | |
| 4 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string) => ics({ path: "a/cal.ics", size: text.length, body: enc(text), media: "/m" }); | |
| 9 | const wrap = (...events: string[]) => | |
| 10 | `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${events.map((e) => `BEGIN:VEVENT\r\n${e}\r\nEND:VEVENT\r\n`).join("")}END:VCALENDAR\r\n`; | |
| 11 | ||
| 12 | describe("calendar files", () => { | |
| 13 | test("the registry names .ics and a renderer draws it", () => { | |
| 14 | expect(fileKind("x/a.ICS", enc("BEGIN:VCALENDAR\n"))).toBe("calendar"); | |
| 15 | expect(RENDERERS.calendar).toBe(ics); | |
| 16 | }); | |
| 17 | ||
| 18 | test("folded lines, escapes, UTC and TZID times, in file order", () => { | |
| 19 | const cal = parseIcs( | |
| 20 | wrap( | |
| 21 | "DTSTART:20260301T090000Z\r\nDTEND:20260301T100000Z\r\nSUMMARY:Plan\\, build\\nand a very long\r\n title folded\r\nLOCATION:Room 1\\; East", | |
| 22 | "DTSTART;TZID=Europe/Paris:20260302T140000\r\nDTEND;TZID=Europe/Paris:20260302T150000\r\nSUMMARY:Second", | |
| 23 | ), | |
| 24 | ); | |
| 25 | expect(cal).toEqual({ | |
| 26 | events: [ | |
| 27 | { start: "2026-03-01 09:00 UTC", end: "2026-03-01 10:00 UTC", allDay: false, summary: "Plan, build\nand a very long title folded", location: "Room 1; East" }, | |
| 28 | { start: "2026-03-02 14:00 Europe/Paris", end: "2026-03-02 15:00 Europe/Paris", allDay: false, summary: "Second", location: "" }, | |
| 29 | ], | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | test("all-day events: the exclusive end becomes the last day", () => { | |
| 34 | const cal = parseIcs( | |
| 35 | wrap( | |
| 36 | "DTSTART;VALUE=DATE:20260301\r\nDTEND;VALUE=DATE:20260302\r\nSUMMARY:One day", | |
| 37 | "DTSTART;VALUE=DATE:20260228\r\nDTEND;VALUE=DATE:20260303\r\nSUMMARY:Span", | |
| 38 | ), | |
| 39 | ); | |
| 40 | if ("error" in cal) throw new Error(cal.error); | |
| 41 | expect(cal.events.map((e) => [e.allDay, e.start, e.end])).toEqual([ | |
| 42 | [true, "2026-03-01", ""], | |
| 43 | [true, "2026-02-28", "2026-03-02"], | |
| 44 | ]); | |
| 45 | const out = String(draw(wrap("DTSTART;VALUE=DATE:20260301\r\nSUMMARY:X")).content); | |
| 46 | expect(out).toContain("all day"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a nested VALARM does not overwrite the event's summary", () => { | |
| 50 | const cal = parseIcs(wrap("DTSTART:20260301T090000\r\nSUMMARY:Real\r\nBEGIN:VALARM\r\nSUMMARY:Alarm\r\nEND:VALARM")); | |
| 51 | if ("error" in cal) throw new Error(cal.error); | |
| 52 | expect(cal.events[0]!.summary).toBe("Real"); | |
| 53 | }); | |
| 54 | ||
| 55 | test("a <script> summary is escaped, never emitted", () => { | |
| 56 | const r = draw(wrap("DTSTART:20260301T090000Z\r\nSUMMARY:<script>alert(1)</script>\r\nLOCATION:\"><img src=x onerror=1>")); | |
| 57 | const out = String(r.content); | |
| 58 | expect(out).not.toContain("<script"); | |
| 59 | expect(out).not.toContain("<img"); | |
| 60 | expect(out).toContain("<script>"); | |
| 61 | expect(r.summary).toContain("1 event"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("malformed files fall back to text", () => { | |
| 65 | expect(draw("just some words").fallback).toBeDefined(); | |
| 66 | expect(draw("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260301\r\n").fallback).toContain("never closed"); | |
| 67 | expect(draw(wrap("SUMMARY:no start")).fallback).toContain("DTSTART"); | |
| 68 | expect(draw(wrap("DTSTART:20261399T000000Z\r\nSUMMARY:bad")).fallback).toBeDefined(); | |
| 69 | expect(draw("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n").fallback).toContain("no events"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("capped at 500 events, saying how many more", () => { | |
| 73 | const many = Array.from({ length: EVENT_CAP + 7 }, (_, i) => `DTSTART:20260301T090000Z\r\nSUMMARY:e${i}`); | |
| 74 | const out = String(draw(wrap(...many)).content); | |
| 75 | expect(out).toContain("e499<"); | |
| 76 | expect(out).not.toContain("e500<"); | |
| 77 | expect(out).toContain("7 more"); | |
| 78 | }); | |
| 79 | }); |
test/render-kinds.test.ts +1
| @@ -68,6 +68,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 68 | 68 | ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"], |
| 69 | 69 | srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"], |
| 70 | 70 | vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"], |
| 71 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], | |
| 71 | 72 | }; |
| 72 | 73 | |
| 73 | 74 | describe("the registry of file kinds", () => { |
test/tui-workspace.test.ts +33
| @@ -383,6 +383,39 @@ describe("the terminal workspace", () => { | ||
| 383 | 383 | } |
| 384 | 384 | }); |
| 385 | 385 | |
| 386 | test("a .ics opens as a table of its events sharing the web parser, and s flips to the source", async () => { | |
| 387 | const made = await createRepo(forge, alice, { | |
| 388 | name: "calendars", | |
| 389 | visibility: "public", | |
| 390 | description: "events", | |
| 391 | }); | |
| 392 | if (!made.ok) throw new Error("repo failed"); | |
| 393 | await commitToBranch(forge, alice, made.value, { | |
| 394 | message: "seed", | |
| 395 | files: [ | |
| 396 | { | |
| 397 | path: "team.ics", | |
| 398 | content: | |
| 399 | "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260301T090000Z\r\nSUMMARY:Planning\r\nLOCATION:Room 1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", | |
| 400 | }, | |
| 401 | ], | |
| 402 | }); | |
| 403 | const h = boot(); | |
| 404 | try { | |
| 405 | await h.settle(); | |
| 406 | await open(h, "calendars"); | |
| 407 | await openFile(h, "team.ics"); | |
| 408 | expect(h.text()).toContain("Summary"); | |
| 409 | expect(h.text()).toContain("2026-03-01 09:00 UTC"); | |
| 410 | expect(h.text()).toContain("Planning"); | |
| 411 | expect(h.text()).toContain("1 event"); | |
| 412 | await h.press("s"); | |
| 413 | expect(h.text()).toContain("SUMMARY:Planning"); | |
| 414 | } finally { | |
| 415 | h.stop(); | |
| 416 | } | |
| 417 | }); | |
| 418 | ||
| 386 | 419 | test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => { |
| 387 | 420 | const made = await createRepo(forge, alice, { |
| 388 | 421 | name: "sheets", |
A single backslash before the semicolon inside a normal JS string becomes a bare semicolon, so no escape is tested. Double the backslash.