mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 12:45:07 +00:00
feat(tui): render Mermaid GitGraph diagrams (#42179)
This commit is contained in:
parent
d31a994c27
commit
9b805c140f
12 changed files with 792 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
183
packages/merman/src/gitgraph/diagram.test.ts
Normal file
183
packages/merman/src/gitgraph/diagram.test.ts
Normal file
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
8
packages/merman/src/gitgraph/diagram.ts
Normal file
8
packages/merman/src/gitgraph/diagram.ts
Normal file
|
|
@ -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))
|
||||
}
|
||||
234
packages/merman/src/gitgraph/drawing.ts
Normal file
234
packages/merman/src/gitgraph/drawing.ts
Normal file
|
|
@ -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<string, BranchSpan>,
|
||||
laneByBranch: Map<string, number>,
|
||||
commitById: Map<string, GitGraphCommit>,
|
||||
commit: GitGraphCommit,
|
||||
index: number,
|
||||
y: number,
|
||||
): void {
|
||||
const cells = new Map<number, Connections>()
|
||||
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<string, BranchSpan>,
|
||||
laneByBranch: Map<string, number>,
|
||||
commitById: Map<string, GitGraphCommit>,
|
||||
commit: GitGraphCommit,
|
||||
index: number,
|
||||
y: number,
|
||||
): void {
|
||||
const cells = new Map<number, Connections>()
|
||||
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<number, Connections>,
|
||||
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<Connections, "up" | "down"> {
|
||||
return { ...(up ? { up: true } : {}), ...(down ? { down: true } : {}) }
|
||||
}
|
||||
|
||||
function connect(
|
||||
cells: Map<number, Connections>,
|
||||
x: number,
|
||||
additions: Omit<Connections, "style">,
|
||||
style: GitGraphCellStyle,
|
||||
): void {
|
||||
const current = cells.get(x)
|
||||
cells.set(x, { ...current, ...additions, style: current?.style ?? style })
|
||||
}
|
||||
|
||||
function paintConnections(grid: GitGraphGrid, cells: Map<number, Connections>, 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<string, string> = {
|
||||
"1100": "│",
|
||||
"0011": "─",
|
||||
"0101": "╭",
|
||||
"0110": "╮",
|
||||
"1001": "╰",
|
||||
"1010": "╯",
|
||||
"1101": "├",
|
||||
"1110": "┤",
|
||||
"0111": "┬",
|
||||
"1011": "┴",
|
||||
"1111": "┼",
|
||||
"1000": "│",
|
||||
"0100": "│",
|
||||
"0010": "─",
|
||||
"0001": "─",
|
||||
}
|
||||
return glyphs[mask] ?? " "
|
||||
}
|
||||
|
||||
function branchSpans(diagram: GitGraphDiagram, commitById: Map<string, GitGraphCommit>): Map<string, BranchSpan> {
|
||||
const spans = new Map<string, BranchSpan>()
|
||||
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<string, string[]> {
|
||||
const heads = new Map<string, string[]>()
|
||||
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<string, number>,
|
||||
commitById: Map<string, GitGraphCommit>,
|
||||
): 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, string[]>): 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
|
||||
}
|
||||
219
packages/merman/src/gitgraph/parser.ts
Normal file
219
packages/merman/src/gitgraph/parser.ts
Normal file
|
|
@ -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<string, string | undefined>([["main", undefined]])
|
||||
const ids = new Set<string>()
|
||||
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<string, string[]> {
|
||||
const result = new Map<string, string[]>()
|
||||
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<string, string[]>, 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)
|
||||
}
|
||||
17
packages/merman/src/gitgraph/render-grid.ts
Normal file
17
packages/merman/src/gitgraph/render-grid.ts
Normal file
|
|
@ -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<GitGraphCellStyle>
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
37
packages/merman/src/gitgraph/style.ts
Normal file
37
packages/merman/src/gitgraph/style.ts
Normal file
|
|
@ -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<Record<GitGraphCellStyle, RGBA>>
|
||||
|
||||
export function resolveGitGraphStyleColors(
|
||||
colors: Partial<Record<"primary" | "secondary" | "muted" | "warning" | "text", RGBA | undefined>> = {},
|
||||
): 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]),
|
||||
}
|
||||
}
|
||||
36
packages/merman/src/gitgraph/types.ts
Normal file
36
packages/merman/src/gitgraph/types.ts
Normal file
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue