From 76640a5c9c4d2a71fb89577356059f259b56ef33 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 19:44:33 -0400 Subject: [PATCH 01/20] feat(sdk): re-export Event schema from sdk-next (#42175) --- packages/sdk-next/src/index.ts | 1 + packages/sdk-next/test/contract-identity.test.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index caf97183cd..8448d703b3 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -7,6 +7,7 @@ export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" export { Config } from "@opencode-ai/schema/config" export { Credential } from "@opencode-ai/schema/credential" +export { Event } from "@opencode-ai/schema/event" export { FileSystem } from "@opencode-ai/schema/filesystem" export { Integration } from "@opencode-ai/schema/integration" export { Location } from "@opencode-ai/schema/location" diff --git a/packages/sdk-next/test/contract-identity.test.ts b/packages/sdk-next/test/contract-identity.test.ts index 1e3c69a6c9..0b41eace60 100644 --- a/packages/sdk-next/test/contract-identity.test.ts +++ b/packages/sdk-next/test/contract-identity.test.ts @@ -4,6 +4,7 @@ import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbo import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" import { Agent } from "@opencode-ai/schema/agent" import { Config } from "@opencode-ai/schema/config" +import { Event } from "@opencode-ai/schema/event" import { Location } from "@opencode-ai/schema/location" import { Model } from "@opencode-ai/schema/model" import { Project } from "@opencode-ai/schema/project" @@ -26,6 +27,7 @@ const CoreSession = await import("@opencode-ai/core/session") test("re-exports canonical contracts directly from Schema", () => { expect(SDK.Agent).toBe(Agent) expect(SDK.Config).toBe(Config) + expect(SDK.Event).toBe(Event) expect(SDK.Model).toBe(Model) expect(SDK.WebSearch).toBe(WebSearch) expect(SDK.Session).toBe(Session) @@ -37,6 +39,7 @@ test("re-exports canonical contracts directly from Schema", () => { "Command", "Config", "Credential", + "Event", "FileSystem", "Integration", "Location", From d31a994c27998dc94a48ea33e5b3e0439eb5fa69 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 19:55:39 -0400 Subject: [PATCH 02/20] feat(tui): render Mermaid timelines (#42130) --- packages/merman/src/detect.ts | 2 + packages/merman/src/diagnostics.ts | 2 +- packages/merman/src/markdown.ts | 23 +++ packages/merman/src/test/diagnostics.test.ts | 7 + packages/merman/src/test/markdown.test.ts | 28 +++ packages/merman/src/timeline/diagram.test.ts | 188 +++++++++++++++++++ packages/merman/src/timeline/diagram.ts | 8 + packages/merman/src/timeline/drawing.ts | 109 +++++++++++ packages/merman/src/timeline/parser.ts | 119 ++++++++++++ packages/merman/src/timeline/render-grid.ts | 17 ++ packages/merman/src/timeline/style.ts | 36 ++++ packages/merman/src/timeline/types.ts | 31 +++ 12 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 packages/merman/src/timeline/diagram.test.ts create mode 100644 packages/merman/src/timeline/diagram.ts create mode 100644 packages/merman/src/timeline/drawing.ts create mode 100644 packages/merman/src/timeline/parser.ts create mode 100644 packages/merman/src/timeline/render-grid.ts create mode 100644 packages/merman/src/timeline/style.ts create mode 100644 packages/merman/src/timeline/types.ts diff --git a/packages/merman/src/detect.ts b/packages/merman/src/detect.ts index 3e8443a833..443ae61cda 100644 --- a/packages/merman/src/detect.ts +++ b/packages/merman/src/detect.ts @@ -2,10 +2,12 @@ import type { MermaidDiagramKind } from "./diagnostics.js" import { isMermaidFlowchartDiagram } from "./flowchart/parser.js" import { isMermaidSequenceDiagram } from "./sequence/parser.js" import { isMermaidStateDiagram } from "./state/parser.js" +import { isMermaidTimelineDiagram } from "./timeline/parser.js" export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined { if (isMermaidFlowchartDiagram(content)) return "flowchart" if (isMermaidSequenceDiagram(content)) return "sequence" if (isMermaidStateDiagram(content)) return "state" + if (isMermaidTimelineDiagram(content)) return "timeline" return undefined } diff --git a/packages/merman/src/diagnostics.ts b/packages/merman/src/diagnostics.ts index 7336bfcaa8..8a93661888 100644 --- a/packages/merman/src/diagnostics.ts +++ b/packages/merman/src/diagnostics.ts @@ -1,4 +1,4 @@ -export type MermaidDiagramKind = "flowchart" | "sequence" | "state" +export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" /** An otherwise valid diagram contains syntax that this renderer does not support. */ export class MermaidSyntaxError extends Error { diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts index 136b5cab16..e933742869 100644 --- a/packages/merman/src/markdown.ts +++ b/packages/merman/src/markdown.ts @@ -25,6 +25,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js" import { parseMermaidStateDiagram } from "./state/parser.js" import { renderStateGridStyledText } from "./state/render-grid.js" import { resolveStateStyleColors } from "./state/style.js" +import { drawTimelineDiagramGrid } from "./timeline/drawing.js" +import { parseMermaidTimelineDiagram } from "./timeline/parser.js" +import { renderTimelineGridStyledText } from "./timeline/render-grid.js" +import { resolveTimelineStyleColors } from "./timeline/style.js" type DiagramKind = NonNullable> @@ -180,6 +184,25 @@ function prepareDiagram( height: size.height, } } + case "timeline": { + const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source)) + const size = grid.getTextSize({ trimBottom: true }) + return { + kind, + source, + text: renderTimelineGridStyledText( + grid, + resolveTimelineStyleColors({ + title: color(colors.text), + section: color(colors.secondary), + period: color(colors.warning), + spine: color(colors.muted), + event: color(colors.primary), + }), + ), + height: size.height, + } + } } } diff --git a/packages/merman/src/test/diagnostics.test.ts b/packages/merman/src/test/diagnostics.test.ts index 18f01cf363..5ccebb2ac6 100644 --- a/packages/merman/src/test/diagnostics.test.ts +++ b/packages/merman/src/test/diagnostics.test.ts @@ -3,6 +3,7 @@ import { MermaidSyntaxError } from "../diagnostics.js" import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js" import { parseMermaidSequenceDiagram } from "../sequence/parser.js" import { parseMermaidStateDiagram } from "../state/parser.js" +import { renderTimelineDiagram } from "../timeline/diagram.js" import { renderSequenceDiagram } from "../sequence/diagram.js" describe("parser diagnostics", () => { @@ -104,6 +105,12 @@ describe("parser diagnostics", () => { ).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"') }) + test("reports malformed timeline continuations with timeline diagnostics", () => { + expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow( + 'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"', + ) + }) + test("does not attach else through an unclosed nested sequence block", () => { expect(() => parseMermaidSequenceDiagram(`sequenceDiagram diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts index 056b0f636a..49c3423c2d 100644 --- a/packages/merman/src/test/markdown.test.ts +++ b/packages/merman/src/test/markdown.test.ts @@ -333,3 +333,31 @@ stateDiagram-v2 expect(frame).toContain("Idle") expect(frame).not.toContain("stateDiagram-v2") }) + +test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 18 }) + renderer = testRenderer.renderer + const { renderOnce, captureCharFrame } = testRenderer + const markdown = new MarkdownRenderable(renderer, { + id: "markdown-timeline", + content: `\`\`\`mermaid +timeline + title Product history + section Foundation + 2024 : Prototype + : First release +\`\`\``, + syntaxStyle, + treeSitterClient, + renderNode: createMermaidMarkdownRenderer(renderer), + }) + + renderer.root.add(markdown) + await renderMarkdown(markdown, renderOnce) + + const frame = captureCharFrame() + expect(frame).toContain("Product history") + expect(frame).toContain("Foundation") + expect(frame).toContain("First release") + expect(frame).not.toContain("timeline") +}) diff --git a/packages/merman/src/timeline/diagram.test.ts b/packages/merman/src/timeline/diagram.test.ts new file mode 100644 index 0000000000..52e8ee2f8c --- /dev/null +++ b/packages/merman/src/timeline/diagram.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test" +import { renderTimelineDiagram } from "./diagram.js" +import { drawTimelineDiagramGrid } from "./drawing.js" +import { parseMermaidTimelineDiagram } from "./parser.js" +import { renderTimelineGridText } from "./render-grid.js" +import { resolveTimelineStyleColors } from "./style.js" + +describe("TimelineDiagram", () => { + test("detects and parses titles, sections, periods, inline events, and continuations", () => { + const diagram = parseMermaidTimelineDiagram(` +%% product history +timeline LR + title Product &
Platform + + section Foundation + 2024 : Prototype : First release + : Public beta + section Growth + 2025 : "Scale: ≥ 10k" +`) + + expect(diagram.direction).toBe("LR") + expect(diagram.title).toBe("Product &
Platform") + expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }]) + expect(diagram.periods).toEqual([ + { period: "2024", events: ["Prototype", "First release", "Public beta"] }, + { period: "2025", events: ["Scale: ≥ 10k"] }, + ]) + expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"]) + }) + + test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => { + const output = renderTimelineDiagram(`timeline + title Product &
Platform + section Foundation
phase + 2024 : Prototype
ready : First release + : Scale ≥ 10k`) + + expect(output).toBe( + [ + " Product &", + " Platform", + "", + "Foundation ───┐", + " phase │", + " │", + " 2024 ───● Prototype", + " │ ready", + " │ First release", + " │ Scale ≥ 10k", + " │", + ].join("\n"), + ) + }) + + test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => { + const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`) + const lines = output.split("\n") + + expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan( + lines.findIndex((line) => line.includes("2025")), + ) + expect(output).toContain("│") + expect(output).toContain("●") + }) + + test("preserves Mermaid direction semantics while using vertical terminal layout", () => { + expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR") + expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD") + }) + + test("keeps ordinary colons in event text", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + 2024 : https://example.com : event:detail : next event`) + + expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"]) + }) + + test("does not treat apostrophes in event prose as quotes", () => { + const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta") + + expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"]) + }) + + test("supports standalone periods followed by continuation events", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + 2024 + : First release + : Public beta`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }]) + }) + + test("ignores timeline comments and accessibility directives", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + # product history + accTitle: Product timeline + accDescr Product release history + 2024 : Prototype %% internal note`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }]) + }) + + test("ignores multiline accessibility descriptions", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + accDescr { + Product milestones by year. + Includes launch and growth. + } + 2024 : Ship`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }]) + }) + + test("rejects a continuation without a period with source diagnostics", () => { + expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow( + 'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"', + ) + }) + + test("rejects unsupported and empty syntax", () => { + expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty") + expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty") + expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period") + }) + + test("draws semantic styles for every timeline role", () => { + const grid = drawTimelineDiagramGrid( + parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), + ) + const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean))) + + expect(styles).toEqual( + new Set([ + "title", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + "period", + "periodFade1", + "periodFade2", + "periodFade3", + "event", + ]), + ) + expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([ + "event", + "period", + "periodFade1", + "periodFade2", + "periodFade3", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + "title", + ]) + expect(renderTimelineGridText(grid)).toBe( + renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), + ) + }) + + test("uses section starts and joins with ordered color ramps", () => { + const grid = drawTimelineDiagramGrid( + parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"), + ) + const text = renderTimelineGridText(grid) + + expect(text).toContain("Morning ───┐") + expect(text).toContain("Midday ───┤") + expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([ + "section", + "section", + "section", + "section", + "section", + "section", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + ]) + }) +}) diff --git a/packages/merman/src/timeline/diagram.ts b/packages/merman/src/timeline/diagram.ts new file mode 100644 index 0000000000..d8340bff5e --- /dev/null +++ b/packages/merman/src/timeline/diagram.ts @@ -0,0 +1,8 @@ +import { drawTimelineDiagramGrid } from "./drawing.js" +import { parseMermaidTimelineDiagram } from "./parser.js" +import { renderTimelineGridText } from "./render-grid.js" +import type { TimelineDiagramRenderOptions } from "./types.js" + +export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string { + return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options)) +} diff --git a/packages/merman/src/timeline/drawing.ts b/packages/merman/src/timeline/drawing.ts new file mode 100644 index 0000000000..34fe704747 --- /dev/null +++ b/packages/merman/src/timeline/drawing.ts @@ -0,0 +1,109 @@ +import { DiagramCanvas } from "../core/canvas.js" +import { splitDiagramLines } from "../core/text-lines.js" +import { diagramTextWidth } from "../core/text.js" +import type { TimelineGrid } from "./render-grid.js" +import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js" +import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js" + +interface PeriodLayout { + period: TimelinePeriod + periodLines: string[] + eventLines: string[][] + height: number +} + +const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length +const SPINE_OFFSET = JOIN_WIDTH + 1 +const EVENT_OFFSET = 3 + +export function drawTimelineDiagramGrid( + diagram: TimelineDiagram, + _options: TimelineDiagramRenderOptions = {}, +): TimelineGrid { + const periodLayouts = new Map() + let leftWidth = 0 + let rightWidth = 0 + let bodyHeight = 0 + + for (const entry of diagram.entries) { + if (entry.type === "section") { + const lines = splitDiagramLines(entry.section.label) + bodyHeight += lines.length + 1 + for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line)) + continue + } + const periodLines = splitDiagramLines(entry.period.period) + const eventLines = entry.period.events.map(splitDiagramLines) + const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0) + const height = Math.max(periodLines.length, eventHeight) + periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height }) + for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line)) + for (const lines of eventLines) { + for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line)) + } + bodyHeight += height + 1 + } + + const titleLines = diagram.title ? splitDiagramLines(diagram.title) : [] + const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1 + let titleWidth = 0 + for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line)) + const width = Math.max(bodyWidth, titleWidth) + const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1) + if (width === 0) return new DiagramCanvas(0, 0) + + const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight) + titleLines.forEach((line, index) => + setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"), + ) + if (diagram.entries.length === 0) return grid + + const spineX = leftWidth + SPINE_OFFSET + let y = titleHeight + let railStarted = false + for (const entry of diagram.entries) { + if (entry.type === "section") { + const lines = splitDiagramLines(entry.section.label) + lines.forEach((line, index) => { + setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section") + if (index > 0) setCell(grid, spineX, y + index, "│", "spine") + }) + drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES) + setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine") + setCell(grid, spineX, y + lines.length, "│", "spine") + railStarted = true + y += lines.length + 1 + continue + } + + const layout = periodLayouts.get(entry.period)! + for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine") + railStarted = true + setCell(grid, spineX, y, "●", "spine") + layout.periodLines.forEach((line, index) => { + const lineWidth = diagramTextWidth(line) + setText(grid, leftWidth - lineWidth, y + index, line, "period") + }) + drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES) + + let eventY = y + for (const lines of layout.eventLines) { + lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event")) + eventY += lines.length + } + y += layout.height + 1 + } + return grid +} + +function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void { + styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style)) +} + +function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void { + grid.setCell(x, y, char, style) +} + +function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void { + grid.setText(x, y, text, style) +} diff --git a/packages/merman/src/timeline/parser.ts b/packages/merman/src/timeline/parser.ts new file mode 100644 index 0000000000..818d908557 --- /dev/null +++ b/packages/merman/src/timeline/parser.ts @@ -0,0 +1,119 @@ +import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js" +import { MermaidSyntaxError } from "../diagnostics.js" +import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js" + +const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i +const TITLE_RE = /^title(?:\s+(.+))?$/i +const SECTION_RE = /^section(?:\s+(.+))?$/i +const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i + +export function isMermaidTimelineDiagram(content: string): boolean { + return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "") +} + +export function parseMermaidTimelineDiagram(content: string): TimelineDiagram { + const sections: TimelineSection[] = [] + const periods: TimelinePeriod[] = [] + const entries: TimelineEntry[] = [] + let direction: TimelineDirection = "LR" + let title: string | undefined + let currentPeriod: TimelinePeriod | undefined + let inAccessibilityDescription = false + + for (const source of meaningfulNumberedMermaidLines(content)) { + const line = stripTimelineComment(source.text) + if (inAccessibilityDescription) { + if (line === "}") inAccessibilityDescription = false + continue + } + if (/^accDescr\s*\{$/i.test(line)) { + inAccessibilityDescription = true + continue + } + if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue + const header = line.match(HEADER_RE) + if (header) { + direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR" + continue + } + + const titleMatch = line.match(TITLE_RE) + if (titleMatch) { + if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty") + title = stripMermaidQuotes(titleMatch[1]) + continue + } + + const sectionMatch = line.match(SECTION_RE) + if (sectionMatch) { + if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty") + const section = { label: stripMermaidQuotes(sectionMatch[1]) } + sections.push(section) + entries.push({ type: "section", section }) + currentPeriod = undefined + continue + } + + if (line.startsWith(":")) { + if (!currentPeriod) { + throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period") + } + currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line)) + continue + } + + const fields = splitEventFields(line) + const periodLabel = stripMermaidQuotes(fields.shift()!) + if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty") + const period = { + period: periodLabel, + events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line), + } + periods.push(period) + entries.push({ type: "period", period }) + currentPeriod = period + } + + return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries } +} + +function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] { + return parseEventFields(splitEventFields(value), lineNumber, sourceLine) +} + +function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] { + const events = fields.map(stripMermaidQuotes) + if (events.length === 0 || events.some((event) => event.length === 0)) { + throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty") + } + return events +} + +function splitEventFields(value: string): string[] { + const fields: string[] = [] + let quote: '"' | "'" | undefined + let start = 0 + for (let index = 0; index < value.length; index++) { + const char = value[index] + if (char === '"' || char === "'") { + if (quote === char) quote = undefined + else if (quote === undefined && value.slice(start, index).trim() === "") quote = char + continue + } + const next = value[index + 1] + if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue + fields.push(value.slice(start, index)) + start = index + 1 + } + fields.push(value.slice(start)) + return fields +} + +function stripTimelineComment(value: string): string { + const comment = value.indexOf("%%") + return (comment < 0 ? value : value.slice(0, comment)).trim() +} + +function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError { + return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason) +} diff --git a/packages/merman/src/timeline/render-grid.ts b/packages/merman/src/timeline/render-grid.ts new file mode 100644 index 0000000000..cb28a919a7 --- /dev/null +++ b/packages/merman/src/timeline/render-grid.ts @@ -0,0 +1,17 @@ +import type { StyledText } from "@opentui/core" +import type { DiagramCanvas } from "../core/canvas.js" +import { renderDiagramGridStyledText } from "../core/render-grid.js" +import type { TimelineStyleColors } from "./style.js" +import type { TimelineCellStyle } from "./types.js" + +export type TimelineGrid = DiagramCanvas + +export function renderTimelineGridText(grid: TimelineGrid): string { + return grid.toString({ trimBottom: true }) +} + +export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText { + return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, { + trimBottom: true, + }) +} diff --git a/packages/merman/src/timeline/style.ts b/packages/merman/src/timeline/style.ts new file mode 100644 index 0000000000..bea9c14318 --- /dev/null +++ b/packages/merman/src/timeline/style.ts @@ -0,0 +1,36 @@ +import { RGBA } from "@opentui/core" +import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js" +import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js" + +const DEFAULT_THEME_RGB = { + title: [228, 239, 232], + section: [154, 184, 169], + period: [230, 177, 126], + spine: [111, 138, 126], + event: [134, 225, 200], +} as const satisfies Record + +export type TimelineStyleColors = Required> +export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const) +export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const) + +export function resolveTimelineStyleColors( + colors: Partial> = {}, +): TimelineStyleColors { + const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section) + const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period) + const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine) + return { + title: colors.title ?? rgba(DEFAULT_THEME_RGB.title), + section, + period, + spine, + event: colors.event ?? rgba(DEFAULT_THEME_RGB.event), + sectionFade1: blendColor(section, spine, 0.5), + sectionFade2: blendColor(section, spine, 0.67), + sectionFade3: blendColor(section, spine, 0.83), + periodFade1: blendColor(period, spine, 0.5), + periodFade2: blendColor(period, spine, 0.67), + periodFade3: blendColor(period, spine, 0.83), + } +} diff --git a/packages/merman/src/timeline/types.ts b/packages/merman/src/timeline/types.ts new file mode 100644 index 0000000000..fc7c3ab108 --- /dev/null +++ b/packages/merman/src/timeline/types.ts @@ -0,0 +1,31 @@ +export type TimelineDirection = "TD" | "LR" + +export interface TimelineSection { + label: string +} + +export interface TimelinePeriod { + period: string + events: string[] +} + +export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod } + +export interface TimelineDiagram { + direction: TimelineDirection + title?: string + sections: TimelineSection[] + periods: TimelinePeriod[] + entries: TimelineEntry[] +} + +export interface TimelineDiagramRenderOptions { + /** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */ + direction?: TimelineDirection +} + +export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event" +export type TimelineFadeStep = 1 | 2 | 3 +export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}` +export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}` +export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle From 9b805c140f36db3e8ef7d843adc6b269853bbe3d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 20:10:52 -0400 Subject: [PATCH 03/20] feat(tui): render Mermaid GitGraph diagrams (#42179) --- packages/merman/src/detect.ts | 2 + packages/merman/src/diagnostics.ts | 2 +- packages/merman/src/gitgraph/diagram.test.ts | 183 +++++++++++++++ packages/merman/src/gitgraph/diagram.ts | 8 + packages/merman/src/gitgraph/drawing.ts | 234 +++++++++++++++++++ packages/merman/src/gitgraph/parser.ts | 219 +++++++++++++++++ packages/merman/src/gitgraph/render-grid.ts | 17 ++ packages/merman/src/gitgraph/style.ts | 37 +++ packages/merman/src/gitgraph/types.ts | 36 +++ packages/merman/src/markdown.ts | 23 ++ packages/merman/src/test/diagnostics.test.ts | 7 + packages/merman/src/test/markdown.test.ts | 25 ++ 12 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 packages/merman/src/gitgraph/diagram.test.ts create mode 100644 packages/merman/src/gitgraph/diagram.ts create mode 100644 packages/merman/src/gitgraph/drawing.ts create mode 100644 packages/merman/src/gitgraph/parser.ts create mode 100644 packages/merman/src/gitgraph/render-grid.ts create mode 100644 packages/merman/src/gitgraph/style.ts create mode 100644 packages/merman/src/gitgraph/types.ts diff --git a/packages/merman/src/detect.ts b/packages/merman/src/detect.ts index 443ae61cda..e1b86518cc 100644 --- a/packages/merman/src/detect.ts +++ b/packages/merman/src/detect.ts @@ -1,11 +1,13 @@ import type { MermaidDiagramKind } from "./diagnostics.js" import { isMermaidFlowchartDiagram } from "./flowchart/parser.js" +import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js" import { isMermaidSequenceDiagram } from "./sequence/parser.js" import { isMermaidStateDiagram } from "./state/parser.js" import { isMermaidTimelineDiagram } from "./timeline/parser.js" export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined { if (isMermaidFlowchartDiagram(content)) return "flowchart" + if (isMermaidGitGraphDiagram(content)) return "gitGraph" if (isMermaidSequenceDiagram(content)) return "sequence" if (isMermaidStateDiagram(content)) return "state" if (isMermaidTimelineDiagram(content)) return "timeline" diff --git a/packages/merman/src/diagnostics.ts b/packages/merman/src/diagnostics.ts index 8a93661888..6bae600323 100644 --- a/packages/merman/src/diagnostics.ts +++ b/packages/merman/src/diagnostics.ts @@ -1,4 +1,4 @@ -export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" +export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph" /** An otherwise valid diagram contains syntax that this renderer does not support. */ export class MermaidSyntaxError extends Error { diff --git a/packages/merman/src/gitgraph/diagram.test.ts b/packages/merman/src/gitgraph/diagram.test.ts new file mode 100644 index 0000000000..a021065889 --- /dev/null +++ b/packages/merman/src/gitgraph/diagram.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { MermaidSyntaxError } from "../diagnostics.js" +import { renderGitGraphDiagram } from "./diagram.js" +import { drawGitGraphDiagramGrid } from "./drawing.js" +import { isMermaidGitGraphDiagram, parseMermaidGitGraphDiagram } from "./parser.js" +import { renderGitGraphGridText } from "./render-grid.js" +import { resolveGitGraphStyleColors } from "./style.js" + +describe("GitGraphDiagram", () => { + test("detects and parses commits, branches, checkout, tags, types, and merges", () => { + const diagram = parseMermaidGitGraphDiagram(`gitGraph TB: + commit id: "init" + branch feature order: 1 + commit id: "api" msg: "Add API" tag: "ready" + checkout main + commit id: "docs" type: HIGHLIGHT + merge feature id: "merge-feature"`) + + expect(diagram).toEqual({ + direction: "TB", + branches: [ + { name: "main", order: 0, head: "merge-feature" }, + { name: "feature", order: 1, head: "api" }, + ], + commits: [ + { id: "init", tags: [], type: "NORMAL", branch: "main", parents: [] }, + { id: "api", message: "Add API", tags: ["ready"], type: "NORMAL", branch: "feature", parents: ["init"] }, + { id: "docs", tags: [], type: "HIGHLIGHT", branch: "main", parents: ["init"] }, + { + id: "merge-feature", + tags: [], + type: "NORMAL", + branch: "main", + parents: ["docs", "api"], + }, + ], + }) + }) + + test("renders branch and merge transitions beside compact labels", () => { + const source = `gitGraph + commit id: "baseline" + branch refactor + commit id: "extract-seam" msg: "Extract seam" + commit id: "add-tests" tag: "ready" + checkout main + commit id: "unrelated-fix" + merge refactor id: "land-refactor" tag: "v2"` + + expect(renderGitGraphDiagram(source)).toBe(`● baseline +├─╮ +│ ● Extract seam +│ ● add-tests [refactor] [ready] +● │ unrelated-fix +◎─╯ land-refactor [main] [v2]`) + }) + + test("uses deterministic generated ids", () => { + expect(parseMermaidGitGraphDiagram("gitGraph\n commit\n commit").commits.map((commit) => commit.id)).toEqual([ + "commit-1", + "commit-2", + ]) + }) + + test("supports shorthand messages and preserves branch heads without direct commits", () => { + const diagram = parseMermaidGitGraphDiagram(`gitGraph + commit "Initial release" + branch feature + checkout main + commit id: next`) + + expect(diagram.commits[0]?.message).toBe("Initial release") + expect(diagram.branches).toEqual([ + { name: "main", order: 0, head: "next" }, + { name: "feature", head: "commit-1" }, + ]) + expect( + renderGitGraphDiagram(`gitGraph + commit id: base + branch feature + checkout main + commit id: next`), + ).toContain("base [feature]") + }) + + test("places unordered branches before explicitly ordered branches", () => { + const diagram = parseMermaidGitGraphDiagram(`gitGraph + commit id: base + branch later order: 2 + checkout main + branch ordinary + checkout main + branch earlier order: 1`) + + expect(diagram.branches.map((branch) => branch.name)).toEqual(["main", "ordinary", "earlier", "later"]) + }) + + test("keeps comment markers inside quoted labels", () => { + expect(parseMermaidGitGraphDiagram('gitGraph\n commit id: "release%%candidate" %% comment').commits[0]?.id).toBe( + "release%%candidate", + ) + }) + + test("uses rounded routing for wide lane transitions", () => { + expect( + renderGitGraphDiagram(`gitGraph + commit id: base + branch one + branch two + commit id: work`), + ).toBe(`● base [main] [one] +├───╮ + ● work [two]`) + }) + + test("preserves direction semantics while rendering vertically", () => { + const source = "gitGraph BT:\n commit id: one" + const diagram = parseMermaidGitGraphDiagram(source) + expect(diagram.direction).toBe("BT") + expect(renderGitGraphGridText(drawGitGraphDiagramGrid(diagram, { direction: "LR" }))).toBe( + renderGitGraphDiagram(source), + ) + }) + + test("reports semantic failures with source diagnostics", () => { + expect(() => parseMermaidGitGraphDiagram("gitGraph\n checkout missing")).toThrow( + new MermaidSyntaxError("gitGraph", 2, "checkout missing", 'Unknown branch "missing"'), + ) + expect(() => parseMermaidGitGraphDiagram("gitGraph\n cherry-pick id: one")).toThrow( + new MermaidSyntaxError("gitGraph", 2, "cherry-pick id: one", "Cherry-pick is not supported"), + ) + expect(() => parseMermaidGitGraphDiagram("gitGraph\n commit id: same\n commit id: same")).toThrow( + 'Duplicate commit id "same"', + ) + expect(() => parseMermaidGitGraphDiagram("gitGraph\n branch feature\n checkout main\n branch feature")).toThrow( + 'Duplicate branch "feature"', + ) + expect(() => + parseMermaidGitGraphDiagram("gitGraph\n branch feature\n commit id: work\n checkout main\n merge feature"), + ).toThrow('Branch "main" has no commits') + }) + + test("draws semantic styles for rails, commit types, merges, and labels", () => { + const grid = drawGitGraphDiagramGrid( + parseMermaidGitGraphDiagram(`gitGraph + commit id: base + branch feature + commit id: work type: REVERSE + checkout main + commit id: checkpoint type: HIGHLIGHT + merge feature id: done`), + ) + const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean))) + + expect(styles).toEqual(new Set(["branch0", "branch1", "commit", "reverse", "highlight", "merge", "label"])) + expect(Object.keys(resolveGitGraphStyleColors()).sort()).toEqual( + [ + "branch0", + "branch1", + "branch2", + "branch3", + "branch4", + "branch5", + "branch6", + "branch7", + "commit", + "highlight", + "label", + "merge", + "reverse", + ].sort(), + ) + }) + + test("recognizes only GitGraph headers", () => { + expect(isMermaidGitGraphDiagram("%% comment\ngitGraph LR:\n commit")).toBe(true) + expect(isMermaidGitGraphDiagram("graph LR\n A --> B")).toBe(false) + expect(() => parseMermaidGitGraphDiagram("commit id: missing-header")).toThrow("GitGraph header is required") + expect(() => parseMermaidGitGraphDiagram("gitGraph\n commit\n gitGraph")).toThrow( + "GitGraph header can only appear once", + ) + }) +}) diff --git a/packages/merman/src/gitgraph/diagram.ts b/packages/merman/src/gitgraph/diagram.ts new file mode 100644 index 0000000000..81c89bc407 --- /dev/null +++ b/packages/merman/src/gitgraph/diagram.ts @@ -0,0 +1,8 @@ +import { drawGitGraphDiagramGrid } from "./drawing.js" +import { parseMermaidGitGraphDiagram } from "./parser.js" +import { renderGitGraphGridText } from "./render-grid.js" +import type { GitGraphDiagramRenderOptions } from "./types.js" + +export function renderGitGraphDiagram(content: string, options: GitGraphDiagramRenderOptions = {}): string { + return renderGitGraphGridText(drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(content), options)) +} diff --git a/packages/merman/src/gitgraph/drawing.ts b/packages/merman/src/gitgraph/drawing.ts new file mode 100644 index 0000000000..8582bf91a1 --- /dev/null +++ b/packages/merman/src/gitgraph/drawing.ts @@ -0,0 +1,234 @@ +import { DiagramCanvas } from "../core/canvas.js" +import { diagramTextWidth } from "../core/text.js" +import type { GitGraphGrid } from "./render-grid.js" +import type { GitGraphCellStyle, GitGraphCommit, GitGraphDiagram, GitGraphDiagramRenderOptions } from "./types.js" + +interface BranchSpan { + first: number + last: number +} + +interface Connections { + up?: boolean + down?: boolean + left?: boolean + right?: boolean + style: GitGraphCellStyle +} + +const LANE_WIDTH = 2 +const LABEL_GAP = 2 + +export function drawGitGraphDiagramGrid( + diagram: GitGraphDiagram, + _options: GitGraphDiagramRenderOptions = {}, +): GitGraphGrid { + if (diagram.commits.length === 0) return new DiagramCanvas(0, 0) + const laneByBranch = new Map(diagram.branches.map((branch, index) => [branch.name, index])) + const commitById = new Map(diagram.commits.map((commit) => [commit.id, commit])) + const spans = branchSpans(diagram, commitById) + const heads = branchHeads(diagram) + const graphWidth = (diagram.branches.length - 1) * LANE_WIDTH + 1 + let labelWidth = 0 + for (const commit of diagram.commits) labelWidth = Math.max(labelWidth, diagramTextWidth(commitLabel(commit, heads))) + const forks = diagram.commits.map((commit) => isFork(commit, laneByBranch, commitById)) + const height = diagram.commits.length + forks.filter(Boolean).length + const grid: GitGraphGrid = new DiagramCanvas(graphWidth + LABEL_GAP + labelWidth, height) + + let row = 0 + diagram.commits.forEach((commit, index) => { + if (forks[index]) { + drawTransitionRow(grid, spans, laneByBranch, commitById, commit, index, row) + row += 1 + } + drawCommitRow(grid, diagram, spans, laneByBranch, commitById, commit, index, row) + grid.setText(graphWidth + LABEL_GAP, row, commitLabel(commit, heads), "label") + row += 1 + }) + return grid +} + +function drawTransitionRow( + grid: GitGraphGrid, + spans: Map, + laneByBranch: Map, + commitById: Map, + commit: GitGraphCommit, + index: number, + y: number, +): void { + const cells = new Map() + for (const [branch, span] of spans) { + if (span.first >= index || span.last < index) continue + const lane = laneByBranch.get(branch)! + connect(cells, lane * LANE_WIDTH, { up: true, down: true }, branchStyle(lane)) + } + + const lane = laneByBranch.get(commit.branch)! + const firstParent = commit.parents[0] === undefined ? undefined : commitById.get(commit.parents[0]) + if (firstParent && firstParent.branch !== commit.branch) { + const parentLane = laneByBranch.get(firstParent.branch)! + connectHorizontal( + cells, + parentLane, + lane, + { sourceUp: true, sourceDown: true, targetDown: true }, + branchStyle(lane), + ) + } + paintConnections(grid, cells, y) +} + +function drawCommitRow( + grid: GitGraphGrid, + diagram: GitGraphDiagram, + spans: Map, + laneByBranch: Map, + commitById: Map, + commit: GitGraphCommit, + index: number, + y: number, +): void { + const cells = new Map() + for (const branch of diagram.branches) { + const span = spans.get(branch.name) + if (!span || span.first > index || (span.last <= index && branch.name !== commit.branch)) continue + const lane = laneByBranch.get(branch.name)! + connect(cells, lane * LANE_WIDTH, { up: index > 0, down: span.last > index }, branchStyle(lane)) + } + + const lane = laneByBranch.get(commit.branch)! + const secondParent = commit.parents[1] === undefined ? undefined : commitById.get(commit.parents[1]) + if (secondParent) { + const parentLane = laneByBranch.get(secondParent.branch)! + connectHorizontal(cells, lane, parentLane, { sourceUp: true, targetUp: true }, branchStyle(parentLane)) + } + paintConnections(grid, cells, y) + grid.setCell(lane * LANE_WIDTH, y, commitGlyph(commit), commitStyle(commit)) +} + +function connectHorizontal( + cells: Map, + sourceLane: number, + targetLane: number, + vertical: { sourceUp?: boolean; sourceDown?: boolean; targetUp?: boolean; targetDown?: boolean }, + style: GitGraphCellStyle, +): void { + if (sourceLane === targetLane) return + const source = sourceLane * LANE_WIDTH + const target = targetLane * LANE_WIDTH + const direction = Math.sign(target - source) + connect( + cells, + source, + { ...verticalAt(vertical.sourceUp, vertical.sourceDown), ...(direction > 0 ? { right: true } : { left: true }) }, + style, + ) + for (let x = source + direction; x !== target; x += direction) { + connect(cells, x, { left: true, right: true }, style) + } + connect( + cells, + target, + { ...verticalAt(vertical.targetUp, vertical.targetDown), ...(direction > 0 ? { left: true } : { right: true }) }, + style, + ) +} + +function verticalAt(up: boolean | undefined, down: boolean | undefined): Pick { + return { ...(up ? { up: true } : {}), ...(down ? { down: true } : {}) } +} + +function connect( + cells: Map, + x: number, + additions: Omit, + style: GitGraphCellStyle, +): void { + const current = cells.get(x) + cells.set(x, { ...current, ...additions, style: current?.style ?? style }) +} + +function paintConnections(grid: GitGraphGrid, cells: Map, y: number): void { + for (const [x, connections] of cells) grid.setCell(x, y, connectionGlyph(connections), connections.style) +} + +function connectionGlyph({ up, down, left, right }: Connections): string { + const mask = `${up ? 1 : 0}${down ? 1 : 0}${left ? 1 : 0}${right ? 1 : 0}` + const glyphs: Record = { + "1100": "│", + "0011": "─", + "0101": "╭", + "0110": "╮", + "1001": "╰", + "1010": "╯", + "1101": "├", + "1110": "┤", + "0111": "┬", + "1011": "┴", + "1111": "┼", + "1000": "│", + "0100": "│", + "0010": "─", + "0001": "─", + } + return glyphs[mask] ?? " " +} + +function branchSpans(diagram: GitGraphDiagram, commitById: Map): Map { + const spans = new Map() + diagram.commits.forEach((commit, index) => { + const span = spans.get(commit.branch) + if (span) span.last = index + else spans.set(commit.branch, { first: index, last: index }) + for (const parentId of commit.parents) { + const parent = commitById.get(parentId) + if (!parent || parent.branch === commit.branch) continue + const parentSpan = spans.get(parent.branch) + if (parentSpan) parentSpan.last = Math.max(parentSpan.last, index) + } + }) + return spans +} + +function branchHeads(diagram: GitGraphDiagram): Map { + const heads = new Map() + for (const branch of diagram.branches) { + if (branch.head === undefined) continue + const names = heads.get(branch.head) ?? [] + names.push(branch.name) + heads.set(branch.head, names) + } + return heads +} + +function isFork( + commit: GitGraphCommit, + laneByBranch: Map, + commitById: Map, +): boolean { + const parent = commit.parents[0] === undefined ? undefined : commitById.get(commit.parents[0]) + return parent !== undefined && laneByBranch.get(parent.branch) !== laneByBranch.get(commit.branch) +} + +function commitGlyph(commit: GitGraphCommit): string { + if (commit.type === "REVERSE") return "⊗" + if (commit.type === "HIGHLIGHT") return "◆" + return commit.parents.length > 1 ? "◎" : "●" +} + +function commitStyle(commit: GitGraphCommit): GitGraphCellStyle { + if (commit.type === "REVERSE") return "reverse" + if (commit.type === "HIGHLIGHT") return "highlight" + return commit.parents.length > 1 ? "merge" : "commit" +} + +function commitLabel(commit: GitGraphCommit, heads: Map): string { + const subject = commit.message ?? commit.id + const decorations = [...(heads.get(commit.id) ?? []), ...commit.tags].map((value) => `[${value}]`) + return decorations.length === 0 ? subject : `${subject} ${decorations.join(" ")}` +} + +function branchStyle(lane: number): GitGraphCellStyle { + return `branch${lane % 8}` as GitGraphCellStyle +} diff --git a/packages/merman/src/gitgraph/parser.ts b/packages/merman/src/gitgraph/parser.ts new file mode 100644 index 0000000000..f77ec4dfa0 --- /dev/null +++ b/packages/merman/src/gitgraph/parser.ts @@ -0,0 +1,219 @@ +import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js" +import { MermaidSyntaxError } from "../diagnostics.js" +import type { GitGraphBranch, GitGraphCommit, GitGraphCommitType, GitGraphDiagram, GitGraphDirection } from "./types.js" + +const HEADER_RE = /^gitGraph(?:\s+(LR|TB|BT))?\s*:?$/i +const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i + +export function isMermaidGitGraphDiagram(content: string): boolean { + return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "") +} + +export function parseMermaidGitGraphDiagram(content: string): GitGraphDiagram { + const firstLine = firstMeaningfulMermaidLine(content) + if (!HEADER_RE.test(firstLine ?? "")) throw syntaxError(1, firstLine ?? "", "GitGraph header is required") + const branches: GitGraphBranch[] = [{ name: "main", order: 0 }] + const commits: GitGraphCommit[] = [] + const heads = new Map([["main", undefined]]) + const ids = new Set() + let direction: GitGraphDirection = "LR" + let currentBranch = "main" + let generatedId = 1 + let inAccessibilityDescription = false + let headerSeen = false + + for (const source of meaningfulNumberedMermaidLines(content)) { + const line = stripComment(source.text) + if (inAccessibilityDescription) { + if (line === "}") inAccessibilityDescription = false + continue + } + if (/^accDescr\s*\{$/i.test(line)) { + inAccessibilityDescription = true + continue + } + if (!line || ACCESSIBILITY_RE.test(line) || /^title(?:\s|$)/i.test(line)) continue + + const header = line.match(HEADER_RE) + if (header) { + if (headerSeen) throw syntaxError(source.lineNumber, line, "GitGraph header can only appear once") + headerSeen = true + direction = (header[1]?.toUpperCase() as GitGraphDirection | undefined) ?? "LR" + continue + } + + const [command = "", ...rest] = tokenize(line) + const operation = command.toLowerCase() + if (operation === "commit") { + const shorthandMessage = rest[0]?.match(/^(["']).*\1$/) ? stripMermaidQuotes(rest.shift()!) : undefined + const attributes = parseAttributes(rest, source.lineNumber, line, ["id", "msg", "tag", "type"]) + const id = single(attributes, "id", source.lineNumber, line) ?? `commit-${generatedId++}` + if (!id) throw syntaxError(source.lineNumber, line, "GitGraph commit id cannot be empty") + if (ids.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate commit id "${id}"`) + const type = parseCommitType(single(attributes, "type", source.lineNumber, line), source.lineNumber, line) + const parent = heads.get(currentBranch) + const message = single(attributes, "msg", source.lineNumber, line) ?? shorthandMessage + const commit: GitGraphCommit = { + id, + ...(message === undefined ? {} : { message }), + tags: attributes.get("tag") ?? [], + type, + branch: currentBranch, + parents: parent === undefined ? [] : [parent], + } + commits.push(commit) + ids.add(id) + heads.set(currentBranch, id) + continue + } + + if (operation === "branch") { + if (rest.length === 0) throw syntaxError(source.lineNumber, line, "GitGraph branch name cannot be empty") + const name = stripMermaidQuotes(rest[0]!) + if (!name) throw syntaxError(source.lineNumber, line, "GitGraph branch name cannot be empty") + if (heads.has(name)) throw syntaxError(source.lineNumber, line, `Duplicate branch "${name}"`) + const attributes = parseAttributes(rest.slice(1), source.lineNumber, line, ["order"]) + const orderValue = single(attributes, "order", source.lineNumber, line) + const order = orderValue === undefined ? undefined : Number(orderValue) + if (order !== undefined && (!Number.isInteger(order) || order < 0)) { + throw syntaxError(source.lineNumber, line, "GitGraph branch order must be a non-negative integer") + } + branches.push({ name, ...(order === undefined ? {} : { order }) }) + heads.set(name, heads.get(currentBranch)) + currentBranch = name + continue + } + + if (operation === "checkout" || operation === "switch") { + if (rest.length !== 1) throw syntaxError(source.lineNumber, line, `GitGraph ${operation} requires one branch`) + const name = stripMermaidQuotes(rest[0]!) + if (!heads.has(name)) throw syntaxError(source.lineNumber, line, `Unknown branch "${name}"`) + currentBranch = name + continue + } + + if (operation === "merge") { + if (rest.length === 0) throw syntaxError(source.lineNumber, line, "GitGraph merge requires a branch") + const branch = stripMermaidQuotes(rest[0]!) + if (!heads.has(branch)) throw syntaxError(source.lineNumber, line, `Unknown branch "${branch}"`) + if (branch === currentBranch) + throw syntaxError(source.lineNumber, line, "GitGraph cannot merge a branch into itself") + const currentHead = heads.get(currentBranch) + const mergedHead = heads.get(branch) + if (currentHead === undefined) + throw syntaxError(source.lineNumber, line, `Branch "${currentBranch}" has no commits`) + if (mergedHead === undefined) throw syntaxError(source.lineNumber, line, `Branch "${branch}" has no commits`) + if (currentHead === mergedHead) + throw syntaxError(source.lineNumber, line, `Branches already share head "${mergedHead}"`) + const attributes = parseAttributes(rest.slice(1), source.lineNumber, line, ["id", "tag", "type"]) + const id = single(attributes, "id", source.lineNumber, line) ?? `commit-${generatedId++}` + if (ids.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate commit id "${id}"`) + const commit: GitGraphCommit = { + id, + tags: attributes.get("tag") ?? [], + type: parseCommitType(single(attributes, "type", source.lineNumber, line), source.lineNumber, line), + branch: currentBranch, + parents: [currentHead, mergedHead], + } + commits.push(commit) + ids.add(id) + heads.set(currentBranch, id) + continue + } + + if (operation === "cherry-pick") { + throw syntaxError(source.lineNumber, line, "Cherry-pick is not supported") + } + throw syntaxError(source.lineNumber, line) + } + + const resolvedBranches = branches.map((branch) => { + const head = heads.get(branch.name) + return { ...branch, ...(head === undefined ? {} : { head }) } + }) + return { direction, branches: orderBranches(resolvedBranches), commits } +} + +function tokenize(line: string): string[] { + const tokens: string[] = [] + let token = "" + let quote: '"' | "'" | undefined + for (const char of line) { + if ((char === '"' || char === "'") && (quote === undefined || quote === char)) { + quote = quote === char ? undefined : char + token += char + continue + } + if (/\s/.test(char) && quote === undefined) { + if (token) tokens.push(token) + token = "" + continue + } + token += char + } + if (quote !== undefined) return [line] + if (token) tokens.push(token) + return tokens +} + +function parseAttributes( + tokens: string[], + lineNumber: number, + line: string, + allowed: readonly string[], +): Map { + const result = new Map() + for (let index = 0; index < tokens.length; index += 1) { + const keyToken = tokens[index]! + const separator = keyToken.indexOf(":") + const key = (separator < 0 ? keyToken : keyToken.slice(0, separator)).toLowerCase() + if (!allowed.includes(key)) throw syntaxError(lineNumber, line, `Unsupported GitGraph attribute "${key}"`) + const inline = separator < 0 ? "" : keyToken.slice(separator + 1) + const valueToken = inline || tokens[++index] + if (valueToken === undefined) throw syntaxError(lineNumber, line, `GitGraph attribute "${key}" requires a value`) + const values = result.get(key) ?? [] + values.push(stripMermaidQuotes(valueToken)) + result.set(key, values) + } + return result +} + +function single(attributes: Map, key: string, lineNumber: number, line: string): string | undefined { + const values = attributes.get(key) + if (values && values.length > 1) throw syntaxError(lineNumber, line, `GitGraph attribute "${key}" cannot repeat`) + return values?.[0] +} + +function parseCommitType(value: string | undefined, lineNumber: number, line: string): GitGraphCommitType { + if (value === undefined) return "NORMAL" + const type = value.toUpperCase() + if (type === "NORMAL" || type === "REVERSE" || type === "HIGHLIGHT") return type + throw syntaxError(lineNumber, line, `Unknown GitGraph commit type "${value}"`) +} + +function orderBranches(branches: GitGraphBranch[]): GitGraphBranch[] { + const main = branches[0]! + const rest = branches.slice(1).map((branch, index) => ({ branch, index })) + const unordered = rest.filter(({ branch }) => branch.order === undefined) + const ordered = rest + .filter(({ branch }) => branch.order !== undefined) + .sort((left, right) => left.branch.order! - right.branch.order! || left.index - right.index) + return [main, ...unordered.map(({ branch }) => branch), ...ordered.map(({ branch }) => branch)] +} + +function stripComment(value: string): string { + let quote: '"' | "'" | undefined + for (let index = 0; index < value.length - 1; index += 1) { + const char = value[index] + if ((char === '"' || char === "'") && (quote === undefined || quote === char)) { + quote = quote === char ? undefined : char + continue + } + if (quote === undefined && char === "%" && value[index + 1] === "%") return value.slice(0, index).trim() + } + return value.trim() +} + +function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError { + return new MermaidSyntaxError("gitGraph", lineNumber, sourceLine, reason) +} diff --git a/packages/merman/src/gitgraph/render-grid.ts b/packages/merman/src/gitgraph/render-grid.ts new file mode 100644 index 0000000000..90874eaa42 --- /dev/null +++ b/packages/merman/src/gitgraph/render-grid.ts @@ -0,0 +1,17 @@ +import type { StyledText } from "@opentui/core" +import type { DiagramCanvas } from "../core/canvas.js" +import { renderDiagramGridStyledText } from "../core/render-grid.js" +import type { GitGraphStyleColors } from "./style.js" +import type { GitGraphCellStyle } from "./types.js" + +export type GitGraphGrid = DiagramCanvas + +export function renderGitGraphGridText(grid: GitGraphGrid): string { + return grid.toString({ trimBottom: true }) +} + +export function renderGitGraphGridStyledText(grid: GitGraphGrid, colors: GitGraphStyleColors): StyledText { + return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, { + trimBottom: true, + }) +} diff --git a/packages/merman/src/gitgraph/style.ts b/packages/merman/src/gitgraph/style.ts new file mode 100644 index 0000000000..966a53821d --- /dev/null +++ b/packages/merman/src/gitgraph/style.ts @@ -0,0 +1,37 @@ +import { RGBA } from "@opentui/core" +import { rgba, type DiagramRgb } from "../core/color/style.js" +import type { GitGraphCellStyle } from "./types.js" + +const BRANCH_RGB = [ + [134, 225, 200], + [230, 177, 126], + [154, 184, 169], + [198, 160, 246], + [126, 189, 230], + [225, 134, 166], + [190, 210, 120], + [180, 180, 210], +] as const satisfies readonly DiagramRgb[] + +export type GitGraphStyleColors = Required> + +export function resolveGitGraphStyleColors( + colors: Partial> = {}, +): GitGraphStyleColors { + const rail = colors.muted ?? rgba([111, 138, 126]) + return { + branch0: rail, + branch1: rail, + branch2: rail, + branch3: rail, + branch4: rail, + branch5: rail, + branch6: rail, + branch7: rail, + commit: colors.primary ?? rgba(BRANCH_RGB[0]), + merge: colors.secondary ?? rgba(BRANCH_RGB[2]), + highlight: colors.warning ?? rgba(BRANCH_RGB[1]), + reverse: colors.warning ?? rgba(BRANCH_RGB[5]), + label: colors.text ?? rgba([228, 239, 232]), + } +} diff --git a/packages/merman/src/gitgraph/types.ts b/packages/merman/src/gitgraph/types.ts new file mode 100644 index 0000000000..ad858e64bc --- /dev/null +++ b/packages/merman/src/gitgraph/types.ts @@ -0,0 +1,36 @@ +export type GitGraphDirection = "LR" | "TB" | "BT" +export type GitGraphCommitType = "NORMAL" | "REVERSE" | "HIGHLIGHT" + +export interface GitGraphBranch { + name: string + order?: number + head?: string +} + +export interface GitGraphCommit { + id: string + message?: string + tags: string[] + type: GitGraphCommitType + branch: string + parents: string[] +} + +export interface GitGraphDiagram { + direction: GitGraphDirection + branches: GitGraphBranch[] + commits: GitGraphCommit[] +} + +export interface GitGraphDiagramRenderOptions { + /** Parsed for Mermaid compatibility. Git graphs always use a vertical terminal layout. */ + direction?: GitGraphDirection +} + +export type GitGraphCellStyle = + | `branch${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7}` + | "commit" + | "merge" + | "highlight" + | "reverse" + | "label" diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts index e933742869..5b4dc55e63 100644 --- a/packages/merman/src/markdown.ts +++ b/packages/merman/src/markdown.ts @@ -17,6 +17,10 @@ import { detectMermaidDiagram } from "./detect.js" import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js" import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js" import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js" +import { drawGitGraphDiagramGrid } from "./gitgraph/drawing.js" +import { parseMermaidGitGraphDiagram } from "./gitgraph/parser.js" +import { renderGitGraphGridStyledText } from "./gitgraph/render-grid.js" +import { resolveGitGraphStyleColors } from "./gitgraph/style.js" import { drawSequenceDiagramGrid } from "./sequence/drawing.js" import { parseMermaidSequenceDiagram } from "./sequence/parser.js" import { renderSequenceGridStyledText } from "./sequence/render-grid.js" @@ -137,6 +141,25 @@ function prepareDiagram( height: size.height, } } + case "gitGraph": { + const grid = drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(source)) + const size = grid.getTextSize({ trimBottom: true }) + return { + kind, + source, + text: renderGitGraphGridStyledText( + grid, + resolveGitGraphStyleColors({ + primary: color(colors.primary), + secondary: color(colors.secondary), + muted: color(colors.muted), + warning: color(colors.warning), + text: color(colors.text), + }), + ), + height: size.height, + } + } case "sequence": { const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact: options.compact }) const size = grid.getTextSize() diff --git a/packages/merman/src/test/diagnostics.test.ts b/packages/merman/src/test/diagnostics.test.ts index 5ccebb2ac6..0dfe57bc5e 100644 --- a/packages/merman/src/test/diagnostics.test.ts +++ b/packages/merman/src/test/diagnostics.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { MermaidSyntaxError } from "../diagnostics.js" +import { renderGitGraphDiagram } from "../gitgraph/diagram.js" import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js" import { parseMermaidSequenceDiagram } from "../sequence/parser.js" import { parseMermaidStateDiagram } from "../state/parser.js" @@ -111,6 +112,12 @@ describe("parser diagnostics", () => { ) }) + test("reports unsupported GitGraph operations with source diagnostics", () => { + expect(() => renderGitGraphDiagram("gitGraph\n cherry-pick id: missing")).toThrow( + 'Cherry-pick is not supported in gitGraph diagram at line 2: "cherry-pick id: missing"', + ) + }) + test("does not attach else through an unclosed nested sequence block", () => { expect(() => parseMermaidSequenceDiagram(`sequenceDiagram diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts index 49c3423c2d..380aa4034a 100644 --- a/packages/merman/src/test/markdown.test.ts +++ b/packages/merman/src/test/markdown.test.ts @@ -361,3 +361,28 @@ timeline expect(frame).toContain("First release") expect(frame).not.toContain("timeline") }) + +test("renders a Mermaid GitGraph fence inside MarkdownRenderable", async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 18 }) + renderer = testRenderer.renderer + const markdown = new MarkdownRenderable(renderer, { + id: "markdown-gitgraph", + content: `\`\`\`mermaid +gitGraph + commit id: "baseline" + branch feature + commit id: "ship" +\`\`\``, + syntaxStyle, + treeSitterClient, + renderNode: createMermaidMarkdownRenderer(renderer), + }) + + renderer.root.add(markdown) + await renderMarkdown(markdown, testRenderer.renderOnce) + + const frame = testRenderer.captureCharFrame() + expect(frame).toContain("baseline") + expect(frame).toContain("ship") + expect(frame).not.toContain("gitGraph") +}) From 9d6e05b6e4e8e9cd975a64785d416bae978a1f42 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 20:15:56 -0400 Subject: [PATCH 04/20] fix(cli): inset update footer (#42189) --- packages/cli/src/services/update-preflight.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index e104245095..f18f4a8ff3 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -447,7 +447,7 @@ function UpdateFooter(props: { }) return ( - + From b17fbf41e30fa79fd1584770e07b3ad2c6e508da Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 20:29:32 -0400 Subject: [PATCH 05/20] feat(catalog): click-to-annotate captures with GitHub issue handoff (#42183) --- packages/lab/catalog/catalog/feedback.test.ts | 36 ++ packages/lab/catalog/src/App.tsx | 14 +- packages/lab/catalog/src/annotations.ts | 82 ++++ .../src/components/AnnotationEditor.tsx | 163 ++++++++ .../src/components/CaptureSetSwitcher.tsx | 11 +- .../lab/catalog/src/components/Viewer.tsx | 80 +++- packages/lab/catalog/src/feedback.ts | 27 +- packages/lab/catalog/src/styles.css | 352 +++++++++++++++++- 8 files changed, 740 insertions(+), 25 deletions(-) create mode 100644 packages/lab/catalog/src/annotations.ts create mode 100644 packages/lab/catalog/src/components/AnnotationEditor.tsx diff --git a/packages/lab/catalog/catalog/feedback.test.ts b/packages/lab/catalog/catalog/feedback.test.ts index b614e12993..cb50a2a691 100644 --- a/packages/lab/catalog/catalog/feedback.test.ts +++ b/packages/lab/catalog/catalog/feedback.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { feedbackIssueUrl } from "../src/feedback" +import { annotationUrl, readAnnotations } from "../src/annotations" describe("catalog feedback", () => { test("opens a prefilled issue for an exact capture", () => { @@ -18,4 +19,39 @@ describe("catalog feedback", () => { expect(url.searchParams.get("body")).toContain("`skill-picker`") expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode") }) + + test("round-trips a capture annotation document through the URL fragment", () => { + const document = { + version: 1 as const, + identifier: "skill-picker", + variant: "opencode", + annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }], + } + const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document)) + + expect(url.hash).toStartWith("#annotations=") + expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations) + expect(readAnnotations(url, "other-screen", "opencode")).toEqual([]) + }) + + test("includes human and machine-readable annotations in the issue", () => { + const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }] + const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations } + const url = new URL( + feedbackIssueUrl({ + title: "Skill picker", + identifier: "skill-picker", + deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document), + variant: "opencode", + annotations, + document, + }), + ) + const body = url.searchParams.get("body") ?? "" + + expect(body).toContain("## 1. Row 5, column 13") + expect(body).toContain("This label needs more contrast.") + expect(body).toContain("Annotation data") + expect(body).toContain('"row": 4') + }) }) diff --git a/packages/lab/catalog/src/App.tsx b/packages/lab/catalog/src/App.tsx index 407487711f..7e65612daa 100644 --- a/packages/lab/catalog/src/App.tsx +++ b/packages/lab/catalog/src/App.tsx @@ -334,6 +334,7 @@ export function App({ catalog }: AppProps) { }, []) useEffect(() => { + if (ui.viewerOpen) return window.history.replaceState( null, "", @@ -349,18 +350,22 @@ export function App({ catalog }: AppProps) { states: ui.facets.state, }), ) - }, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements]) + }, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen]) useEffect(() => { if (!ui.viewerOpen || !selectedScreen) return - window.history.replaceState( - null, - "", + const url = new URL( catalogDeepLink(selectedScreen.id, { flowId: ui.mode === "flows" ? activeFlow?.id : undefined, variantId: activeVariant.id, }), ) + if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash + window.history.replaceState( + null, + "", + url, + ) }, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen]) useEffect(() => { @@ -483,6 +488,7 @@ export function App({ catalog }: AppProps) { {ui.viewerOpen && selectedScreen ? ( +} + +const FragmentKey = "annotations" +const MaxAnnotations = 24 +const MaxNoteLength = 2_000 + +export function annotationUrl(deepLink: string, document: AnnotationDocument) { + const url = new URL(deepLink) + url.hash = `${FragmentKey}=${encode(document)}` + return url.href +} + +export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray { + const params = new URLSearchParams(url.hash.slice(1)) + const encoded = params.get(FragmentKey) + if (!encoded) return [] + const value = decode(encoded) + if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return [] + return value.annotations +} + +export function readAnnotationDraft(value: string): ReadonlyArray { + try { + const annotations: unknown = JSON.parse(value) + return isAnnotations(annotations) ? annotations : [] + } catch { + return [] + } +} + +function encode(value: AnnotationDocument) { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join("")) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, "") +} + +function decode(value: string): unknown { + try { + const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")) + return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)))) + } catch { + return undefined + } +} + +function isDocument(value: unknown): value is AnnotationDocument { + if (!value || typeof value !== "object") return false + const document = value as Partial + if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string") + return false + return isAnnotations(document.annotations) +} + +function isAnnotations(value: unknown): value is ReadonlyArray { + if (!Array.isArray(value) || value.length > MaxAnnotations) return false + return value.every( + (annotation) => + annotation && + typeof annotation === "object" && + typeof annotation.id === "string" && + Number.isInteger(annotation.row) && + annotation.row >= 0 && + Number.isInteger(annotation.column) && + annotation.column >= 0 && + typeof annotation.note === "string" && + annotation.note.length <= MaxNoteLength, + ) +} diff --git a/packages/lab/catalog/src/components/AnnotationEditor.tsx b/packages/lab/catalog/src/components/AnnotationEditor.tsx new file mode 100644 index 0000000000..ace8705eb7 --- /dev/null +++ b/packages/lab/catalog/src/components/AnnotationEditor.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from "react" +import type { Annotation } from "../annotations" + +interface AnnotationEditorProps { + readonly cols: number + readonly rows: number + readonly annotations: ReadonlyArray + readonly onAdd: (row: number, column: number, note: string) => void + readonly onChange: (id: string, note: string) => void + readonly onDelete: (id: string) => void + readonly issueLink: string + readonly onDone: () => void +} + +interface Draft { + readonly id?: string + readonly row: number + readonly column: number + readonly note: string +} + +export function AnnotationEditor(props: AnnotationEditorProps) { + const [draft, setDraft] = useState() + const textareaRef = useRef(null) + const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "") + + useEffect(() => { + if (!draft) return + const frame = requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length) + }) + return () => cancelAnimationFrame(frame) + }, [draft?.id, draft?.row, draft?.column]) + + const save = () => { + if (!draft?.note.trim()) return + if (draft.id) props.onChange(draft.id, draft.note.trim()) + else props.onAdd(draft.row, draft.column, draft.note.trim()) + setDraft(undefined) + } + + const edit = (annotation: Annotation) => + setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note }) + + return ( + <> +
{ + if (event.target !== event.currentTarget) return + const bounds = event.currentTarget.getBoundingClientRect() + const column = Math.min(props.cols - 1, Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols))) + const row = Math.min(props.rows - 1, Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows))) + setDraft({ row, column, note: "" }) + }} + > + {props.annotations.map((annotation, index) => ( + + ))} + {draft ? ( +
props.rows / 2 ? " above" : ""}`} + style={{ + left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`, + top: `${((draft.row + 0.5) / props.rows) * 100}%`, + }} + onPointerDown={(event) => event.stopPropagation()} + > +
+ {draft.id ? "Edit annotation" : "New annotation"} + R{draft.row + 1} · C{draft.column + 1} +
+