mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 04:59:58 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae7a954a8 |
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
|
||||
<Monogram ink={monogramInk} />
|
||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<CellLine cells={header()} />
|
||||
|
||||
@@ -56,7 +56,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const timing = ensureTiming(options)
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
@@ -80,18 +79,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const registration = yield* registered(options.file, true, timing.requestTimeout)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (registration.timedOut && info !== undefined) {
|
||||
timeouts = {
|
||||
info,
|
||||
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
@@ -99,8 +86,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
if (compatible && service.state === "failed")
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* kill(service, timing)
|
||||
yield* announce("version-mismatch", service.version)
|
||||
yield* kill(service, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -133,8 +120,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
|
||||
const registration = yield* registered(options.file, true)
|
||||
if (registration.service !== undefined) yield* kill(registration.service, defaultEnsureTiming)
|
||||
if (registration.service === undefined && registration.info !== undefined)
|
||||
return yield* Effect.fail(
|
||||
new Error("Background service is not responding; stop its process manually and try again"),
|
||||
)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
@@ -243,20 +234,10 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||
return (yield* registered(options.file, true)).service
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
// discovery window.
|
||||
// Poll until an authenticated stop exits, bounded by the configured stop window.
|
||||
const poll = (timing: EnsureTiming) =>
|
||||
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
|
||||
|
||||
const signal = (pid: number, name: NodeJS.Signals) =>
|
||||
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
|
||||
|
||||
const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
|
||||
Effect.orElseSucceed(() => false),
|
||||
@@ -265,44 +246,14 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (
|
||||
service: LocalService,
|
||||
options: { readonly file?: string },
|
||||
timing: EnsureTiming,
|
||||
) {
|
||||
const kill = Effect.fnUntraced(function* (service: LocalService, timing: EnsureTiming) {
|
||||
const requested = yield* requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
// A stale registration may point at a reused PID. Authenticate again
|
||||
// immediately before the legacy signal fallback.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected the stop request"))
|
||||
if (requested === "unsupported")
|
||||
return yield* Effect.fail(new Error("Background service does not support authenticated stop requests"))
|
||||
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGKILL")
|
||||
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
|
||||
return yield* Effect.fail(new Error("Background service accepted the stop request but did not exit"))
|
||||
})
|
||||
|
||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||
@@ -317,7 +268,8 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
if (response === undefined) return "rejected" as const
|
||||
if (response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const decoded = decodeStopResponse(body)
|
||||
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
|
||||
|
||||
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const timing = ensureTiming(options)
|
||||
const deadline = Date.now() + timing.promiseTimeout
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
@@ -61,19 +60,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
@@ -81,8 +67,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
await kill(service, timing)
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +96,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
|
||||
const registration = await registered(options.file, true)
|
||||
if (registration.service !== undefined) await kill(registration.service, defaultEnsureTiming)
|
||||
if (registration.service === undefined && registration.info !== undefined)
|
||||
throw new Error("Background service is not responding; stop its process manually and try again")
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -199,16 +187,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
|
||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
return (await registered(options.file, true)).service
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
try {
|
||||
process.kill(pid, name)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stopped(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
@@ -226,37 +204,12 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
|
||||
return false
|
||||
}
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid, timing)) return
|
||||
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
async function kill(service: LocalService, timing: EnsureTiming) {
|
||||
const requested = await requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
const current = await find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (requested === "rejected") throw new Error("Background service rejected the stop request")
|
||||
if (requested === "unsupported") throw new Error("Background service does not support authenticated stop requests")
|
||||
if (await waitUntilStopped(service.info.pid, timing)) return
|
||||
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid, timing)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
throw new Error("Background service accepted the stop request but did not exit")
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
@@ -267,7 +220,8 @@ async function requestStop(service: LocalService, timeout = defaultEnsureTiming.
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => undefined)
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
if (response === undefined) return "rejected" as const
|
||||
if (response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||
return "accepted" as const
|
||||
|
||||
@@ -28,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
||||
if (mode === "old" || mode === "reject-stop" || mode === "stop-hanging" || mode === "stop-accepted-hanging") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
@@ -40,7 +40,15 @@ const server = Bun.serve({
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: false })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "graceful") {
|
||||
if (pathname === "/api/service/stop" && mode === "stop-hanging") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "stop-accepted-hanging") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: true })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && (mode === "graceful" || mode === "old" || mode === "incompatible")) {
|
||||
const body = await request.json()
|
||||
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
|
||||
await writeFile(registration + ".stop", JSON.stringify(body))
|
||||
@@ -63,7 +71,13 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
||||
if (
|
||||
mode === "starting" ||
|
||||
mode === "graceful" ||
|
||||
mode === "reject-stop" ||
|
||||
mode === "stop-hanging" ||
|
||||
mode === "stop-accepted-hanging"
|
||||
)
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
|
||||
@@ -100,7 +100,7 @@ test("reports a bounded contender stderr tail with native promises", async () =>
|
||||
expect(error.message.length).toBeLessThan(9_000)
|
||||
}, 10_000)
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
test("never evicts an unresponsive registered service automatically", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||
@@ -111,19 +111,25 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await ensure({
|
||||
const result = ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
command: [process.execPath, fixture, registration, "record-start"],
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
await waitForLines(registration + ".requests", 3)
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
await expect(result).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("explicit native stop refuses to signal an unidentified unresponsive PID", async () => {
|
||||
const registration = await setup("hanging")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await expect(Service.stop({ file: registration })).rejects.toThrow("stop its process manually")
|
||||
|
||||
expect(process.kill(info.pid, 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
@@ -135,6 +141,29 @@ test("requests graceful stop of the exact service instance", async () => {
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
test.each([
|
||||
["reject-stop", "rejected the stop request"],
|
||||
["stop-hanging", "rejected the stop request"],
|
||||
["stop-accepted-hanging", "accepted the stop request but did not exit"],
|
||||
["legacy", "does not support authenticated stop requests"],
|
||||
])("native replacement fails closed for %s service stop", async (mode, message) => {
|
||||
const registration = await setup(mode)
|
||||
const directory = await temp()
|
||||
const contender = join(directory, "contender.json")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await expect(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}),
|
||||
).rejects.toThrow(message)
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(process.kill(info.pid, 0)).toBe(true)
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
@@ -156,3 +185,14 @@ async function waitForFile(file: string) {
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForLines(file: string, count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const text = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (text.trim().split("\n").length >= count) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
|
||||
}
|
||||
|
||||
@@ -118,29 +118,39 @@ test("reports a failed registered service without spawning", async () => {
|
||||
expect(process.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
test("never evicts an unresponsive registered service automatically", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await run(
|
||||
const controller = new AbortController()
|
||||
const result = Effect.runPromise(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
command: [process.execPath, fixture, registration, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
await waitForLines(registration + ".requests", 3)
|
||||
controller.abort()
|
||||
await result.catch(() => undefined)
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
})
|
||||
|
||||
test("explicit stop refuses to signal an unidentified unresponsive PID", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
|
||||
await expect(run(Service.stop({ file: registration }))).rejects.toThrow("stop its process manually")
|
||||
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
@@ -161,36 +171,39 @@ test("does not spawn contenders while an incompatible service rejects replacemen
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, "reject-stop")
|
||||
await waitForFile(registration)
|
||||
const controller = new AbortController()
|
||||
const starting = Effect.runPromise(
|
||||
const starting = run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
}),
|
||||
)
|
||||
|
||||
await waitForLines(registration + ".stop-attempts", 2)
|
||||
controller.abort()
|
||||
await starting.catch(() => undefined)
|
||||
await expect(starting).rejects.toThrow("Background service rejected the stop request")
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
test.each([
|
||||
["stop-hanging", "rejected the stop request"],
|
||||
["stop-accepted-hanging", "accepted the stop request but did not exit"],
|
||||
["legacy", "does not support authenticated stop requests"],
|
||||
])("replacement fails closed for %s service stop", async (mode, message) => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "legacy")
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, mode)
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
const result = run(
|
||||
ensure({ file: registration, version: "test", command: [process.execPath, fixture, contender, "record-start"] }),
|
||||
)
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
await existing.exited
|
||||
await expect(result).rejects.toThrow(message)
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("waits for a slow winner while bounding lock probes", async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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", () => {
|
||||
@@ -19,39 +18,4 @@ 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("<summary>Annotation data</summary>")
|
||||
expect(body).toContain('"row": 4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -334,7 +334,6 @@ export function App({ catalog }: AppProps) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.viewerOpen) return
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
@@ -350,18 +349,18 @@ export function App({ catalog }: AppProps) {
|
||||
states: ui.facets.state,
|
||||
}),
|
||||
)
|
||||
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen])
|
||||
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.viewerOpen || !selectedScreen) return
|
||||
const url = new URL(
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
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(() => {
|
||||
@@ -484,7 +483,6 @@ export function App({ catalog }: AppProps) {
|
||||
</main>
|
||||
{ui.viewerOpen && selectedScreen ? (
|
||||
<Viewer
|
||||
key={`${selectedScreen.id}:${activeVariant.id}`}
|
||||
screen={selectedScreen}
|
||||
identifier={
|
||||
ui.mode === "flows" && activeFlow?.replayable ? `${activeFlow.id}/${selectedScreen.id}` : selectedScreen.id
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
export interface Annotation {
|
||||
readonly id: string
|
||||
readonly row: number
|
||||
readonly column: number
|
||||
readonly note: string
|
||||
}
|
||||
|
||||
export interface AnnotationDocument {
|
||||
readonly version: 1
|
||||
readonly identifier: string
|
||||
readonly variant: string
|
||||
readonly annotations: ReadonlyArray<Annotation>
|
||||
}
|
||||
|
||||
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<Annotation> {
|
||||
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<Annotation> {
|
||||
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<AnnotationDocument>
|
||||
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<Annotation> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { Annotation } from "../annotations"
|
||||
|
||||
interface AnnotationEditorProps {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly annotations: ReadonlyArray<Annotation>
|
||||
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<Draft>()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<>
|
||||
<div
|
||||
className="annotation-layer"
|
||||
aria-label="Click the terminal to add an annotation"
|
||||
onPointerDown={(event) => {
|
||||
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) => (
|
||||
<button
|
||||
key={annotation.id}
|
||||
type="button"
|
||||
className={`annotation-pin${annotation.id === draft?.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${((annotation.column + 0.5) / props.cols) * 100}%`,
|
||||
top: `${((annotation.row + 0.5) / props.rows) * 100}%`,
|
||||
}}
|
||||
aria-label={`Edit annotation ${index + 1}, row ${annotation.row + 1}, column ${annotation.column + 1}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => edit(annotation)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))}
|
||||
{draft ? (
|
||||
<div
|
||||
className={`annotation-composer${draft.row > 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()}
|
||||
>
|
||||
<header>
|
||||
<span>{draft.id ? "Edit annotation" : "New annotation"}</span>
|
||||
<small>
|
||||
R{draft.row + 1} · C{draft.column + 1}
|
||||
</small>
|
||||
</header>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="annotation-note"
|
||||
aria-label="Annotation note"
|
||||
value={draft.note}
|
||||
maxLength={2_000}
|
||||
rows={2}
|
||||
placeholder="What should change?"
|
||||
onChange={(event) => setDraft({ ...draft, note: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setDraft(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<footer>
|
||||
{draft.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-composer-delete"
|
||||
onClick={() => {
|
||||
if (draft.id) props.onDelete(draft.id)
|
||||
setDraft(undefined)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" onClick={() => setDraft(undefined)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="annotation-composer-save" disabled={!draft.note.trim()} onClick={save}>
|
||||
{draft.id ? "Save" : "Add"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<aside className="annotation-panel" aria-label="Capture annotations">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Annotations</strong>
|
||||
<span>
|
||||
{props.annotations.length === 0 ? "Click anywhere on the terminal" : `${props.annotations.length} placed`}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={props.onDone}>
|
||||
Done
|
||||
</button>
|
||||
</header>
|
||||
<div className="annotation-list">
|
||||
{props.annotations.map((annotation, index) => (
|
||||
<button key={annotation.id} type="button" className="annotation-list-row" onClick={() => edit(annotation)}>
|
||||
<span className="annotation-list-pin">{index + 1}</span>
|
||||
<span>
|
||||
<small>
|
||||
Row {annotation.row + 1} · Column {annotation.column + 1}
|
||||
</small>
|
||||
<strong>{annotation.note}</strong>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer>
|
||||
<a
|
||||
className="annotation-issue"
|
||||
href={complete.length === 0 ? undefined : props.issueLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={complete.length === 0}
|
||||
>
|
||||
Open GitHub issue
|
||||
</a>
|
||||
<span>
|
||||
{complete.length === 0
|
||||
? "Add a note to continue"
|
||||
: `${complete.length} note${complete.length === 1 ? "" : "s"} will be included`}
|
||||
</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ interface CaptureSetSwitcherProps {
|
||||
|
||||
export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitcherProps) {
|
||||
return (
|
||||
<label className="variant-switcher" title="Switch theme">
|
||||
<label className="variant-switcher" title={active.label}>
|
||||
<span className="sr-only">Theme</span>
|
||||
<select aria-label="Select theme" value={active.id} onChange={(event) => onSelect(event.target.value)}>
|
||||
{sets.map((set) => (
|
||||
<option key={set.id} value={set.id}>
|
||||
@@ -16,14 +17,8 @@ export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitche
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="variant-hint" aria-hidden="true">
|
||||
Theme
|
||||
</span>
|
||||
<span className="variant-name" aria-hidden="true">
|
||||
{active.label}
|
||||
</span>
|
||||
<span className="variant-chevron" aria-hidden="true">
|
||||
▾
|
||||
↓
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react"
|
||||
import { useEffect, useEffectEvent, useRef } from "react"
|
||||
import type { Facet, Filter, Screen, Taxonomy, TaxonomyGroup, Variant } from "../catalog"
|
||||
import { facetValues, frameFor, label, taxonomyLabel } from "../catalog"
|
||||
import { TerminalFrame } from "./TerminalFrame"
|
||||
@@ -6,14 +6,6 @@ import { CaptureSetSwitcher } from "./CaptureSetSwitcher"
|
||||
import { CaptureContextMenu } from "./CaptureContextMenu"
|
||||
import { feedbackIssueUrl } from "../feedback"
|
||||
import { CaptureActionsMenu } from "./CaptureActionsMenu"
|
||||
import {
|
||||
annotationUrl,
|
||||
readAnnotationDraft,
|
||||
readAnnotations,
|
||||
type Annotation,
|
||||
type AnnotationDocument,
|
||||
} from "../annotations"
|
||||
import { AnnotationEditor } from "./AnnotationEditor"
|
||||
|
||||
interface ViewerProps {
|
||||
readonly screen: Screen
|
||||
@@ -58,65 +50,17 @@ export function Viewer({
|
||||
const frame = frameFor(screen, variant.id)
|
||||
if (!frame) throw new Error(`Capture ${screen.id} is unavailable in set ${variant.id}`)
|
||||
const issueLink = feedbackIssueUrl({ title: screen.title, identifier, deepLink, variant: variant.id })
|
||||
const storageKey = `catalog-annotations:${identifier}:${variant.id}`
|
||||
const [annotating, setAnnotating] = useState(() => window.location.hash.startsWith("#annotations="))
|
||||
const [annotations, setAnnotations] = useState<ReadonlyArray<Annotation>>(() => {
|
||||
const linked = readAnnotations(new URL(window.location.href), identifier, variant.id)
|
||||
if (linked.length > 0)
|
||||
return linked.filter((annotation) => annotation.row < frame.rows && annotation.column < frame.cols)
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (!stored) return []
|
||||
return readAnnotationDraft(stored).filter(
|
||||
(annotation) => annotation.row < frame.rows && annotation.column < frame.cols,
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const document: AnnotationDocument = { version: 1, identifier, variant: variant.id, annotations }
|
||||
const annotatedLink = annotationUrl(deepLink, document)
|
||||
const completeAnnotations = annotations.filter((annotation) => annotation.note.trim() !== "")
|
||||
const issueDocument = { ...document, annotations: completeAnnotations }
|
||||
const annotationIssueLink = feedbackIssueUrl({
|
||||
title: screen.title,
|
||||
identifier,
|
||||
deepLink: annotationUrl(deepLink, issueDocument),
|
||||
variant: variant.id,
|
||||
annotations: completeAnnotations,
|
||||
document: issueDocument,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(annotations))
|
||||
if (annotating) window.history.replaceState(null, "", annotations.length > 0 ? annotatedLink : deepLink)
|
||||
}, [annotatedLink, annotating, annotations, deepLink, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.showModal()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
|
||||
const editing =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
(event.target instanceof HTMLElement && event.target.isContentEditable)
|
||||
if (editing && event.key !== "Escape") return
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
if (annotating) {
|
||||
setAnnotating(false)
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key.toLowerCase() === "a" && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault()
|
||||
setAnnotating((value) => !value)
|
||||
return
|
||||
}
|
||||
if (annotating) return
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
onNavigate(event.key === "ArrowLeft" ? -1 : 1)
|
||||
@@ -163,15 +107,6 @@ export function Viewer({
|
||||
</button>
|
||||
</span>
|
||||
<div className="viewer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-button${annotating ? " active" : ""}`}
|
||||
onClick={() => setAnnotating((value) => !value)}
|
||||
title="Toggle annotation mode (A)"
|
||||
>
|
||||
Annotate
|
||||
{annotations.length > 0 ? <span className="viewer-button-count">{annotations.length}</span> : undefined}
|
||||
</button>
|
||||
<CaptureActionsMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink} />
|
||||
<CaptureSetSwitcher sets={variants} active={variant} onSelect={onVariantSelect} />
|
||||
</div>
|
||||
@@ -182,28 +117,6 @@ export function Viewer({
|
||||
<CaptureContextMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink}>
|
||||
<div className="viewer-image-wrap">
|
||||
<TerminalFrame frame={frame} label={`${screen.title}, ${variant.label}`} />
|
||||
{annotating ? (
|
||||
<AnnotationEditor
|
||||
cols={frame.cols}
|
||||
rows={frame.rows}
|
||||
annotations={annotations}
|
||||
onAdd={(row, column, note) => {
|
||||
if (annotations.length >= 24) return
|
||||
const annotation = { id: crypto.randomUUID(), row, column, note }
|
||||
setAnnotations([...annotations, annotation])
|
||||
}}
|
||||
onChange={(id, note) =>
|
||||
setAnnotations(
|
||||
annotations.map((annotation) => (annotation.id === id ? { ...annotation, note } : annotation)),
|
||||
)
|
||||
}
|
||||
onDelete={(id) => setAnnotations(annotations.filter((annotation) => annotation.id !== id))}
|
||||
onDone={() => {
|
||||
setAnnotating(false)
|
||||
}}
|
||||
issueLink={annotationIssueLink}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
</CaptureContextMenu>
|
||||
<figcaption className="viewer-caption">
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { Annotation, AnnotationDocument } from "./annotations"
|
||||
|
||||
interface FeedbackIssue {
|
||||
readonly title: string
|
||||
readonly identifier: string
|
||||
readonly deepLink: string
|
||||
readonly variant: string
|
||||
readonly annotations?: ReadonlyArray<Annotation>
|
||||
readonly document?: AnnotationDocument
|
||||
}
|
||||
|
||||
export function feedbackIssueUrl(issue: FeedbackIssue) {
|
||||
@@ -16,32 +12,15 @@ export function feedbackIssueUrl(issue: FeedbackIssue) {
|
||||
url.searchParams.set(
|
||||
"body",
|
||||
[
|
||||
...(issue.annotations?.length
|
||||
? issue.annotations.flatMap((annotation, index) => [
|
||||
`## ${index + 1}. Row ${annotation.row + 1}, column ${annotation.column + 1}`,
|
||||
"",
|
||||
annotation.note.trim(),
|
||||
"",
|
||||
])
|
||||
: ["## Feedback", "", "<!-- What looks wrong, confusing, or could be improved? -->", ""]),
|
||||
"## Feedback",
|
||||
"",
|
||||
"<!-- What looks wrong, confusing, or could be improved? -->",
|
||||
"",
|
||||
"## Catalog state",
|
||||
"",
|
||||
`- Screen: \`${issue.identifier}\``,
|
||||
`- Theme: \`${issue.variant}\``,
|
||||
`- Link: ${issue.deepLink}`,
|
||||
...(issue.document
|
||||
? [
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Annotation data</summary>",
|
||||
"",
|
||||
"```json",
|
||||
JSON.stringify(issue.document, null, 2),
|
||||
"```",
|
||||
"</details>",
|
||||
]
|
||||
: []),
|
||||
].join("\n"),
|
||||
)
|
||||
return url.href
|
||||
|
||||
@@ -114,7 +114,7 @@ a {
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline: 1px solid var(--kit-accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -222,17 +222,12 @@ kbd {
|
||||
}
|
||||
|
||||
.catalog-tabs button:focus-visible,
|
||||
.viewer-button:focus-visible,
|
||||
.command-trigger:focus-visible {
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline: 1px solid var(--catalog-accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.viewer-button:focus-visible {
|
||||
outline: 0;
|
||||
background: var(--kit-bg-hover);
|
||||
color: var(--kit-fg-strong);
|
||||
}
|
||||
|
||||
.catalog-tools {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -697,35 +692,20 @@ kbd {
|
||||
}
|
||||
|
||||
.variant-switcher select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
outline: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.variant-switcher:has(select:focus-visible) {
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.variant-switcher .variant-hint {
|
||||
color: var(--kit-fg-faint);
|
||||
}
|
||||
|
||||
.variant-switcher .variant-name {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.variant-switcher .variant-chevron {
|
||||
margin-left: -0.15rem;
|
||||
color: var(--kit-fg-faint);
|
||||
font-size: 0.55rem;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.capture-open:hover .capture-frame,
|
||||
@@ -738,7 +718,7 @@ kbd {
|
||||
}
|
||||
|
||||
.capture-open:focus-visible .capture-frame {
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline: 1px solid var(--catalog-accent);
|
||||
outline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
@@ -766,11 +746,6 @@ kbd {
|
||||
letter-spacing: 0.08em;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.capture-actions > summary:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.capture-actions > summary::-webkit-details-marker {
|
||||
@@ -1096,7 +1071,7 @@ kbd {
|
||||
}
|
||||
|
||||
.flow-open:focus-visible .flow-frame {
|
||||
outline: 1px solid var(--kit-fg-faint);
|
||||
outline: 1px solid var(--catalog-accent);
|
||||
outline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
@@ -1185,7 +1160,7 @@ kbd {
|
||||
|
||||
.viewer-header > .viewer-button:first-child {
|
||||
justify-self: start;
|
||||
padding-inline: 1.1rem;
|
||||
border-right: 1px solid var(--kit-line);
|
||||
}
|
||||
|
||||
.viewer-position {
|
||||
@@ -1200,12 +1175,11 @@ kbd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.viewer-actions .capture-actions {
|
||||
align-self: center;
|
||||
margin-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.viewer-button {
|
||||
@@ -1214,36 +1188,8 @@ kbd {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.viewer-actions .variant-switcher {
|
||||
border-left: 0;
|
||||
min-height: 1.9rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.viewer-button kbd {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--kit-fg-faint);
|
||||
}
|
||||
|
||||
.viewer-button-count {
|
||||
display: inline-grid;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.28rem;
|
||||
border-radius: 999px;
|
||||
place-items: center;
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.viewer-actions .viewer-button {
|
||||
align-self: center;
|
||||
min-height: 1.9rem;
|
||||
border-left: 1px solid var(--kit-line);
|
||||
}
|
||||
|
||||
.viewer-body {
|
||||
@@ -1289,259 +1235,6 @@ kbd {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.annotation-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.annotation-pin,
|
||||
.annotation-list-pin {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid #17120a;
|
||||
border-radius: 999px;
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.annotation-pin {
|
||||
position: absolute;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.annotation-pin:hover,
|
||||
.annotation-pin:focus-visible,
|
||||
.annotation-pin.selected {
|
||||
outline: 2px solid #fff2d8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.annotation-composer {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: grid;
|
||||
width: 18rem;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
transform: translate(-50%, 1.2rem);
|
||||
border-radius: 0.85rem;
|
||||
background: #1a1a1a;
|
||||
box-shadow:
|
||||
0 12px 40px rgb(0 0 0 / 55%),
|
||||
0 0 0 1px rgb(255 255 255 / 9%);
|
||||
cursor: default;
|
||||
animation: annotation-composer-in 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.annotation-composer.above {
|
||||
transform: translate(-50%, calc(-100% - 1.2rem));
|
||||
}
|
||||
|
||||
.annotation-composer header,
|
||||
.annotation-composer footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.annotation-composer header {
|
||||
justify-content: space-between;
|
||||
color: var(--kit-fg-faint);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.annotation-composer header small {
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.annotation-composer textarea {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: var(--kit-fg-strong);
|
||||
font-family: var(--kit-sans);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.annotation-composer textarea:focus {
|
||||
border-color: var(--catalog-mark);
|
||||
}
|
||||
|
||||
.annotation-composer footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.annotation-composer footer button {
|
||||
min-height: 1.8rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 0.45rem;
|
||||
color: var(--kit-fg-muted);
|
||||
font-family: var(--kit-sans);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.annotation-composer footer > :first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.annotation-composer .annotation-composer-delete {
|
||||
color: #ff8585;
|
||||
}
|
||||
|
||||
.annotation-composer .annotation-composer-save {
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.annotation-composer .annotation-composer-save:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes annotation-composer-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
scale: 0.96;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation-panel {
|
||||
position: fixed;
|
||||
z-index: 4;
|
||||
top: 3rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
width: min(22rem, 34vw);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
border-left: 1px solid var(--kit-line-strong);
|
||||
background: #0b0b0b;
|
||||
box-shadow: -24px 0 64px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.annotation-panel > header,
|
||||
.annotation-panel > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.annotation-panel > header {
|
||||
border-bottom: 1px solid var(--kit-line);
|
||||
}
|
||||
|
||||
.annotation-panel > header div {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.annotation-panel strong,
|
||||
.annotation-panel > header button,
|
||||
.annotation-panel > footer button {
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.annotation-panel > header span,
|
||||
.annotation-panel > footer span,
|
||||
.annotation-list section > div > span {
|
||||
color: var(--kit-fg-faint);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.annotation-panel > header button {
|
||||
padding: 0.4rem 0.55rem;
|
||||
color: var(--kit-fg-muted);
|
||||
}
|
||||
|
||||
.annotation-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.annotation-list-row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1.65rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--kit-line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.annotation-list-pin {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
}
|
||||
|
||||
.annotation-list-row > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.annotation-list-row small {
|
||||
color: var(--kit-fg-faint);
|
||||
font-family: var(--kit-mono);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.annotation-list-row strong {
|
||||
overflow: hidden;
|
||||
color: var(--kit-fg-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 450;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.annotation-list-row:hover,
|
||||
.annotation-list-row:focus-visible {
|
||||
background: rgb(255 255 255 / 3%);
|
||||
}
|
||||
|
||||
.annotation-panel > footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
border-top: 1px solid var(--kit-line);
|
||||
}
|
||||
|
||||
.annotation-issue {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 2.3rem;
|
||||
padding: 0 0.85rem;
|
||||
background: var(--catalog-mark);
|
||||
color: var(--catalog-mark-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.annotation-issue[aria-disabled="true"] {
|
||||
background: var(--kit-bg-hover);
|
||||
color: var(--kit-fg-faint);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-variant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1801,25 +1494,6 @@ kbd {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.annotation-panel {
|
||||
top: auto;
|
||||
width: 100%;
|
||||
height: min(48dvh, 25rem);
|
||||
border-top: 1px solid var(--kit-line-strong);
|
||||
border-left: 0;
|
||||
box-shadow: 0 -24px 64px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.annotation-pin {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.annotation-composer textarea {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--catalog-header-height: 10.5rem;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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" | "gitGraph"
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that this renderer does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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]),
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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,10 +17,6 @@ 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"
|
||||
@@ -141,25 +137,6 @@ 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,6 +1,5 @@
|
||||
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"
|
||||
@@ -112,12 +111,6 @@ 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,28 +361,3 @@ 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")
|
||||
})
|
||||
|
||||
@@ -7771,12 +7771,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) and (max-width: 58rem) {
|
||||
[data-page="stats"] [data-slot="top-models-bar"][data-active="true"] {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.999rem) {
|
||||
[data-page="stats"] [data-section="top-models"],
|
||||
[data-page="stats"] [data-section="leaderboard"],
|
||||
|
||||
Reference in New Issue
Block a user