mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 17:49:53 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60124ac57d |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -395,7 +395,6 @@
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
@@ -571,20 +570,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.8",
|
||||
@@ -895,7 +880,6 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
@@ -2079,8 +2063,6 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
@@ -688,8 +688,6 @@ export default function Page() {
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
enabled,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
@@ -703,16 +701,6 @@ export default function Page() {
|
||||
}
|
||||
})
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
createEffect(
|
||||
on(
|
||||
() => desktopReviewOpen() || mobileChanges(),
|
||||
(open, previous) => {
|
||||
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const reviewDiffs = () => {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||
// avoids suspense
|
||||
@@ -959,6 +947,19 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
const details = evt.details as { type: string; properties?: unknown }
|
||||
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof details.properties === "object" && details.properties
|
||||
? (details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk().directory,
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -13,14 +13,12 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
const configuredIntegrations = new Set(
|
||||
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
)
|
||||
for (const [id, provider] of configuredProviders(loaded.entries)) {
|
||||
const integrationID = id
|
||||
if (!integrations.get(integrationID)) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "key", label: "Manually enter API Key" },
|
||||
})
|
||||
}
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = provider.name ?? integration.name
|
||||
})
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Files } from "./files"
|
||||
import { makeFiles } from "./index"
|
||||
import { makeLocalDriver } from "./local"
|
||||
|
||||
export interface Interface {
|
||||
readonly files: Files
|
||||
readonly spawner: ChildProcessSpawner["Service"]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
|
||||
|
||||
export * as EnvironmentService from "./environment"
|
||||
@@ -50,13 +50,13 @@ fi
|
||||
`
|
||||
|
||||
const listScript = `
|
||||
${loadMetadata("-L")}
|
||||
${loadMetadata()}
|
||||
kind=\${metadata%%${TAB}*}
|
||||
if [ "$kind" != directory ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||
`
|
||||
|
||||
const moveScript = `
|
||||
|
||||
@@ -30,8 +30,7 @@ export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Faile
|
||||
|
||||
export interface FilesImpl {
|
||||
/**
|
||||
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
|
||||
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
|
||||
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||
* `Failed`, so callers must use ranges for larger files.
|
||||
*/
|
||||
@@ -42,7 +41,7 @@ export interface FilesImpl {
|
||||
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
|
||||
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||
@@ -51,20 +50,4 @@ export interface FilesImpl {
|
||||
|
||||
export interface Files extends FilesImpl {}
|
||||
|
||||
/**
|
||||
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
|
||||
* symlink fails with `NotFound`.
|
||||
*/
|
||||
export const typeFollowing = (files: Files, path: string) =>
|
||||
files.stat(path).pipe(
|
||||
Effect.flatMap((info) =>
|
||||
info.type === "symlink"
|
||||
? files.read(path, { offset: 0, length: 0 }).pipe(
|
||||
Effect.map((result) => result.info.type),
|
||||
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
|
||||
)
|
||||
: Effect.succeed(info.type),
|
||||
),
|
||||
)
|
||||
|
||||
export * as EnvironmentFiles from "./files"
|
||||
|
||||
@@ -9,13 +9,10 @@ export {
|
||||
type FilesImpl,
|
||||
type FileType,
|
||||
NotFound,
|
||||
typeFollowing,
|
||||
WrongKind,
|
||||
} from "./files"
|
||||
export { execDefaults } from "./exec-defaults"
|
||||
export { makeLocalDriver } from "./local"
|
||||
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||
export { type Interface, node, Service } from "./environment"
|
||||
|
||||
import type { Driver } from "./driver"
|
||||
import { execDefaults } from "./exec-defaults"
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "./driver"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||
|
||||
/**
|
||||
* The host filesystem binding. Deliberately raw node:fs rather than effect's
|
||||
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
|
||||
* reports "symlink") and typed directory entries, and effect's node
|
||||
* FileSystem provides neither — its stat always follows symlinks and
|
||||
* readDirectory returns names only. FSUtil hits the same gap and its
|
||||
* readDirectoryEntries already bypasses to raw node readdir internally.
|
||||
* Nothing above the environment seam touches node:fs.
|
||||
*/
|
||||
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
|
||||
const overrides: FilesImpl = {
|
||||
read: (value, range) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* stat(value, true)
|
||||
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
|
||||
if (range === undefined) {
|
||||
const bytes = yield* attempt(value, () => fs.readFile(value), true)
|
||||
return { info, bytes }
|
||||
}
|
||||
const bytes = yield* attempt(
|
||||
value,
|
||||
async () => {
|
||||
const handle = await fs.open(value, "r")
|
||||
try {
|
||||
const buffer = new Uint8Array(range.length)
|
||||
const result = await handle.read(buffer, 0, range.length, range.offset)
|
||||
return buffer.subarray(0, result.bytesRead)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
return { info, bytes }
|
||||
}),
|
||||
stat: (value) => stat(value, false),
|
||||
list: (value) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* stat(value, true)
|
||||
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
|
||||
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
|
||||
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
|
||||
}),
|
||||
write: (value, bytes) =>
|
||||
attempt(value, async () => {
|
||||
await fs.mkdir(path.dirname(value), { recursive: true })
|
||||
await fs.writeFile(value, bytes)
|
||||
}),
|
||||
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
|
||||
move: (from, to) =>
|
||||
Effect.gen(function* () {
|
||||
yield* stat(from, false)
|
||||
const destination = yield* stat(to, false).pipe(
|
||||
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof NotFound,
|
||||
() => Effect.succeed(to),
|
||||
),
|
||||
)
|
||||
yield* attempt(from, () => fs.rename(from, destination))
|
||||
}),
|
||||
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
|
||||
}
|
||||
|
||||
return { spawner, overrides }
|
||||
}
|
||||
|
||||
const stat = (value: string, follow: boolean) =>
|
||||
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
|
||||
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
|
||||
)
|
||||
|
||||
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
|
||||
if (entry.isFile()) return "file"
|
||||
if (entry.isDirectory()) return "directory"
|
||||
if (entry.isSymbolicLink()) return "symlink"
|
||||
return "other"
|
||||
}
|
||||
|
||||
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
|
||||
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
|
||||
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
|
||||
return Effect.tryPromise({
|
||||
try: run,
|
||||
catch: (cause) =>
|
||||
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
|
||||
})
|
||||
}
|
||||
|
||||
const isMissing = (cause: unknown) =>
|
||||
cause !== null &&
|
||||
typeof cause === "object" &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
|
||||
export * as EnvironmentLocal from "./local"
|
||||
@@ -90,7 +90,7 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Context, Effect, Layer } from "effect"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "./environment"
|
||||
import type { Files } from "./environment"
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: string
|
||||
@@ -31,36 +29,13 @@ export interface WriteResult {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */
|
||||
readonly withLock: (
|
||||
targets: ReadonlyArray<string>,
|
||||
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (
|
||||
input: TextWriteInput,
|
||||
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||
|
||||
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
|
||||
return Bom.decodeBytes((yield* files.read(target)).bytes)
|
||||
})
|
||||
|
||||
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
||||
files: Files,
|
||||
target: string,
|
||||
bom: boolean,
|
||||
) {
|
||||
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
|
||||
if (synced.bytes) yield* files.write(target, synced.bytes)
|
||||
return synced.text
|
||||
})
|
||||
|
||||
/** Share transaction locks across Location graphs that address the same file. */
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
@@ -69,12 +44,8 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
||||
[...new Set(targets.map(FSUtil.resolve))]
|
||||
.sort()
|
||||
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
||||
const withTargetLock =
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -90,14 +61,8 @@ const layer = Layer.effect(
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const existed = yield* environment.files.stat(input.target.absolute).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
|
||||
)
|
||||
yield* environment.files.write(
|
||||
input.target.absolute,
|
||||
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
|
||||
)
|
||||
const existed = yield* fs.exists(input.target.absolute)
|
||||
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||
return writeResult(input.target, existed)
|
||||
}),
|
||||
),
|
||||
@@ -107,24 +72,23 @@ const layer = Layer.effect(
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
|
||||
Effect.map((result) => result.bytes),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
yield* environment.files.write(
|
||||
const current = yield* fs
|
||||
.readFile(input.target.absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.absolute,
|
||||
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ withLock, write, writeTextPreservingBom })
|
||||
return Service.of({ write, writeTextPreservingBom })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding integrations exist.
|
||||
|
||||
@@ -11,6 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { Watcher } from "./watcher"
|
||||
import { Ignore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const relative = path.relative(dir, item)
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
})
|
||||
}
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
@@ -35,6 +44,19 @@ const layer = Layer.effect(
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = Protected.isHome(location.directory)
|
||||
|
||||
if (!home && location.vcs) {
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: location.directory,
|
||||
type: "directory",
|
||||
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
|
||||
})
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
if (home) {
|
||||
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
|
||||
}
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
@@ -42,7 +64,10 @@ const layer = Layer.effect(
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||
)
|
||||
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Environment } from "./environment"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
@@ -54,7 +53,6 @@ export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
Command.node,
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { Form } from "../form"
|
||||
@@ -71,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const credential = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
@@ -104,7 +102,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Credential.Service, credential),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(Environment.Service, environment),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
@@ -283,9 +282,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
@@ -323,7 +320,6 @@ export const node = makeLocationNode({
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
|
||||
@@ -3,9 +3,8 @@ export * as Ripgrep from "./ripgrep"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||
import { Environment } from "./environment"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { RipgrepBinary } from "./ripgrep/binary"
|
||||
|
||||
@@ -94,7 +93,7 @@ const isInvalidPattern = (stderr: string) =>
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
const process = yield* AppProcess.Service
|
||||
const binary = yield* RipgrepBinary.Service
|
||||
|
||||
const run = <A>(input: {
|
||||
@@ -108,8 +107,7 @@ const layer = Layer.effect(
|
||||
}) => {
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
|
||||
const handle = yield* environment.spawner.spawn(
|
||||
const handle = yield* process.spawn(
|
||||
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
||||
@@ -277,4 +275,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
|
||||
@@ -2,14 +2,9 @@ export * as SessionRestart from "./restart"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { SessionStore } from "../store"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Marks every execution active in this process for resumption by the next server start.
|
||||
@@ -31,7 +26,6 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
return Service.of({
|
||||
suspendActiveSessions: Effect.gen(function* () {
|
||||
yield* store.suspend(yield* execution.active)
|
||||
@@ -43,11 +37,6 @@ export const layer = Layer.effect(
|
||||
(sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* store.consumeSuspended(sessionID))) return
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
@@ -58,8 +47,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
|
||||
|
||||
+270
-269
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { produce } from "immer"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Bus } from "./bus"
|
||||
import { Environment } from "./environment"
|
||||
import { Location } from "./location"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
@@ -65,284 +65,285 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||
|
||||
export const layer = (options?: ShellSelect.Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const environment = yield* Environment.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
||||
}
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
||||
}
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||
return (yield* require(id)).info
|
||||
})
|
||||
|
||||
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
||||
return yield* Deferred.await((yield* require(id)).done)
|
||||
})
|
||||
|
||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
let offset = 0
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
bytes.copy(buffer, offset)
|
||||
offset += bytes.length
|
||||
})
|
||||
stream.on("end", () => resolve(offset))
|
||||
stream.on("error", () => resolve(0))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const invocation: ShellCreateBefore = {
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("shell", "create.before", invocation)
|
||||
if (before) yield* before(invocation)
|
||||
|
||||
const id = Shell.ID.ascending()
|
||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
status: "running",
|
||||
command: invocation.command,
|
||||
cwd: invocation.cwd,
|
||||
shell: invocation.shell,
|
||||
file,
|
||||
metadata: input.metadata ?? {},
|
||||
time: { started: Date.now() },
|
||||
}
|
||||
|
||||
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
|
||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
const ready = Deferred.makeUnsafe<Active>()
|
||||
runFork(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* appProcess.spawn(
|
||||
ChildProcess.make(invocation.shell, args, {
|
||||
cwd: invocation.cwd,
|
||||
env: invocation.env,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
}),
|
||||
)
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
file,
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Deferred.makeUnsafe<void>()
|
||||
const pump = handle.all.pipe(
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
session.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* pump.pipe(Effect.catch(() => Effect.void))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve())
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(outputDone, undefined)
|
||||
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.once("open", () => resolve())
|
||||
stream.once("error", () => resolve())
|
||||
}),
|
||||
)
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
draft.status = status
|
||||
if (exit !== undefined) draft.exit = exit
|
||||
draft.time.completed = Date.now()
|
||||
})
|
||||
yield* beforeWait
|
||||
yield* Deferred.await(outputDone)
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||
yield* Deferred.succeed(session.done, session.info)
|
||||
yield* bus.publish(Shell.Event.Exited, {
|
||||
id,
|
||||
...(exit !== undefined ? { exit } : {}),
|
||||
status,
|
||||
})
|
||||
exitOrder.push(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(Shell.ID.make(oldest))
|
||||
}
|
||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||
// aborting finish when finish itself runs on the timeout fiber.
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
Effect.sleep(Duration.millis(duration)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
Effect.flatMap((code) => finish("exited", code)),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||
// release (kill) the process before its exit is observed.
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
|
||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||
return (yield* require(id)).info
|
||||
})
|
||||
|
||||
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
||||
return yield* Deferred.await((yield* require(id)).done)
|
||||
})
|
||||
|
||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
let offset = 0
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
bytes.copy(buffer, offset)
|
||||
offset += bytes.length
|
||||
})
|
||||
stream.on("end", () => resolve(offset))
|
||||
stream.on("error", () => resolve(0))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const invocation: ShellCreateBefore = {
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("shell", "create.before", invocation)
|
||||
if (before) yield* before(invocation)
|
||||
|
||||
const id = Shell.ID.ascending()
|
||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
status: "running",
|
||||
command: invocation.command,
|
||||
cwd: invocation.cwd,
|
||||
shell: invocation.shell,
|
||||
file,
|
||||
metadata: input.metadata ?? {},
|
||||
time: { started: Date.now() },
|
||||
}
|
||||
|
||||
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
|
||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
const ready = Deferred.makeUnsafe<Active>()
|
||||
runFork(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* environment.spawner.spawn(
|
||||
ChildProcess.make(invocation.shell, args, {
|
||||
cwd: invocation.cwd,
|
||||
env: invocation.env,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
}),
|
||||
)
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
file,
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Deferred.makeUnsafe<void>()
|
||||
const pump = handle.all.pipe(
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
session.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* pump.pipe(Effect.catch(() => Effect.void))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve())
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(outputDone, undefined)
|
||||
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.once("open", () => resolve())
|
||||
stream.once("error", () => resolve())
|
||||
}),
|
||||
)
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
draft.status = status
|
||||
if (exit !== undefined) draft.exit = exit
|
||||
draft.time.completed = Date.now()
|
||||
})
|
||||
yield* beforeWait
|
||||
yield* Deferred.await(outputDone)
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||
yield* Deferred.succeed(session.done, session.info)
|
||||
yield* bus.publish(Shell.Event.Exited, {
|
||||
id,
|
||||
...(exit !== undefined ? { exit } : {}),
|
||||
status,
|
||||
})
|
||||
exitOrder.push(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(Shell.ID.make(oldest))
|
||||
}
|
||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||
// aborting finish when finish itself runs on the timeout fiber.
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
Effect.sleep(Duration.millis(duration)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
Effect.flatMap((code) => finish("exited", code)),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||
// release (kill) the process before its exit is observed.
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+34
-105
@@ -2,7 +2,8 @@ export * as Skill from "./skill"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
|
||||
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
@@ -12,7 +13,6 @@ import { Permission } from "./permission"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SkillDiscovery } from "./skill/discovery"
|
||||
import { State } from "./state"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
|
||||
export const DirectorySource = Skill.DirectorySource
|
||||
export type DirectorySource = Skill.DirectorySource
|
||||
@@ -81,82 +81,6 @@ const layer = Layer.effect(
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const changes = yield* PubSub.unbounded<string>()
|
||||
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const changed = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return false
|
||||
cache.clear()
|
||||
yield* FiberMap.clear(watches)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!changed) return
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe(
|
||||
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
|
||||
)
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
|
||||
directory: string,
|
||||
) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) {
|
||||
yield* watch(target, "file")
|
||||
}
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "skill",
|
||||
@@ -168,10 +92,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
finalize: () =>
|
||||
lock
|
||||
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
|
||||
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
||||
@@ -183,22 +104,14 @@ const layer = Layer.effect(
|
||||
directories: [],
|
||||
skills: [source.skill.id],
|
||||
})
|
||||
return { skills: [source.skill], paths: [] }
|
||||
return { skills: [source.skill], directories: [] }
|
||||
}
|
||||
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const paths = [...roots]
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
|
||||
const external = path.dirname(resolved)
|
||||
paths.push(external)
|
||||
yield* watch(external, "directory")
|
||||
}
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
@@ -226,22 +139,38 @@ const layer = Layer.effect(
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return { skills, paths }
|
||||
return { skills, directories }
|
||||
})
|
||||
|
||||
const list = Effect.fn("Skill.list")(function* () {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
}),
|
||||
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return
|
||||
for (const [key] of invalidated) cache.delete(key)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => invalidate(event.data.file)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
const list = Effect.fn("Skill.list")(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -258,5 +187,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { Location } from "../../location"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
@@ -111,10 +109,9 @@ export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -155,16 +152,17 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
|
||||
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
Effect.catchTag("Environment.WrongKind", (error) =>
|
||||
error.actual === "directory"
|
||||
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
|
||||
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
|
||||
),
|
||||
)
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
)
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.absolute)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
@@ -206,20 +204,19 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* fileMutation.write({
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
: (yield* FileMutation.readText(environment.files, target.absolute)).text
|
||||
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
fileMutation.withLock([path.resolve(location.directory, input.path)]),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Ripgrep } from "../../ripgrep"
|
||||
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
@@ -82,20 +82,22 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (type !== "directory")
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = target.absolute
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd: root,
|
||||
cwd: target.absolute,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
|
||||
export const name = "grep"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GrepInput.fields.pattern
|
||||
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
|
||||
.annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
}),
|
||||
pattern: FileSystem.GrepInput.fields.pattern.check(
|
||||
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
|
||||
).annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
}),
|
||||
path: Schema.optionalKey(RelativePath).annotate({
|
||||
description: "File or directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
@@ -66,100 +66,104 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const root = target.absolute
|
||||
const type = yield* Environment.typeFollowing(environment.files, root).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const cwd = type === "directory" ? root : path.dirname(root)
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* ripgrep
|
||||
.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
file: type === "file" ? path.basename(root) : undefined,
|
||||
include: input.include,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
const root = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs
|
||||
.stat(root)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const cwd = info?.type === "Directory" ? root : path.dirname(root)
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* ripgrep
|
||||
.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
file: info?.type === "File" ? path.basename(root) : undefined,
|
||||
include: input.include,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(
|
||||
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(
|
||||
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.matches,
|
||||
content: toModelContent(
|
||||
result.matches.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
result.truncated,
|
||||
),
|
||||
)
|
||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.matches,
|
||||
content: toModelContent(
|
||||
result.matches.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: error instanceof Ripgrep.InvalidPatternError
|
||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: error instanceof Ripgrep.InvalidPatternError
|
||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -4,13 +4,12 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "../../environment"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -45,13 +44,7 @@ export const toModelOutput = (output: Output) =>
|
||||
].join("\n")
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
|
||||
readonly target: Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
@@ -76,8 +69,7 @@ interface Target {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -92,13 +84,6 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
const lockTargets = Result.isSuccess(parsed)
|
||||
? parsed.success.flatMap((hunk) => [
|
||||
path.resolve(location.directory, hunk.path),
|
||||
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
|
||||
])
|
||||
: []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
@@ -112,7 +97,7 @@ export const Plugin = {
|
||||
id: context.id,
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(parsed).pipe(
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
@@ -140,19 +125,18 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
const content =
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content,
|
||||
before: "",
|
||||
after: Bom.split(content).text,
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||
).text,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -167,7 +151,20 @@ export const Plugin = {
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
const stats = yield* fs.stat(target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -236,8 +233,13 @@ export const Plugin = {
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* environment.files
|
||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.absolute,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
@@ -247,7 +249,7 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* environment.files
|
||||
yield* fs
|
||||
.remove(change.target.absolute)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
@@ -259,10 +261,10 @@ export const Plugin = {
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* environment.files
|
||||
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.absolute, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* environment.files
|
||||
yield* fs
|
||||
.remove(change.target.absolute)
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
@@ -276,8 +278,8 @@ export const Plugin = {
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* environment.files
|
||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.absolute, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
@@ -292,13 +294,13 @@ export const Plugin = {
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* FileMutation.readText(environment.files, target).pipe(
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
@@ -313,7 +315,6 @@ export const Plugin = {
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
mutation.withLock(lockTargets),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
@@ -344,10 +345,10 @@ export const Plugin = {
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Environment.NotFound) return "file does not exist"
|
||||
if (error instanceof Environment.WrongKind)
|
||||
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
|
||||
if (error instanceof Environment.Failed) return errorMessage(error.cause)
|
||||
if (error instanceof PlatformError) {
|
||||
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||
return error.reason.description ?? error.reason.message
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Permission } from "../../permission"
|
||||
import { SessionInstructions } from "../../session/instructions"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { ReadToolFileSystem } from "../read-filesystem"
|
||||
import { Environment } from "../../environment"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
@@ -73,12 +72,16 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof Environment.NotFound,
|
||||
() => missing(input.path, target.absolute),
|
||||
),
|
||||
)
|
||||
const type = yield* reader
|
||||
.inspect(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
@@ -92,7 +95,7 @@ export const Plugin = {
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: content.type === "list-page" ? resolved : dirname(resolved),
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
|
||||
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Config } from "../../config"
|
||||
import { Environment } from "../../environment"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
@@ -83,7 +83,7 @@ export const Plugin = {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
const fsUtil = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -179,12 +179,14 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
),
|
||||
)
|
||||
if (workdir !== "directory")
|
||||
const workdir = yield* fsUtil
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
),
|
||||
)
|
||||
if (workdir.type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "../../environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
@@ -47,9 +47,9 @@ export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -77,8 +77,8 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||
@@ -91,11 +91,9 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) {
|
||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
}
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
|
||||
@@ -2,22 +2,17 @@ export * as ReadToolFileSystem from "./read-filesystem"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { lookup } from "mime-types"
|
||||
import { Environment } from "../environment"
|
||||
import type { Files } from "../environment"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Mime } from "../mime"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
|
||||
export const MAX_READ_LINES = 2_000
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
||||
const FIRST_CHUNK = 256 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
||||
|
||||
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
|
||||
resource: Schema.String,
|
||||
@@ -57,13 +52,8 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
||||
}
|
||||
}
|
||||
|
||||
export type ReadError =
|
||||
| Environment.NotFound
|
||||
| Environment.Failed
|
||||
| BinaryFileError
|
||||
| MediaIngestLimitError
|
||||
| OffsetOutOfRangeError
|
||||
| PathKindError
|
||||
export type InspectError = FSUtil.Error | PathKindError
|
||||
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||
|
||||
export const PageInput = Schema.Struct({
|
||||
offset: Schema.optionalKey(NonNegativeInt),
|
||||
@@ -100,113 +90,202 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
|
||||
readonly read: (
|
||||
path: AbsolutePath,
|
||||
resource: string,
|
||||
page?: PageInput,
|
||||
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError>
|
||||
) => Effect.Effect<FileContent | TextPage, ReadError>
|
||||
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||
|
||||
const mimeType = (value: string) => lookup(value) || "application/octet-stream"
|
||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||
const mediaMime = (bytes: Uint8Array) => {
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
||||
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||
return "image/webp"
|
||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||
}
|
||||
const binary = (bytes: Uint8Array) => {
|
||||
if (bytes.length === 0) return false
|
||||
let nonPrintable = 0
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) return true
|
||||
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
|
||||
}
|
||||
return nonPrintable / bytes.length > 0.3
|
||||
}
|
||||
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||
const info = yield* fs.stat(input)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
||||
return type
|
||||
})
|
||||
|
||||
export const read = Effect.fn("ReadTool.read")(function* (
|
||||
files: Files,
|
||||
input: AbsolutePath,
|
||||
fs: FSUtil.Interface,
|
||||
input: string,
|
||||
resource: string,
|
||||
page: PageInput = {},
|
||||
) {
|
||||
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
|
||||
Effect.catchTag("Environment.WrongKind", (error) => {
|
||||
if (error.actual !== "directory")
|
||||
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
|
||||
return files.list(input).pipe(
|
||||
Effect.map((entries) => list(entries, page)),
|
||||
Effect.catchTag("Environment.WrongKind", () =>
|
||||
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
|
||||
),
|
||||
const real = yield* fs.realPath(input)
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(real, { flag: "r" })
|
||||
const info = yield* file.stat
|
||||
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
|
||||
const first = Option.getOrElse(
|
||||
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
|
||||
() => new Uint8Array(),
|
||||
)
|
||||
const mime = mediaMime(first)
|
||||
if (mime) {
|
||||
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||
const chunks = [first]
|
||||
let total = first.length
|
||||
while (total <= MAX_MEDIA_INGEST_BYTES) {
|
||||
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
||||
if (Option.isNone(chunk)) break
|
||||
chunks.push(chunk.value)
|
||||
total += chunk.value.length
|
||||
}
|
||||
if (total > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
content: Buffer.concat(
|
||||
chunks.map((chunk) => Buffer.from(chunk)),
|
||||
total,
|
||||
).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime,
|
||||
}
|
||||
}
|
||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||
if (!paged) {
|
||||
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder()
|
||||
const text = [decodeUtf8(decoder, first)]
|
||||
while (true) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||
}
|
||||
text.push(decodeUtf8(decoder))
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
content: text.join(""),
|
||||
encoding: "utf8" as const,
|
||||
mime: FSUtil.mimeType(real),
|
||||
}
|
||||
}
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const lines: string[] = []
|
||||
const decoder = new TextDecoder()
|
||||
let pending = ""
|
||||
let discard = false
|
||||
let line = 1
|
||||
let bytes = 0
|
||||
let next: number | undefined
|
||||
const append = (input: string) => {
|
||||
if (line < offset) {
|
||||
line++
|
||||
return true
|
||||
}
|
||||
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_READ_BYTES) {
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
lines.push(text)
|
||||
bytes += size
|
||||
line++
|
||||
return true
|
||||
}
|
||||
const consume = (input: string) => {
|
||||
let text = input
|
||||
while (true) {
|
||||
const index = text.indexOf("\n")
|
||||
if (index === -1) {
|
||||
if (!discard) {
|
||||
pending += text
|
||||
if (pending.length > MAX_LINE_LENGTH) {
|
||||
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
|
||||
discard = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
const current = pending + (discard ? "" : text.slice(0, index))
|
||||
pending = ""
|
||||
discard = false
|
||||
text = text.slice(index + 1)
|
||||
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
|
||||
let start = 0
|
||||
while (start < chunk.length) {
|
||||
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
const newline = chunk.indexOf(10, start)
|
||||
const end = newline === -1 ? chunk.length : newline + 1
|
||||
const segment = chunk.subarray(start, end)
|
||||
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||
start = end
|
||||
}
|
||||
return true
|
||||
})
|
||||
let done = !(yield* consumeChunk(first))
|
||||
while (!done) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
done = !(yield* consumeChunk(chunk.value))
|
||||
}
|
||||
if (!done) {
|
||||
const tail = decodeUtf8(decoder)
|
||||
if (!discard) pending += tail
|
||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||
}
|
||||
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: lines.join("\n"),
|
||||
mime: FSUtil.mimeType(real),
|
||||
offset,
|
||||
truncated: next !== undefined,
|
||||
...(next === undefined ? {} : { next }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (first instanceof ListPage) return first
|
||||
|
||||
const media = Mime.detect(first.bytes)
|
||||
if (MEDIA_MIMES.has(media)) {
|
||||
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
|
||||
const whole = yield* readFile(files, input, resource)
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(input).href,
|
||||
name: path.basename(input),
|
||||
content: Buffer.from(whole.bytes).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime: media,
|
||||
}
|
||||
}
|
||||
|
||||
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||
if (!paged) {
|
||||
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(input).href,
|
||||
name: path.basename(input),
|
||||
content: new TextDecoder().decode(first.bytes),
|
||||
encoding: "utf8" as const,
|
||||
mime: mimeType(input),
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = [first.bytes]
|
||||
while (true) {
|
||||
const bytes = Buffer.concat(chunks)
|
||||
const eof = bytes.length >= first.info.size
|
||||
const result = textPage(bytes, eof, page)
|
||||
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
|
||||
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
|
||||
if (next.bytes.length === 0) {
|
||||
const result = textPage(bytes, true, page)
|
||||
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
|
||||
return yield* makeTextPage(bytes, input, resource, result)
|
||||
}
|
||||
chunks.push(next.bytes)
|
||||
}
|
||||
})
|
||||
|
||||
const readFile = (
|
||||
files: Files,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
range?: { readonly offset: number; readonly length: number },
|
||||
) =>
|
||||
files
|
||||
.read(input, range)
|
||||
.pipe(
|
||||
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
|
||||
)
|
||||
|
||||
const makeTextPage = Effect.fnUntraced(function* (
|
||||
bytes: Uint8Array,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
result: NonNullable<ReturnType<typeof textPage>>,
|
||||
) {
|
||||
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
|
||||
if (result.entries.length === 0 && result.offset !== 1)
|
||||
return yield* new OffsetOutOfRangeError({ offset: result.offset })
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: result.entries.join("\n"),
|
||||
mime: mimeType(input),
|
||||
offset: result.offset,
|
||||
truncated: result.next !== undefined,
|
||||
...(result.next === undefined ? {} : { next: result.next }),
|
||||
})
|
||||
})
|
||||
|
||||
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
|
||||
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||
const real = yield* fs.realPath(input)
|
||||
const items = yield* fs.readDirectoryEntries(real)
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const visible = items
|
||||
@@ -237,58 +316,18 @@ const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
|
||||
truncated,
|
||||
...(truncated ? { next: offset + selected.length } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const decoded = new TextDecoder().decode(bytes)
|
||||
const split = decoded.split("\n")
|
||||
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
|
||||
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
||||
|
||||
const entries: string[] = []
|
||||
let size = 0
|
||||
let next: number | undefined
|
||||
for (const [index, value] of available.slice(offset - 1).entries()) {
|
||||
const line = offset + index
|
||||
if (entries.length >= limit || size >= MAX_READ_BYTES) {
|
||||
next = line
|
||||
break
|
||||
}
|
||||
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
|
||||
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
|
||||
if (size + lineSize > MAX_READ_BYTES) {
|
||||
next = line
|
||||
break
|
||||
}
|
||||
entries.push(text)
|
||||
size += lineSize
|
||||
}
|
||||
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
|
||||
next = offset + entries.length
|
||||
if (!eof && next === undefined) return
|
||||
|
||||
const consumedLines = next === undefined ? available.length : next - 1
|
||||
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
|
||||
return { entries, offset, next, consumed }
|
||||
}
|
||||
|
||||
const nthNewline = (bytes: Uint8Array, count: number) => {
|
||||
let found = 0
|
||||
for (const [index, byte] of bytes.entries()) {
|
||||
if (byte !== 10) continue
|
||||
found++
|
||||
if (found === count) return index + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
|
||||
const fs = yield* FSUtil.Service
|
||||
return Service.of({
|
||||
inspect: (path) => inspect(fs, path),
|
||||
read: (path, resource, page) => read(fs, path, resource, page),
|
||||
list: (path, page) => list(fs, path, page),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
@@ -50,33 +50,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
litellm: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]
|
||||
|
||||
yield* addPlugin(entries)
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
|
||||
id: "litellm",
|
||||
name: "litellm",
|
||||
methods: [{ type: "key", label: "Manually enter API Key" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import {
|
||||
execDefaults,
|
||||
Failed,
|
||||
makeFiles,
|
||||
makeLocalDriver,
|
||||
makeMemoryDriver,
|
||||
NotFound,
|
||||
typeFollowing,
|
||||
} from "../src/environment/index"
|
||||
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { environmentConformance } from "./lib/environment-conformance"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("typeFollowing", () => {
|
||||
it.effect("follows symlinks without changing stat semantics", () =>
|
||||
Effect.gen(function* () {
|
||||
const driver = makeMemoryDriver()
|
||||
const files = makeFiles(driver)
|
||||
yield* files.mkdir("/directory")
|
||||
yield* files.write("/file", new Uint8Array())
|
||||
yield* driver.symlink("/directory", "/directory-link")
|
||||
yield* driver.symlink("/file", "/file-link")
|
||||
yield* driver.symlink("/missing", "/dangling-link")
|
||||
|
||||
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
|
||||
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
|
||||
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
environmentConformance("memory environment", () =>
|
||||
Effect.sync(() => {
|
||||
@@ -46,27 +18,6 @@ environmentConformance("memory environment", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
environmentConformance("local environment", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
|
||||
return {
|
||||
files: makeFiles(makeLocalDriver(spawner)),
|
||||
root: tmp.path,
|
||||
...(process.platform === "win32"
|
||||
? {}
|
||||
: {
|
||||
symlink: (target: string, link: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.symlink(target, link),
|
||||
catch: (cause) => new Failed({ path: link, cause }),
|
||||
}),
|
||||
}),
|
||||
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
}
|
||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||
)
|
||||
|
||||
environmentConformance(
|
||||
"GNU exec environment",
|
||||
() =>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
|
||||
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
@@ -21,7 +21,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[Environment.node, environmentLayer],
|
||||
[FSUtil.node, filesystemLayer],
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -152,57 +152,6 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("shares transaction locks across Location service instances", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const target = path.join(directory, "shared.txt")
|
||||
const first = yield* Effect.gen(function* () {
|
||||
const files = yield* FileMutation.Service
|
||||
yield* files.withLock([target])(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
}).pipe(provide(directory), Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* Effect.gen(function* () {
|
||||
const files = yield* FileMutation.Service
|
||||
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
|
||||
}).pipe(provide(directory), Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
const files = yield* FileMutation.Service
|
||||
const first = yield* files
|
||||
.withLock([path.join(directory, "first.txt")])(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
|
||||
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -242,16 +191,16 @@ describe("FileMutation", () => {
|
||||
|
||||
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
||||
return Layer.effect(
|
||||
Environment.Service,
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...environment,
|
||||
files: {
|
||||
...environment.files,
|
||||
write: (target, content) => run(environment.files.write(target, content), target),
|
||||
},
|
||||
const filesystem = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...filesystem,
|
||||
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
|
||||
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
|
||||
writeFileString: (target, content, options) =>
|
||||
run(filesystem.writeFileString(target, content, options), target),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||
|
||||
@@ -74,9 +75,10 @@ describe("Watcher lifecycle", () => {
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consumer = yield* watcher
|
||||
.subscribe({ path: "/pending", type: "directory" })
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
|
||||
Effect.flatMap(Stream.runDrain),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(consumer)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(true)
|
||||
@@ -97,9 +99,10 @@ describe("Watcher lifecycle", () => {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consume = () =>
|
||||
watcher
|
||||
.subscribe({ path: "/shared", type: "directory" })
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
|
||||
Effect.flatMap(Stream.runDrain),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const first = yield* consume()
|
||||
const second = yield* consume()
|
||||
yield* Effect.yieldNow
|
||||
@@ -135,26 +138,22 @@ describe("Watcher lifecycle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
const built = AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
return Effect.provide(built)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(
|
||||
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||
options?: {
|
||||
vcs?: "git" | "hg"
|
||||
init?: (directory: string) => Promise<void>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
},
|
||||
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
@@ -174,57 +173,9 @@ function withTmp<A, E, R>(
|
||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||
}),
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
|
||||
}
|
||||
|
||||
describe("LocationWatcher subscriptions", () => {
|
||||
it.live("watches only exact Git branch metadata", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
||||
}),
|
||||
)
|
||||
return withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
|
||||
}),
|
||||
{ vcs: "git", watcher },
|
||||
)
|
||||
})
|
||||
|
||||
it.live("watches only exact Hg branch metadata", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
||||
}),
|
||||
)
|
||||
return withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
|
||||
}),
|
||||
{ vcs: "hg", watcher },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function wait(check: (event: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -275,18 +226,31 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
|
||||
)
|
||||
}
|
||||
|
||||
function ready(file: string, eventFile = file) {
|
||||
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(check),
|
||||
({ deferred }) =>
|
||||
trigger.pipe(
|
||||
Effect.andThen(Deferred.await(deferred)),
|
||||
Effect.timeoutOption(`${timeout} millis`),
|
||||
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
|
||||
),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}
|
||||
|
||||
function ready(directory: string) {
|
||||
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
|
||||
yield* eventuallyUpdate(
|
||||
(event) => event.file === eventFile,
|
||||
() => fs.writeFileString(file, content),
|
||||
).pipe(Effect.asVoid)
|
||||
(event) => event.file === file,
|
||||
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
describeNative("LocationWatcher", () => {
|
||||
describeWatcher("LocationWatcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -312,25 +276,94 @@ describeNative("LocationWatcher", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("detects creation of a missing directory target", () =>
|
||||
it.live("publishes root create, update, and delete events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const file = path.join(directory, "watch.txt")
|
||||
yield* ready(directory)
|
||||
for (const item of [
|
||||
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
||||
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
||||
{ event: "unlink" as const, trigger: fs.remove(file) },
|
||||
]) {
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
|
||||
).toEqual({
|
||||
file,
|
||||
event: item.event,
|
||||
})
|
||||
}
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips non-git roots", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const target = path.join(directory, "generated")
|
||||
const updates = yield* watcher.subscribe({ path: target, type: "file" })
|
||||
const update = yield* updates.pipe(
|
||||
Stream.take(1),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const creates = yield* Effect.suspend(() =>
|
||||
fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
|
||||
).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
|
||||
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
|
||||
const file = path.join(directory, "plain.txt")
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(event.valueOrUndefined?.path).toBe(target)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleanup stops publishing events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* ready(tmp.path).pipe(
|
||||
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
|
||||
Effect.scoped,
|
||||
)
|
||||
const file = path.join(tmp.path, "after-dispose.txt")
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.live("ignores .git/index changes", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const index = path.join(directory, ".git", "index")
|
||||
yield* ready(directory)
|
||||
yield* noUpdate(
|
||||
(event) => event.file === index,
|
||||
fs
|
||||
.writeFileString(path.join(directory, "tracked.txt"), "a")
|
||||
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
||||
)
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -341,11 +374,11 @@ describeNative("LocationWatcher", () => {
|
||||
const fs = yield* FSUtil.Service
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* ready(head)
|
||||
yield* ready(directory)
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toEqual({ file: head, event: "change" })
|
||||
).toMatchObject({ file: head })
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
@@ -360,8 +393,8 @@ describeNative("LocationWatcher", () => {
|
||||
const afs = yield* FSUtil.Service
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
||||
yield* ready(directory)
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
yield* ready(head, path.join(actual, "HEAD"))
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
@@ -389,7 +422,7 @@ describeNative("LocationWatcher", () => {
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(branch)
|
||||
yield* ready(directory)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
|
||||
@@ -105,29 +105,19 @@ export const environmentConformance = <E>(
|
||||
}),
|
||||
)
|
||||
|
||||
check("preserves symlink metadata while following symlinks for content", (harness) =>
|
||||
check("reports symlinks without resolving them", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
|
||||
yield* harness.symlink("target", `${harness.root}/link`)
|
||||
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||
expect(
|
||||
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
).toEqual([
|
||||
{ name: "entry-link", type: "symlink" },
|
||||
{ name: "file", type: "file" },
|
||||
])
|
||||
|
||||
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
|
||||
expect(fileError).toBeInstanceOf(WrongKind)
|
||||
expect((fileError as WrongKind).actual).toBe("file")
|
||||
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("symlink")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
@@ -128,34 +127,23 @@ describe("SessionExecution lifecycle", () => {
|
||||
it.effect("resumes each suspended Session at most once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.make("ses_resume_first")
|
||||
const second = Session.ID.make("ses_resume_second")
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
|
||||
const drained: string[] = []
|
||||
const continued: SessionEvent.Synthetic[] = []
|
||||
const scope = yield* Scope.make()
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([first, second])
|
||||
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
[first, second].map((sessionID) => ({
|
||||
sessionID,
|
||||
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
|
||||
description: "Continuing after restart",
|
||||
})),
|
||||
)
|
||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained.length).toBe(2)
|
||||
expect(continued.length).toBe(2)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -24,15 +25,8 @@ const discovery = Layer.succeed(
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
|
||||
[SkillDiscovery.node, discovery],
|
||||
[Watcher.node, watcherLayer],
|
||||
]),
|
||||
watcherLayer,
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
|
||||
)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
@@ -59,24 +53,6 @@ function waitForSkillUpdate() {
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe("Skill", () => {
|
||||
it.live("publishes updates when skill sources change", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -113,7 +89,6 @@ describe("Skill", () => {
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => {
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
@@ -144,21 +119,6 @@ describe("Skill", () => {
|
||||
content: "# review",
|
||||
},
|
||||
])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => write(second, "review", "Updated Second"))
|
||||
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -238,7 +198,7 @@ metadata:
|
||||
),
|
||||
)
|
||||
|
||||
it.live("clears cached skills when sources reload", () =>
|
||||
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -250,187 +210,26 @@ metadata:
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
|
||||
|
||||
let refreshed: Skill.Info[] = []
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (event.type !== Skill.Event.Updated.type) return Effect.void
|
||||
return skill.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* skill.reload().pipe(Effect.timeout("1 second"))
|
||||
yield* unsubscribe
|
||||
|
||||
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: tmp.path, type: "directory" },
|
||||
{ path: tmp.path, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads project sources created after their missing parent", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const file = path.join(source, "deploy", "SKILL.md")
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||
await write(source, "deploy", "Deploy production")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: source, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches directory sources for added and changed skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
|
||||
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
|
||||
|
||||
const file = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review changes")
|
||||
})
|
||||
const review = path.join(tmp.path, "review", "SKILL.md")
|
||||
yield* emitAndWait({ type: "create", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
bus
|
||||
.publish(FileSystem.Event.Changed, { file, event: "change" })
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"))
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("invalidates symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source)
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -23,15 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
Location.node,
|
||||
Permission.node,
|
||||
],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||
@@ -80,28 +72,29 @@ const reset = () => {
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
read: (target, range) =>
|
||||
current.files
|
||||
.read(target, range)
|
||||
.pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
|
||||
),
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readFile: (target) =>
|
||||
fs
|
||||
.readFile(target)
|
||||
.pipe(
|
||||
Effect.tap((content) =>
|
||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
|
||||
),
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
||||
},
|
||||
),
|
||||
writeWithDirs: (target, content, mode) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||
writeFile: (target, content, options) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
|
||||
writeFileString: (target, content, options) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -113,9 +106,15 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
|
||||
LayerNode.group([
|
||||
Tool.node,
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
editToolNode,
|
||||
]),
|
||||
[
|
||||
[Environment.node, environment],
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
@@ -472,7 +471,10 @@ describe("EditTool", () => {
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call({ path: "missing.ts", oldString: "before", newString: "after" }),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "File not found: missing.ts" },
|
||||
@@ -643,43 +645,6 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent edit transactions", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "concurrent.txt")
|
||||
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
|
||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.all(
|
||||
[
|
||||
executeTool(
|
||||
registry,
|
||||
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
||||
),
|
||||
executeTool(
|
||||
registry,
|
||||
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.andThen((results) =>
|
||||
Effect.gen(function* () {
|
||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies the edit when content changes after matching", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -2,12 +2,11 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Schema } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -82,33 +81,48 @@ const reset = () => {
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
read: (target, range) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).pipe(Effect.andThen(current.files.read(target, range))),
|
||||
remove: (target) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
|
||||
return current.files.remove(target)
|
||||
},
|
||||
write: (target, content) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
|
||||
return current.files.write(target, content)
|
||||
},
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readFile: (target) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).pipe(Effect.andThen(fs.readFile(target))),
|
||||
remove: (target, options) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "remove",
|
||||
description: "forced remove failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.remove(target, options)
|
||||
},
|
||||
writeWithDirs: (target, content, mode) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "writeWithDirs",
|
||||
description: "forced write failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.writeWithDirs(target, content, mode)
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
const withTool = <A, E, R>(
|
||||
directory: string,
|
||||
@@ -125,8 +139,8 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
||||
[Environment.node, environment],
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
@@ -248,43 +262,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent patch transactions", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "concurrent.txt")
|
||||
afterEditApproval = () =>
|
||||
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
|
||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||
Effect.andThen(
|
||||
Effect.all(
|
||||
[
|
||||
executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
|
||||
"call-patch-one",
|
||||
),
|
||||
),
|
||||
executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
|
||||
"call-patch-two",
|
||||
),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
Effect.andThen((results) =>
|
||||
Effect.gen(function* () {
|
||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns file diffs for final formatted content", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "formatted.txt")
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem])))
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
|
||||
const fixture = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const directory = yield* files.makeTempDirectoryScoped()
|
||||
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
|
||||
return { fs, files, directory }
|
||||
})
|
||||
const absolute = (value: string) => AbsolutePath.make(value)
|
||||
|
||||
describe("ReadToolFileSystem", () => {
|
||||
it.effect("preserves the environment not-found error", () =>
|
||||
it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, directory } = yield* fixture
|
||||
const { fs, directory } = yield* fixture
|
||||
const file = path.join(directory, "missing.txt")
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
|
||||
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Environment.NotFound)
|
||||
expect(error).toMatchObject({ _tag: "PlatformError" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns a listing when read reports a directory", () =>
|
||||
it.effect("fails when a file becomes the wrong path kind", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||
const { fs, directory } = yield* fixture
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
|
||||
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "list-page",
|
||||
entries: [
|
||||
{ path: `folder${path.sep}`, type: "directory" },
|
||||
{ path: "file.txt", type: "file" },
|
||||
],
|
||||
})
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed filesystem error when directory listing fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "file.txt")
|
||||
yield* files.writeFileString(file, "hello")
|
||||
|
||||
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
|
||||
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const binary = path.join(directory, "archive.dat")
|
||||
const malformed = path.join(directory, "malformed.txt")
|
||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||
|
||||
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
|
||||
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt")
|
||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
||||
|
||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||
@@ -69,11 +70,11 @@ describe("ReadToolFileSystem", () => {
|
||||
|
||||
it.effect("reads text despite a binary-associated extension", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "notes.docx")
|
||||
yield* files.writeFileString(file, "plain text")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
|
||||
|
||||
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
||||
}),
|
||||
@@ -82,17 +83,15 @@ describe("ReadToolFileSystem", () => {
|
||||
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const { fs: service, files, directory } = yield* fixture
|
||||
const outside = yield* files.makeTempDirectoryScoped()
|
||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
||||
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
|
||||
const result = yield* ReadToolFileSystem.list(service, directory)
|
||||
|
||||
expect(result.type).toBe("list-page")
|
||||
if (result.type !== "list-page") return
|
||||
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
||||
{ path: `folder${path.sep}`, type: "directory" },
|
||||
{ path: "broken", type: "symlink" },
|
||||
@@ -102,154 +101,45 @@ describe("ReadToolFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads a symlinked directory as a listing", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const target = path.join(directory, "target")
|
||||
const link = path.join(directory, "link")
|
||||
yield* files.makeDirectory(target)
|
||||
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
|
||||
yield* Effect.promise(() => fs.symlink(target, link))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "list-page",
|
||||
entries: [{ path: "file.txt", type: "file" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports out-of-range pagination as a typed error", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "short.txt")
|
||||
yield* files.writeFileString(file, "one\n")
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
|
||||
expect(error.message).toBe("Offset 2 is out of range")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("pages text with one-based offsets", () =>
|
||||
it.effect("stops reading after the requested page is complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "lines.txt")
|
||||
yield* files.writeFileString(file, "one\r\ntwo\nthree")
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const prefix = new TextEncoder().encode("one\n")
|
||||
for (const [name, trailing] of [
|
||||
["malformed.txt", 0x80],
|
||||
["nul.txt", 0],
|
||||
] as const) {
|
||||
const file = path.join(directory, name)
|
||||
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
})
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
|
||||
|
||||
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("truncates long lines", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "long.txt")
|
||||
yield* files.writeFileString(file, "a".repeat(2_001))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "text-page",
|
||||
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enforces line and byte budgets with continuation offsets", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const linesFile = path.join(directory, "many-lines.txt")
|
||||
const bytesFile = path.join(directory, "many-bytes.txt")
|
||||
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
|
||||
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
|
||||
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
|
||||
const tracked = {
|
||||
...environment,
|
||||
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
|
||||
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
|
||||
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
|
||||
}
|
||||
|
||||
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
|
||||
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
|
||||
|
||||
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
|
||||
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
|
||||
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
|
||||
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
|
||||
ReadToolFileSystem.MAX_READ_BYTES,
|
||||
)
|
||||
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sorts and pages directory entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
yield* files.makeDirectory(path.join(directory, "z"))
|
||||
yield* files.makeDirectory(path.join(directory, "a"))
|
||||
yield* files.writeFileString(path.join(directory, "b.txt"), "")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "list-page",
|
||||
entries: [{ path: `z${path.sep}`, type: "directory" }],
|
||||
truncated: true,
|
||||
next: 3,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops checking for null bytes after the requested page", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "nul.txt")
|
||||
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
|
||||
|
||||
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads page two after fetching more than the first 256KB range", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "large.txt")
|
||||
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the media ingestion limit message", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "oversized.png")
|
||||
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
||||
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
|
||||
const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
|
||||
expect(error.message).toBe(
|
||||
@@ -260,11 +150,11 @@ describe("ReadToolFileSystem", () => {
|
||||
|
||||
it.effect("reads PDFs as bounded media", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "document.pdf")
|
||||
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "file",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
@@ -21,7 +21,6 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -43,13 +42,24 @@ const readToolNode = makeLocationNode({
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const missingPath = "__missing_read_target__.txt"
|
||||
const missingAbsolutePath = path.join(process.cwd(), missingPath)
|
||||
const notFound = (target: string) =>
|
||||
PlatformError.systemError({
|
||||
_tag: "NotFound",
|
||||
module: "FileSystem",
|
||||
method: "stat",
|
||||
pathOrDescriptor: target,
|
||||
})
|
||||
const readCalls: {
|
||||
input: AbsolutePath
|
||||
page: ReadToolFileSystem.PageInput
|
||||
}[] = []
|
||||
const listCalls: ReadToolFileSystem.PageInput[] = []
|
||||
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
|
||||
let resolvedType: "file" | "directory" = "file"
|
||||
let resolveFailure: unknown
|
||||
let inspectFailure: ReadToolFileSystem.InspectError | undefined
|
||||
let directoryEntries: string[] = []
|
||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
|
||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
|
||||
type: "file",
|
||||
uri: "file:///README.md",
|
||||
name: "README.md",
|
||||
@@ -61,12 +71,22 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
|
||||
const reader = Layer.succeed(
|
||||
ReadToolFileSystem.Service,
|
||||
ReadToolFileSystem.Service.of({
|
||||
inspect: () =>
|
||||
resolveFailure !== undefined
|
||||
? Effect.die(resolveFailure)
|
||||
: inspectFailure !== undefined
|
||||
? Effect.fail(inspectFailure)
|
||||
: Effect.succeed(resolvedType),
|
||||
read: (input, _resource, page = {}) => {
|
||||
readCalls.push({ input, page })
|
||||
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
|
||||
if (readFailure !== undefined) return Effect.fail(readFailure)
|
||||
return Effect.succeed(readResult)
|
||||
},
|
||||
list: (_path, input = {}) =>
|
||||
Effect.sync(() => {
|
||||
listCalls.push(input)
|
||||
return listResult
|
||||
}),
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
@@ -105,6 +125,17 @@ const testFileSystem = Layer.effect(
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
readDirectory: () => Effect.succeed(directoryEntries),
|
||||
realPath: (path) =>
|
||||
path === missingAbsolutePath
|
||||
? Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "NotFound",
|
||||
module: "FileSystem",
|
||||
method: "realPath",
|
||||
pathOrDescriptor: path,
|
||||
}),
|
||||
)
|
||||
: Effect.succeed(path),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -164,8 +195,11 @@ describe("ReadTool", () => {
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
readCalls.length = 0
|
||||
listCalls.length = 0
|
||||
allow = true
|
||||
resolvedType = "file"
|
||||
resolveFailure = undefined
|
||||
inspectFailure = undefined
|
||||
directoryEntries = []
|
||||
readResult = {
|
||||
type: "file",
|
||||
@@ -176,6 +210,7 @@ describe("ReadTool", () => {
|
||||
mime: "text/plain",
|
||||
}
|
||||
readFailure = undefined
|
||||
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
|
||||
})
|
||||
|
||||
it.effect("registers, authorizes, and reads through the location filesystem", () =>
|
||||
@@ -637,7 +672,7 @@ describe("ReadTool", () => {
|
||||
|
||||
it.effect("returns missing paths as model-visible tool failures", () =>
|
||||
Effect.gen(function* () {
|
||||
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
|
||||
inspectFailure = notFound(missingAbsolutePath)
|
||||
directoryEntries = [
|
||||
"__missing_read_target__.txt.bak",
|
||||
"copy___missing_read_target__.txt",
|
||||
@@ -661,18 +696,14 @@ describe("ReadTool", () => {
|
||||
},
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(missingAbsolutePath),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists a bounded directory page through read", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = new ReadToolFileSystem.ListPage({
|
||||
resolvedType = "directory"
|
||||
listResult = new ReadToolFileSystem.ListPage({
|
||||
type: "list-page",
|
||||
entries: [
|
||||
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
|
||||
@@ -695,7 +726,7 @@ describe("ReadTool", () => {
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
output: { entries: readResult.entries, truncated: true, next: 4 },
|
||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
@@ -706,15 +737,14 @@ describe("ReadTool", () => {
|
||||
},
|
||||
])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||
expect(readCalls).toEqual([
|
||||
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
|
||||
])
|
||||
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not list a directory when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
allow = false
|
||||
resolvedType = "directory"
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -724,7 +754,7 @@ describe("ReadTool", () => {
|
||||
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
||||
}),
|
||||
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
|
||||
expect(readCalls).toEqual([])
|
||||
expect(listCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -743,12 +773,7 @@ describe("ReadTool", () => {
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -24,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [
|
||||
Tool.node,
|
||||
FSUtil.node,
|
||||
Ripgrep.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
Permission.node,
|
||||
],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||
|
||||
@@ -179,7 +186,9 @@ describe("search tools", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
|
||||
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
|
||||
),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result).toMatchObject({
|
||||
@@ -288,7 +297,9 @@ describe("search tools", () => {
|
||||
(tmp) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
|
||||
withTools(tmp.path, (registry) =>
|
||||
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
|
||||
),
|
||||
),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
@@ -320,7 +331,9 @@ describe("search tools", () => {
|
||||
Effect.sync(() => {
|
||||
expect(result.status).toBe("completed")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
|
||||
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
|
||||
expect(assertions[0]?.resources).toEqual([
|
||||
path.join(outside.path, "*").replaceAll("\\", "/"),
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -524,8 +524,7 @@ describe("ShellTool", () => {
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text).not.toContain("one")
|
||||
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
|
||||
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
|
||||
expect(content.text).toStartWith("two\nthree")
|
||||
expect(content.text).toContain("output truncated; full output saved to:")
|
||||
})
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
@@ -68,20 +68,17 @@ const reset = () => {
|
||||
denyAction = undefined
|
||||
}
|
||||
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
||||
},
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
writeWithDirs: (target, content, mode) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -95,7 +92,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||
[
|
||||
[Environment.node, environment],
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import {
|
||||
TextBufferRenderable,
|
||||
type ColorInput,
|
||||
type RenderContext,
|
||||
type RGBA,
|
||||
type StyledText,
|
||||
type TextBufferOptions,
|
||||
} from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasTextOptions } from "../canvas.js"
|
||||
import { setDiagramRenderableColor } from "./renderable-color.js"
|
||||
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
|
||||
|
||||
interface DiagramRenderableOptions<Diagram, Grid extends DiagramCanvas<any, any>> {
|
||||
parse: () => Diagram
|
||||
draw: (diagram: Diagram) => Grid
|
||||
publish: (grid: Grid) => StyledText
|
||||
measure?: DiagramCanvasTextOptions
|
||||
}
|
||||
|
||||
export abstract class DiagramRenderable<Diagram, Grid extends DiagramCanvas<any, any>> extends TextBufferRenderable {
|
||||
private _content: string
|
||||
private _renderedWidth = 0
|
||||
private _renderedHeight = 0
|
||||
private _pipeline?: DiagramRenderablePipeline<Diagram, Grid>
|
||||
|
||||
protected constructor(ctx: RenderContext, options: TextBufferOptions & { content?: string }) {
|
||||
super(ctx, { ...options, wrapMode: options.wrapMode ?? "none" })
|
||||
this._content = options.content ?? ""
|
||||
}
|
||||
|
||||
protected initializeDiagram(options: DiagramRenderableOptions<Diagram, Grid>): void {
|
||||
this._pipeline = new DiagramRenderablePipeline({
|
||||
parse: options.parse,
|
||||
draw: options.draw,
|
||||
didDraw: (grid) => {
|
||||
const size = grid.getTextSize(options.measure)
|
||||
this._renderedWidth = size.width
|
||||
this._renderedHeight = size.height
|
||||
},
|
||||
publish: (grid) => {
|
||||
this.textBuffer.setStyledText(options.publish(grid))
|
||||
this.updateTextInfo()
|
||||
},
|
||||
})
|
||||
this._pipeline.invalidateParsedDiagram()
|
||||
}
|
||||
|
||||
get content(): string {
|
||||
return this._content
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
if (this._content === value) return
|
||||
this._content = value
|
||||
this.contentChanged()
|
||||
this.pipeline.invalidateParsedDiagram()
|
||||
}
|
||||
|
||||
get renderedWidth(): number {
|
||||
return this._renderedWidth
|
||||
}
|
||||
|
||||
get renderedHeight(): number {
|
||||
return this._renderedHeight
|
||||
}
|
||||
|
||||
batchUpdate(update: () => void): void {
|
||||
this.pipeline.batchUpdate(update)
|
||||
}
|
||||
|
||||
protected contentChanged(): void {}
|
||||
|
||||
protected parsedDiagram(): Diagram {
|
||||
return this.pipeline.diagram()
|
||||
}
|
||||
|
||||
protected invalidateGrid(): void {
|
||||
this.pipeline.invalidateGrid()
|
||||
}
|
||||
|
||||
protected invalidateStyle(): void {
|
||||
this.pipeline.invalidateStyle()
|
||||
}
|
||||
|
||||
protected setColor(
|
||||
current: RGBA | undefined,
|
||||
value: ColorInput | undefined,
|
||||
assign: (color: RGBA | undefined) => void,
|
||||
): void {
|
||||
setDiagramRenderableColor(current, value, assign, () => this.invalidateStyle())
|
||||
}
|
||||
|
||||
private get pipeline(): DiagramRenderablePipeline<Diagram, Grid> {
|
||||
if (!this._pipeline) throw new Error("Diagram renderable was not initialized")
|
||||
return this._pipeline
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { parseColor, type ColorInput, type RGBA } from "@opentui/core"
|
||||
import { colorsEqual } from "../color/style.js"
|
||||
|
||||
export function parseDiagramRenderableColor(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value ? parseColor(value) : undefined
|
||||
}
|
||||
|
||||
export function setDiagramRenderableColor(
|
||||
current: RGBA | undefined,
|
||||
value: ColorInput | undefined,
|
||||
assign: (color: RGBA | undefined) => void,
|
||||
invalidate: () => void,
|
||||
): void {
|
||||
const next = parseDiagramRenderableColor(value)
|
||||
if (colorsEqual(current, next)) return
|
||||
assign(next)
|
||||
invalidate()
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
|
||||
|
||||
describe("DiagramRenderablePipeline", () => {
|
||||
test("caches the parsed diagram and grid across style repaints", () => {
|
||||
let parseCount = 0
|
||||
let drawCount = 0
|
||||
let publishCount = 0
|
||||
const pipeline = new DiagramRenderablePipeline({
|
||||
parse: () => ({ version: ++parseCount }),
|
||||
draw: (diagram) => ({ version: diagram.version, draw: ++drawCount }),
|
||||
publish: () => {
|
||||
publishCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
pipeline.invalidateParsedDiagram()
|
||||
pipeline.invalidateStyle()
|
||||
pipeline.invalidateGrid()
|
||||
|
||||
expect({ parseCount, drawCount, publishCount }).toEqual({ parseCount: 1, drawCount: 2, publishCount: 3 })
|
||||
})
|
||||
|
||||
test("reparses source changes and coalesces nested invalidations", () => {
|
||||
const events: string[] = []
|
||||
const pipeline = new DiagramRenderablePipeline({
|
||||
parse: () => {
|
||||
events.push("parse")
|
||||
return {}
|
||||
},
|
||||
draw: (diagram) => {
|
||||
events.push("draw")
|
||||
return diagram
|
||||
},
|
||||
publish: () => events.push("publish"),
|
||||
})
|
||||
|
||||
pipeline.invalidateParsedDiagram()
|
||||
events.length = 0
|
||||
pipeline.batchUpdate(() => {
|
||||
pipeline.invalidateStyle()
|
||||
pipeline.batchUpdate(() => pipeline.invalidateGrid())
|
||||
pipeline.invalidateParsedDiagram()
|
||||
})
|
||||
|
||||
expect(events).toEqual(["parse", "draw", "publish"])
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
export interface DiagramRenderablePipelineOptions<Diagram, Grid> {
|
||||
parse: () => Diagram
|
||||
draw: (diagram: Diagram) => Grid
|
||||
publish: (grid: Grid) => void
|
||||
didDraw?: (grid: Grid) => void
|
||||
}
|
||||
|
||||
type DiagramRenderableInvalidation = "grid" | "style"
|
||||
|
||||
export class DiagramRenderablePipeline<Diagram, Grid> {
|
||||
private _diagram?: Diagram
|
||||
private _grid?: Grid
|
||||
private _batchDepth = 0
|
||||
private _pending?: DiagramRenderableInvalidation
|
||||
|
||||
constructor(private readonly options: DiagramRenderablePipelineOptions<Diagram, Grid>) {}
|
||||
|
||||
diagram(): Diagram {
|
||||
this._diagram ??= this.options.parse()
|
||||
return this._diagram
|
||||
}
|
||||
|
||||
batchUpdate(update: () => void): void {
|
||||
this._batchDepth += 1
|
||||
try {
|
||||
update()
|
||||
} finally {
|
||||
this._batchDepth -= 1
|
||||
if (this._batchDepth === 0 && this._pending) {
|
||||
const pending = this._pending
|
||||
this._pending = undefined
|
||||
this.render(pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateParsedDiagram(): void {
|
||||
this._diagram = undefined
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
invalidateGrid(): void {
|
||||
this._grid = undefined
|
||||
this.invalidate("grid")
|
||||
}
|
||||
|
||||
invalidateStyle(): void {
|
||||
this.invalidate("style")
|
||||
}
|
||||
|
||||
private invalidate(level: DiagramRenderableInvalidation): void {
|
||||
if (this._batchDepth > 0) {
|
||||
if (level === "grid" || !this._pending) this._pending = level
|
||||
return
|
||||
}
|
||||
this.render(level)
|
||||
}
|
||||
|
||||
private render(level: DiagramRenderableInvalidation): void {
|
||||
let grid = this._grid
|
||||
if (level === "grid" || !grid) {
|
||||
grid = this.options.draw(this.diagram())
|
||||
this._grid = grid
|
||||
this.options.didDraw?.(grid)
|
||||
}
|
||||
this.options.publish(grid)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stringWidth from "string-width"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "./canvas.js"
|
||||
|
||||
describe("DiagramCanvas", () => {
|
||||
test("writes cells and text while clipping out-of-bounds positions", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 2)
|
||||
|
||||
canvas.setCell(0, 0, "A", "label")
|
||||
canvas.setCell(9, 0, "X", "label")
|
||||
canvas.setText(2, 1, "hey", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("A\n hey")
|
||||
})
|
||||
|
||||
test("uses measured character widths for text placement", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 1)
|
||||
|
||||
canvas.setText(0, 0, "a界b", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("a界b")
|
||||
expect(stringWidth(canvas.toString())).toBe(4)
|
||||
})
|
||||
|
||||
test("preserves combined graphemes while placing later text", () => {
|
||||
const canvas = new DiagramCanvas<"label">(4, 1)
|
||||
|
||||
canvas.setText(0, 0, "e\u0301x", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("e\u0301x")
|
||||
expect(stringWidth(canvas.toString())).toBe(2)
|
||||
})
|
||||
|
||||
test("merges cells through the adapter-provided merge function", () => {
|
||||
type Style = "line"
|
||||
const canvas = new DiagramCanvas<Style>(3, 1, {
|
||||
mergeCell: (existing, incoming): DiagramCanvasCell<Style> => ({
|
||||
...incoming,
|
||||
char: existing.char === "─" && incoming.char === "│" ? "┼" : incoming.char,
|
||||
}),
|
||||
})
|
||||
|
||||
canvas.setCell(1, 0, "─", "line")
|
||||
canvas.setCell(1, 0, "│", "line")
|
||||
|
||||
expect(canvas.toString()).toBe(" ┼")
|
||||
})
|
||||
|
||||
test("iterates style and metadata runs", () => {
|
||||
interface Metadata {
|
||||
stateId?: string
|
||||
}
|
||||
const canvas = new DiagramCanvas<"state", Metadata>(4, 1)
|
||||
const runs: string[] = []
|
||||
|
||||
canvas.setText(0, 0, "AB", "state", { stateId: "A" })
|
||||
canvas.setText(2, 0, "CD", "state", { stateId: "B" })
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(`${run.text}:${run.style}:${run.cell.stateId}`),
|
||||
() => runs.push("newline"),
|
||||
{ key: (cell) => [cell.style, cell.stateId] },
|
||||
)
|
||||
|
||||
expect(runs).toEqual(["AB:state:A", "CD:state:B"])
|
||||
})
|
||||
|
||||
test("can trim bottom whitespace for renderers with dynamic height", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
const runs: string[] = []
|
||||
canvas.setText(0, 0, "top", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("top\n\n")
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("top")
|
||||
expect(canvas.getTextSize()).toEqual({ width: 3, height: 3 })
|
||||
expect(canvas.getTextSize({ trimBottom: true })).toEqual({ width: 3, height: 1 })
|
||||
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(run.text),
|
||||
() => runs.push("newline"),
|
||||
{ trimBottom: true },
|
||||
)
|
||||
expect(runs).toEqual(["top"])
|
||||
})
|
||||
|
||||
test("can trim unused leading whitespace reserved by layout", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
canvas.setText(0, 2, "end", "label")
|
||||
|
||||
expect(canvas.toString({ trimTop: true })).toBe("end")
|
||||
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
|
||||
})
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
import stringWidth from "string-width"
|
||||
import { diagramTextGraphemes } from "./text.js"
|
||||
|
||||
export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
|
||||
char: string
|
||||
style?: Style
|
||||
} & Partial<Metadata>
|
||||
|
||||
export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
|
||||
text: string
|
||||
style: Style | undefined
|
||||
cell: DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
|
||||
measure?: (text: string) => number
|
||||
mergeCell?: (
|
||||
existing: DiagramCanvasCell<Style, Metadata>,
|
||||
incoming: DiagramCanvasCell<Style, Metadata>,
|
||||
) => DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextOptions {
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type DiagramCanvasTextMetadata<Metadata extends object> =
|
||||
| Partial<Metadata>
|
||||
| ((x: number, y: number) => Partial<Metadata>)
|
||||
|
||||
export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
|
||||
key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
|
||||
return { char: " " } as DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
|
||||
return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
|
||||
}
|
||||
|
||||
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
|
||||
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
|
||||
|
||||
private readonly measure: (text: string) => number
|
||||
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
|
||||
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
options: DiagramCanvasOptions<Style, Metadata> = {},
|
||||
) {
|
||||
this.measure = options.measure ?? stringWidth
|
||||
this.mergeCell = options.mergeCell
|
||||
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
|
||||
}
|
||||
|
||||
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
|
||||
let rowEnd = row.length
|
||||
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
|
||||
return rowEnd
|
||||
}
|
||||
|
||||
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
|
||||
return row
|
||||
.slice(0, rowEnd)
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
}
|
||||
|
||||
private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
|
||||
let start = 0
|
||||
let end = this.rows.length
|
||||
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
|
||||
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
|
||||
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[y]!.length) return
|
||||
|
||||
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
|
||||
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
|
||||
}
|
||||
|
||||
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
|
||||
return this.rows[y]?.[x]
|
||||
}
|
||||
|
||||
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
|
||||
let offset = 0
|
||||
for (const grapheme of diagramTextGraphemes(text)) {
|
||||
const width = Math.max(1, this.measure(grapheme))
|
||||
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
|
||||
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
|
||||
for (let continuation = 1; continuation < width; continuation++) {
|
||||
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
|
||||
}
|
||||
offset += width
|
||||
}
|
||||
}
|
||||
|
||||
toString(options: DiagramCanvasTextOptions = {}): string {
|
||||
const lines: string[] = []
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
lines.push(this.rowText(this.rows[rowIndex]!))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
let width = 0
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
|
||||
}
|
||||
return { width, height: rows.end - rows.start }
|
||||
}
|
||||
|
||||
forEachRun(
|
||||
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
|
||||
onLineEnd: () => void,
|
||||
options: DiagramCanvasRunOptions<Style, Metadata> = {},
|
||||
): void {
|
||||
const key = options.key
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
|
||||
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
|
||||
let currentKey: readonly unknown[] | undefined
|
||||
let currentText = ""
|
||||
const flush = () => {
|
||||
if (!currentText || !currentCell) return
|
||||
onRun({ text: currentText, style: currentCell.style, cell: currentCell })
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
for (let x = 0; x < rowEnd; x++) {
|
||||
const cell = row[x]!
|
||||
const nextKey = key?.(cell)
|
||||
const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
|
||||
if (!sameRun) {
|
||||
flush()
|
||||
currentCell = cell
|
||||
currentKey = nextKey
|
||||
}
|
||||
currentText += cell.char
|
||||
}
|
||||
|
||||
flush()
|
||||
if (rowIndex < rows.end - 1) onLineEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
|
||||
import { colorsEqual } from "./style.js"
|
||||
|
||||
export type DiagramColorMapInput = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
|
||||
|
||||
export const DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR = "::cell:"
|
||||
export const DIAGRAM_CELL_COLOR_LEVEL_COUNT = 6
|
||||
|
||||
export function diagramCellColorKey(id: string, level: number): string {
|
||||
const normalizedLevel = Math.max(0, Math.min(DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1, Math.round(level)))
|
||||
return `${id}${DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR}${normalizedLevel}`
|
||||
}
|
||||
|
||||
export function diagramRadialCellColorLevel(
|
||||
bounds: { width: number; height: number; centerX: number; centerY: number },
|
||||
x: number,
|
||||
y: number,
|
||||
border = false,
|
||||
): number {
|
||||
const halfWidth = Math.max(1, (bounds.width - 1) / 2)
|
||||
const halfHeight = Math.max(1, (bounds.height - 1) / 2)
|
||||
const dx = (x - bounds.centerX) / halfWidth
|
||||
const dy = (y - bounds.centerY) / halfHeight
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const level = Math.round((1 - Math.min(1, distance)) * (DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1))
|
||||
return border ? Math.min(1, level) : level
|
||||
}
|
||||
|
||||
export function baseDiagramCellColorKey(id: string): string {
|
||||
const index = id.lastIndexOf(DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR)
|
||||
return index === -1 ? id : id.slice(0, index)
|
||||
}
|
||||
|
||||
export function mappedDiagramColor(
|
||||
colors: ReadonlyMap<string, RGBA> | undefined,
|
||||
id: string | undefined,
|
||||
): RGBA | undefined {
|
||||
return id ? (colors?.get(id) ?? colors?.get(baseDiagramCellColorKey(id))) : undefined
|
||||
}
|
||||
|
||||
export function normalizeDiagramColorMap(value: DiagramColorMapInput | undefined): Map<string, RGBA> {
|
||||
const colors = new Map<string, RGBA>()
|
||||
if (!value) return colors
|
||||
|
||||
const entries = value instanceof Map ? value.entries() : Object.entries(value)
|
||||
for (const [id, color] of entries) {
|
||||
if (color !== undefined) colors.set(id, parseColor(color))
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
export function diagramColorMapsEqual(left: ReadonlyMap<string, RGBA>, right: ReadonlyMap<string, RGBA>): boolean {
|
||||
if (left.size !== right.size) return false
|
||||
for (const [id, color] of left) {
|
||||
if (!colorsEqual(color, right.get(id))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
baseDiagramCellColorKey,
|
||||
diagramCellColorKey,
|
||||
diagramColorMapsEqual,
|
||||
diagramRadialCellColorLevel,
|
||||
mappedDiagramColor,
|
||||
normalizeDiagramColorMap,
|
||||
} from "./map.js"
|
||||
import {
|
||||
ansiBg,
|
||||
ansiFg,
|
||||
blendColor,
|
||||
brightenColor,
|
||||
createAnsiRampTheme,
|
||||
mixRgb,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
} from "./style.js"
|
||||
|
||||
describe("diagram style helpers", () => {
|
||||
test("mixes rgb values and emits truecolor ANSI", () => {
|
||||
expect(mixRgb([0, 10, 20], [10, 30, 60], 0.5)).toEqual([5, 20, 40])
|
||||
expect(ansiFg([1, 2, 3])).toBe("\x1b[38;2;1;2;3m")
|
||||
expect(ansiBg([4, 5, 6])).toBe("\x1b[48;2;4;5;6m")
|
||||
})
|
||||
|
||||
test("converts rgb tuples and blends optional RGBA values", () => {
|
||||
const black = RGBA.fromInts(0, 0, 0, 255)
|
||||
const white = RGBA.fromInts(10, 20, 30, 255)
|
||||
|
||||
expect(rgba([1, 2, 3]).equals(RGBA.fromInts(1, 2, 3, 255))).toBe(true)
|
||||
expect(blendColor(black, white, 0.5).equals(RGBA.fromInts(5, 10, 15, 255))).toBe(true)
|
||||
expect(blendColor(undefined, white, 0.5)?.equals(white)).toBe(true)
|
||||
expect(blendColor(undefined, undefined, 0.5)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("brightens colors toward white", () => {
|
||||
const color = brightenColor(RGBA.fromInts(100, 150, 200, 255), 0.5)
|
||||
|
||||
expect(color?.equals(RGBA.fromInts(178, 203, 228, 255))).toBe(true)
|
||||
expect(brightenColor(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("creates numbered style keys and ANSI ramps", () => {
|
||||
const styles = numberedStyleKeys("requestFade", [1, 2, 3] as const)
|
||||
const theme = createAnsiRampTheme(styles, [0, 0, 0], [12, 24, 36])
|
||||
|
||||
expect(styles).toEqual(["requestFade1", "requestFade2", "requestFade3"])
|
||||
expect(theme.requestFade1).toBe("\x1b[38;2;3;6;9m")
|
||||
expect(theme.requestFade2).toBe("\x1b[38;2;6;12;18m")
|
||||
expect(theme.requestFade3).toBe("\x1b[38;2;9;18;27m")
|
||||
})
|
||||
|
||||
test("normalizes and resolves leveled diagram color maps", () => {
|
||||
const red = RGBA.fromInts(255, 0, 0, 255)
|
||||
const blue = RGBA.fromInts(0, 0, 255, 255)
|
||||
const colors = normalizeDiagramColorMap({
|
||||
A: red,
|
||||
B: undefined,
|
||||
[diagramCellColorKey("C", 2)]: blue,
|
||||
})
|
||||
|
||||
expect(colors.size).toBe(2)
|
||||
expect(diagramCellColorKey("A", -1)).toBe("A::cell:0")
|
||||
expect(diagramCellColorKey("A", 10)).toBe("A::cell:5")
|
||||
expect(baseDiagramCellColorKey("A::cell:3")).toBe("A")
|
||||
expect(mappedDiagramColor(colors, "A")?.equals(red)).toBe(true)
|
||||
expect(mappedDiagramColor(colors, "C::cell:2")?.equals(blue)).toBe(true)
|
||||
expect(mappedDiagramColor(colors, "C::cell:4")).toBeUndefined()
|
||||
expect(diagramColorMapsEqual(colors, normalizeDiagramColorMap(new Map(colors)))).toBe(true)
|
||||
})
|
||||
|
||||
test("computes radial diagram cell color levels", () => {
|
||||
const bounds = { width: 9, height: 5, centerX: 4, centerY: 2 }
|
||||
|
||||
expect(diagramRadialCellColorLevel(bounds, 4, 2)).toBe(5)
|
||||
expect(diagramRadialCellColorLevel(bounds, 0, 0)).toBe(0)
|
||||
expect(diagramRadialCellColorLevel(bounds, 4, 2, true)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export type DiagramRgb = readonly [number, number, number]
|
||||
export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
|
||||
|
||||
export function numberedStyleKeys<Prefix extends string, Step extends number>(
|
||||
prefix: Prefix,
|
||||
steps: readonly Step[],
|
||||
): Array<`${Prefix}${Step}`> {
|
||||
return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
|
||||
}
|
||||
|
||||
export function mixChannel(left: number, right: number, amount: number): number {
|
||||
return Math.round(left + (right - left) * amount)
|
||||
}
|
||||
|
||||
export function mixRgb(left: DiagramRgb, right: DiagramRgb, amount: number): DiagramRgb {
|
||||
return [
|
||||
mixChannel(left[0], right[0], amount),
|
||||
mixChannel(left[1], right[1], amount),
|
||||
mixChannel(left[2], right[2], amount),
|
||||
]
|
||||
}
|
||||
|
||||
export function ansiFg(rgb: DiagramRgb): string {
|
||||
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
|
||||
}
|
||||
|
||||
export function ansiBg(rgb: DiagramRgb): string {
|
||||
return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
|
||||
}
|
||||
|
||||
export function rgba(rgb: DiagramRgb): RGBA {
|
||||
return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
|
||||
}
|
||||
|
||||
export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
|
||||
if (!from && !to) return undefined
|
||||
if (!from) return to
|
||||
if (!to) return from
|
||||
|
||||
const [fromR, fromG, fromB, fromA] = from.toInts()
|
||||
const [toR, toG, toB, toA] = to.toInts()
|
||||
const mix = (left: number, right: number) => left + (right - left) * amount
|
||||
|
||||
return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
|
||||
}
|
||||
|
||||
export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
|
||||
if (!left || !right) return left === right
|
||||
return left.equals(right)
|
||||
}
|
||||
|
||||
export function brightenColor(color: RGBA | undefined, amount: number = 0.35): RGBA | undefined {
|
||||
if (!color) return undefined
|
||||
|
||||
const [r, g, b, a] = color.toInts()
|
||||
return RGBA.fromInts(mixChannel(r, 255, amount), mixChannel(g, 255, amount), mixChannel(b, 255, amount), a)
|
||||
}
|
||||
|
||||
export function createAnsiRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: DiagramRgb,
|
||||
to: DiagramRgb,
|
||||
): Record<Style, string> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, ansiFg(mixRgb(from, to, (index + 1) / (styles.length + 1)))]),
|
||||
) as Record<Style, string>
|
||||
}
|
||||
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA,
|
||||
to: RGBA,
|
||||
): Record<Style, RGBA>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
|
||||
) as Record<Style, RGBA | undefined>
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import { DiagramCanvas } from "./canvas.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "./drawing.js"
|
||||
|
||||
describe("diagram drawing", () => {
|
||||
test("merges line glyphs with square and rounded corners", () => {
|
||||
expect(mergeDiagramLineGlyph("─", "│")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "│", "rounded")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "╭", "rounded")).toBe("┬")
|
||||
expect(mergeDiagramLineGlyph("a", "│")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("draws frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(8, 4)
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 4, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("draws diamond frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(9, 5)
|
||||
drawDiagramDiamond({ left: 0, top: 0, width: 9, height: 5, centerX: 4, centerY: 2 }, (x, y, char) =>
|
||||
canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭───╮\n╭─╯ ╰─╮\n│ │\n╰─╮ ╭─╯\n ╰───╯")
|
||||
expect(canvas.toString()).not.toMatch(/[╱╲\\/]/)
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with selected corner style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ cornerStyle: "rounded" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("───╮\n │\n ╰──")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with heavy line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "heavy" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("━━━┓\n ┃\n ┗━━")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with dashed line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(9, 1)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 8, y: 0 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "dashed" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("─ ─ ─ ─")
|
||||
})
|
||||
|
||||
test("keeps container frame policy separate from edge drawing policy", () => {
|
||||
const canvas = new DiagramCanvas<"group" | "edge">(8, 5, {
|
||||
mergeCell: (existing, incoming) => {
|
||||
if (incoming.style === "edge") return incoming
|
||||
return existing.char === " " ? incoming : existing
|
||||
},
|
||||
})
|
||||
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 5, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "group"),
|
||||
)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
)
|
||||
canvas.setCell(7, 2, "▶", "edge")
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n───────▶\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("selects filled and line arrow heads", () => {
|
||||
expect(diagramArrowHead("right")).toBe("▶")
|
||||
expect(diagramArrowHead("left", "line")).toBe("←")
|
||||
})
|
||||
})
|
||||
@@ -1,295 +0,0 @@
|
||||
import { BorderChars, type BorderCharacters } from "@opentui/core"
|
||||
import {
|
||||
directionBetween,
|
||||
walkOrthogonalSegment,
|
||||
type DiagramBounds,
|
||||
type DiagramDirection,
|
||||
type DiagramPoint,
|
||||
} from "./geometry.js"
|
||||
|
||||
export type DiagramLineCornerStyle = "square" | "rounded"
|
||||
export type DiagramLineStyle = "single" | "heavy" | "dashed"
|
||||
export type DiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface DiagramDiamondCharacters {
|
||||
topLeft: string
|
||||
topRight: string
|
||||
upperLeft: string
|
||||
upperLeftJoin: string
|
||||
upperRightJoin: string
|
||||
upperRight: string
|
||||
vertical: string
|
||||
lowerLeft: string
|
||||
lowerLeftJoin: string
|
||||
lowerRightJoin: string
|
||||
lowerRight: string
|
||||
bottomLeft: string
|
||||
bottomRight: string
|
||||
horizontal: string
|
||||
}
|
||||
|
||||
export const DIAGRAM_ARROW_HEADS = new Set(["▶", "◀", "▼", "▲", "→", "←", "↓", "↑"])
|
||||
const HEAVY_LINE_GLYPHS = new Set(Object.values(BorderChars.heavy))
|
||||
|
||||
export const DIAGRAM_DIAMOND_CHARS = {
|
||||
topLeft: "╭",
|
||||
topRight: "╮",
|
||||
upperLeft: "╭",
|
||||
upperLeftJoin: "╯",
|
||||
upperRightJoin: "╰",
|
||||
upperRight: "╮",
|
||||
vertical: "│",
|
||||
lowerLeft: "╰",
|
||||
lowerLeftJoin: "╮",
|
||||
lowerRightJoin: "╭",
|
||||
lowerRight: "╯",
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
} as const satisfies DiagramDiamondCharacters
|
||||
|
||||
export function diagramDiamondCharactersFromBorder(chars: BorderCharacters): DiagramDiamondCharacters {
|
||||
return {
|
||||
topLeft: chars.topLeft,
|
||||
topRight: chars.topRight,
|
||||
upperLeft: chars.topLeft,
|
||||
upperLeftJoin: chars.bottomRight,
|
||||
upperRightJoin: chars.bottomLeft,
|
||||
upperRight: chars.topRight,
|
||||
vertical: chars.vertical,
|
||||
lowerLeft: chars.bottomLeft,
|
||||
lowerLeftJoin: chars.topRight,
|
||||
lowerRightJoin: chars.topLeft,
|
||||
lowerRight: chars.bottomRight,
|
||||
bottomLeft: chars.bottomLeft,
|
||||
bottomRight: chars.bottomRight,
|
||||
horizontal: chars.horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
function lineDirections(char: string): readonly DiagramDirection[] | undefined {
|
||||
switch (char) {
|
||||
case "─":
|
||||
return ["left", "right"]
|
||||
case "│":
|
||||
return ["up", "down"]
|
||||
case "━":
|
||||
return ["left", "right"]
|
||||
case "┃":
|
||||
return ["up", "down"]
|
||||
case "┌":
|
||||
case "╭":
|
||||
case "┏":
|
||||
return ["right", "down"]
|
||||
case "┐":
|
||||
case "╮":
|
||||
case "┓":
|
||||
return ["left", "down"]
|
||||
case "└":
|
||||
case "╰":
|
||||
case "┗":
|
||||
return ["up", "right"]
|
||||
case "┘":
|
||||
case "╯":
|
||||
case "┛":
|
||||
return ["up", "left"]
|
||||
case "├":
|
||||
case "┣":
|
||||
return ["up", "down", "right"]
|
||||
case "┤":
|
||||
case "┫":
|
||||
return ["up", "down", "left"]
|
||||
case "┬":
|
||||
case "┳":
|
||||
return ["left", "right", "down"]
|
||||
case "┴":
|
||||
case "┻":
|
||||
return ["left", "right", "up"]
|
||||
case "┼":
|
||||
case "╋":
|
||||
return ["up", "down", "left", "right"]
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function diagramLineGlyph(
|
||||
directions: ReadonlySet<DiagramDirection>,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
lineStyle: DiagramLineStyle = "single",
|
||||
): string {
|
||||
const up = directions.has("up")
|
||||
const down = directions.has("down")
|
||||
const left = directions.has("left")
|
||||
const right = directions.has("right")
|
||||
if (lineStyle === "heavy") {
|
||||
const chars = BorderChars.heavy
|
||||
if (up && down && left && right) return chars.cross
|
||||
if (up && down && right) return chars.leftT
|
||||
if (up && down && left) return chars.rightT
|
||||
if (left && right && down) return chars.topT
|
||||
if (left && right && up) return chars.bottomT
|
||||
if (up && right) return chars.bottomLeft
|
||||
if (up && left) return chars.bottomRight
|
||||
if (down && right) return chars.topLeft
|
||||
if (down && left) return chars.topRight
|
||||
if (up || down) return chars.vertical
|
||||
return chars.horizontal
|
||||
}
|
||||
if (up && down && left && right) return "┼"
|
||||
if (up && down && right) return "├"
|
||||
if (up && down && left) return "┤"
|
||||
if (left && right && down) return "┬"
|
||||
if (left && right && up) return "┴"
|
||||
if (up && right) return cornerStyle === "rounded" ? "╰" : "└"
|
||||
if (up && left) return cornerStyle === "rounded" ? "╯" : "┘"
|
||||
if (down && right) return cornerStyle === "rounded" ? "╭" : "┌"
|
||||
if (down && left) return cornerStyle === "rounded" ? "╮" : "┐"
|
||||
if (up || down) return "│"
|
||||
return "─"
|
||||
}
|
||||
|
||||
function isHeavyLineGlyph(char: string): boolean {
|
||||
return HEAVY_LINE_GLYPHS.has(char)
|
||||
}
|
||||
|
||||
function segmentGlyph(direction: DiagramDirection, lineStyle: DiagramLineStyle | undefined): string {
|
||||
const directions = new Set<DiagramDirection>(
|
||||
direction === "left" || direction === "right" ? ["left", "right"] : ["up", "down"],
|
||||
)
|
||||
return diagramLineGlyph(directions, "square", lineStyle === "heavy" ? "heavy" : "single")
|
||||
}
|
||||
|
||||
export function mergeDiagramLineGlyph(
|
||||
existing: string,
|
||||
incoming: string,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
): string | undefined {
|
||||
const existingDirections = lineDirections(existing)
|
||||
const incomingDirections = lineDirections(incoming)
|
||||
if (!existingDirections || !incomingDirections) return undefined
|
||||
|
||||
return diagramLineGlyph(
|
||||
new Set([...existingDirections, ...incomingDirections]),
|
||||
cornerStyle,
|
||||
isHeavyLineGlyph(existing) && isHeavyLineGlyph(incoming) ? "heavy" : "single",
|
||||
)
|
||||
}
|
||||
|
||||
export function diagramArrowHead(direction: DiagramDirection, style: DiagramArrowHeadStyle = "filled"): string {
|
||||
if (style === "line") {
|
||||
if (direction === "right") return "→"
|
||||
if (direction === "left") return "←"
|
||||
if (direction === "up") return "↑"
|
||||
return "↓"
|
||||
}
|
||||
|
||||
if (direction === "right") return "▶"
|
||||
if (direction === "left") return "◀"
|
||||
if (direction === "up") return "▲"
|
||||
return "▼"
|
||||
}
|
||||
|
||||
export function diagramArrowHeadBetween(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
style: DiagramArrowHeadStyle = "filled",
|
||||
): string {
|
||||
const direction = directionBetween(from, to)
|
||||
return direction ? diagramArrowHead(direction, style) : diagramArrowHead("right", style)
|
||||
}
|
||||
|
||||
export function drawDiagramFrame(
|
||||
bounds: DiagramBounds,
|
||||
chars: BorderCharacters,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
): void {
|
||||
setCell(bounds.left, bounds.top, chars.topLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top, chars.topRight)
|
||||
setCell(bounds.left, bounds.top + bounds.height - 1, chars.bottomLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top + bounds.height - 1, chars.bottomRight)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
setCell(x, bounds.top, chars.horizontal)
|
||||
setCell(x, bounds.top + bounds.height - 1, chars.horizontal)
|
||||
}
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
setCell(bounds.left, y, chars.vertical)
|
||||
setCell(bounds.left + bounds.width - 1, y, chars.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawDiagramDiamond(
|
||||
bounds: DiagramBounds,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
chars: DiagramDiamondCharacters = DIAGRAM_DIAMOND_CHARS,
|
||||
): void {
|
||||
const left = bounds.left
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const top = bounds.top
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
const capInset = Math.min(2, Math.max(1, Math.floor((bounds.width - 1) / 2)))
|
||||
const capLeft = left + capInset
|
||||
const capRight = right - capInset
|
||||
|
||||
setCell(capLeft, top, chars.topLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, top, chars.horizontal)
|
||||
setCell(capRight, top, chars.topRight)
|
||||
|
||||
setCell(left, top + 1, chars.upperLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(capLeft, top + 1, chars.upperLeftJoin)
|
||||
setCell(capRight, top + 1, chars.upperRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(right, top + 1, chars.upperRight)
|
||||
|
||||
for (let y = top + 2; y < bottom - 1; y++) {
|
||||
setCell(left, y, chars.vertical)
|
||||
setCell(right, y, chars.vertical)
|
||||
}
|
||||
|
||||
setCell(left, bottom - 1, chars.lowerLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(capLeft, bottom - 1, chars.lowerLeftJoin)
|
||||
setCell(capRight, bottom - 1, chars.lowerRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(right, bottom - 1, chars.lowerRight)
|
||||
|
||||
setCell(capLeft, bottom, chars.bottomLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, bottom, chars.horizontal)
|
||||
setCell(capRight, bottom, chars.bottomRight)
|
||||
}
|
||||
|
||||
export function drawOrthogonalPath(
|
||||
points: readonly DiagramPoint[],
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
options: { cornerStyle?: DiagramLineCornerStyle; lineStyle?: DiagramLineStyle } = {},
|
||||
): void {
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
const glyph = segmentGlyph(direction, options.lineStyle)
|
||||
let step = index === 1 ? 0 : 1
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (options.lineStyle !== "dashed" || step % 2 === 0) setCell(point.x, point.y, glyph)
|
||||
step += 1
|
||||
})
|
||||
}
|
||||
|
||||
for (let index = 1; index < points.length - 1; index++) {
|
||||
const previous = points[index - 1]!
|
||||
const current = points[index]!
|
||||
const next = points[index + 1]!
|
||||
const fromDirection = directionBetween(current, previous)
|
||||
const toDirection = directionBetween(current, next)
|
||||
const directions = new Set<DiagramDirection>()
|
||||
if (fromDirection) directions.add(fromDirection)
|
||||
if (toDirection) directions.add(toDirection)
|
||||
setCell(
|
||||
current.x,
|
||||
current.y,
|
||||
diagramLineGlyph(directions, options.cornerStyle, options.lineStyle === "heavy" ? "heavy" : "single"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
diagramBoundsFromRect,
|
||||
boundsSidePoint,
|
||||
directionBetween,
|
||||
lane,
|
||||
orthogonalPath,
|
||||
orthogonalPathPoints,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
point,
|
||||
translateDiagramBounds,
|
||||
walkOrthogonalSegment,
|
||||
} from "./geometry.js"
|
||||
|
||||
describe("diagram geometry", () => {
|
||||
test("directions are only defined for orthogonal point pairs", () => {
|
||||
expect(directionBetween(point(1, 2), point(5, 2))).toBe("right")
|
||||
expect(directionBetween(point(1, 2), point(1, 0))).toBe("up")
|
||||
expect(directionBetween(point(1, 2), point(5, 4))).toBeUndefined()
|
||||
expect(directionBetween(point(1, 2), point(1, 2))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds side points describe border and outside ports", () => {
|
||||
const bounds = { left: 10, top: 4, width: 8, height: 5, centerX: 14, centerY: 6 }
|
||||
|
||||
expect(boundsSidePoint(bounds, "left", "border")).toEqual(point(10, 6))
|
||||
expect(boundsSidePoint(bounds, "left")).toEqual(point(9, 6))
|
||||
expect(boundsSidePoint(bounds, "right", "border")).toEqual(point(17, 6))
|
||||
expect(boundsSidePoint(bounds, "right")).toEqual(point(18, 6))
|
||||
expect(boundsSidePoint(bounds, "top", "border")).toEqual(point(14, 4))
|
||||
expect(boundsSidePoint(bounds, "bottom")).toEqual(point(14, 9))
|
||||
})
|
||||
|
||||
test("bounds helpers create, translate, and union bounds", () => {
|
||||
const bounds = diagramBoundsFromRect(2, 3, 5, 4)
|
||||
|
||||
expect(bounds).toEqual({ left: 2, top: 3, width: 5, height: 4, centerX: 4, centerY: 5 })
|
||||
translateDiagramBounds(bounds, 3, -1)
|
||||
expect(bounds).toEqual({ left: 5, top: 2, width: 5, height: 4, centerX: 7, centerY: 4 })
|
||||
expect(diagramBoundsFromBounds([bounds, diagramBoundsFromRect(0, 0, 2, 2)])).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 6,
|
||||
centerX: 5,
|
||||
centerY: 3,
|
||||
})
|
||||
expect(diagramBoundsFromPoints([point(2, 2), point(4, 5)])).toEqual({
|
||||
left: 2,
|
||||
top: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
centerX: 3,
|
||||
centerY: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test("paths compose through lanes while removing duplicate joints", () => {
|
||||
expect(pathThrough([point(0, 0), point(0, 0), point(3, 0)])).toEqual([point(0, 0), point(3, 0)])
|
||||
expect(pathViaLane(point(0, 0), lane("x", 4), point(8, 3))).toEqual([
|
||||
point(0, 0),
|
||||
point(4, 0),
|
||||
point(4, 3),
|
||||
point(8, 3),
|
||||
])
|
||||
})
|
||||
|
||||
test("composed paths do not alias caller-owned points", () => {
|
||||
const start = point(0, 0)
|
||||
const end = point(8, 3)
|
||||
const firstPath = pathViaLane(start, lane("x", 4), end)
|
||||
const secondPath = pathViaLane(start, lane("x", 5), end)
|
||||
|
||||
expect(firstPath[0]).not.toBe(start)
|
||||
expect(firstPath[firstPath.length - 1]).not.toBe(end)
|
||||
expect(firstPath[0]).not.toBe(secondPath[0])
|
||||
|
||||
firstPath[0]!.x += 10
|
||||
firstPath[firstPath.length - 1]!.y += 10
|
||||
|
||||
expect(start).toEqual(point(0, 0))
|
||||
expect(end).toEqual(point(8, 3))
|
||||
expect(secondPath[0]).toEqual(point(0, 0))
|
||||
expect(secondPath[secondPath.length - 1]).toEqual(point(8, 3))
|
||||
})
|
||||
|
||||
test("orthogonal paths choose a terminal lane on the dominant axis", () => {
|
||||
expect(orthogonalPath(point(0, 0), point(10, 4))).toEqual([point(0, 0), point(6, 0), point(6, 4), point(10, 4)])
|
||||
})
|
||||
|
||||
test("orthogonal segment walkers exclude endpoints", () => {
|
||||
const visited: Array<{ x: number; y: number }> = []
|
||||
walkOrthogonalSegment(point(0, 0), point(3, 0), false, (next) => {
|
||||
visited.push(next)
|
||||
})
|
||||
expect(visited).toEqual([point(1, 0), point(2, 0)])
|
||||
})
|
||||
|
||||
test("orthogonal path points include endpoints without duplicating joints", () => {
|
||||
expect(orthogonalPathPoints([point(0, 0), point(3, 0), point(3, 2)])).toEqual([
|
||||
point(0, 0),
|
||||
point(1, 0),
|
||||
point(2, 0),
|
||||
point(3, 0),
|
||||
point(3, 1),
|
||||
point(3, 2),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,347 +0,0 @@
|
||||
export type DiagramAxis = "x" | "y"
|
||||
export type DiagramDirection = "up" | "down" | "left" | "right"
|
||||
export type DiagramSide = "left" | "right" | "top" | "bottom"
|
||||
|
||||
export interface DiagramPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DiagramBounds {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface DiagramSegment {
|
||||
from: DiagramPoint
|
||||
to: DiagramPoint
|
||||
axis: DiagramAxis
|
||||
direction: DiagramDirection
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface DiagramLane {
|
||||
axis: DiagramAxis
|
||||
coordinate: number
|
||||
}
|
||||
|
||||
export interface DiagramSpan {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const DIRECTION_AXIS = {
|
||||
left: "x",
|
||||
right: "x",
|
||||
up: "y",
|
||||
down: "y",
|
||||
} as const satisfies Record<DiagramDirection, DiagramAxis>
|
||||
|
||||
const DIRECTION_SIGN = {
|
||||
left: -1,
|
||||
right: 1,
|
||||
up: -1,
|
||||
down: 1,
|
||||
} as const satisfies Record<DiagramDirection, -1 | 1>
|
||||
|
||||
const DIRECTION_SIDE = {
|
||||
left: "left",
|
||||
right: "right",
|
||||
up: "top",
|
||||
down: "bottom",
|
||||
} as const satisfies Record<DiagramDirection, DiagramSide>
|
||||
|
||||
const OPPOSITE_SIDE = {
|
||||
left: "right",
|
||||
right: "left",
|
||||
top: "bottom",
|
||||
bottom: "top",
|
||||
} as const satisfies Record<DiagramSide, DiagramSide>
|
||||
|
||||
export function point(x: number, y: number): DiagramPoint {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function diagramBoundsFromRect(left: number, top: number, width: number, height: number): DiagramBounds {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDiagramBounds(bounds: DiagramBounds, dx: number, dy: number): void {
|
||||
bounds.left += dx
|
||||
bounds.top += dy
|
||||
bounds.centerX += dx
|
||||
bounds.centerY += dy
|
||||
}
|
||||
|
||||
export function diagramBoundsFromBounds(bounds: readonly DiagramBounds[]): DiagramBounds | undefined {
|
||||
if (bounds.length === 0) return undefined
|
||||
const left = Math.min(...bounds.map((bound) => bound.left))
|
||||
const top = Math.min(...bounds.map((bound) => bound.top))
|
||||
const right = Math.max(...bounds.map((bound) => bound.left + bound.width))
|
||||
const bottom = Math.max(...bounds.map((bound) => bound.top + bound.height))
|
||||
return diagramBoundsFromRect(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
export function diagramBoundsFromPoints(points: readonly DiagramPoint[]): DiagramBounds | undefined {
|
||||
if (points.length === 0) return undefined
|
||||
const left = Math.min(...points.map((point) => point.x))
|
||||
const top = Math.min(...points.map((point) => point.y))
|
||||
const right = Math.max(...points.map((point) => point.x))
|
||||
const bottom = Math.max(...points.map((point) => point.y))
|
||||
return diagramBoundsFromRect(left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
export function coordinate(point: DiagramPoint, axis: DiagramAxis): number {
|
||||
return point[axis]
|
||||
}
|
||||
|
||||
export function withCoordinate(point: DiagramPoint, axis: DiagramAxis, value: number): DiagramPoint {
|
||||
return axis === "x" ? { x: value, y: point.y } : { x: point.x, y: value }
|
||||
}
|
||||
|
||||
export function shiftPoint(point: DiagramPoint, direction: DiagramDirection, distance = 1): DiagramPoint {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
return { x: point.x - distance, y: point.y }
|
||||
case "right":
|
||||
return { x: point.x + distance, y: point.y }
|
||||
case "up":
|
||||
return { x: point.x, y: point.y - distance }
|
||||
case "down":
|
||||
return { x: point.x, y: point.y + distance }
|
||||
}
|
||||
}
|
||||
|
||||
export function clampPoint(point: DiagramPoint, min: DiagramPoint = { x: 0, y: 0 }): DiagramPoint {
|
||||
return { x: Math.max(min.x, point.x), y: Math.max(min.y, point.y) }
|
||||
}
|
||||
|
||||
export function samePoint(left: DiagramPoint, right: DiagramPoint): boolean {
|
||||
return left.x === right.x && left.y === right.y
|
||||
}
|
||||
|
||||
export function directionAxis(direction: DiagramDirection): DiagramAxis {
|
||||
return DIRECTION_AXIS[direction]
|
||||
}
|
||||
|
||||
export function directionSign(direction: DiagramDirection): -1 | 1 {
|
||||
return DIRECTION_SIGN[direction]
|
||||
}
|
||||
|
||||
export function sideForDirection(direction: DiagramDirection): DiagramSide {
|
||||
return DIRECTION_SIDE[direction]
|
||||
}
|
||||
|
||||
export function oppositeSide(side: DiagramSide): DiagramSide {
|
||||
return OPPOSITE_SIDE[side]
|
||||
}
|
||||
|
||||
export function directionBetween(from: DiagramPoint, to: DiagramPoint): DiagramDirection | undefined {
|
||||
if (from.y === to.y) {
|
||||
if (to.x > from.x) return "right"
|
||||
if (to.x < from.x) return "left"
|
||||
}
|
||||
if (from.x === to.x) {
|
||||
if (to.y > from.y) return "down"
|
||||
if (to.y < from.y) return "up"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function boundsCenter(bounds: DiagramBounds): DiagramPoint {
|
||||
return point(bounds.centerX, bounds.centerY)
|
||||
}
|
||||
|
||||
export function boundsSidePoint(
|
||||
bounds: DiagramBounds,
|
||||
side: DiagramSide,
|
||||
surface: "border" | "outside" = "outside",
|
||||
): DiagramPoint {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return point(bounds.left - (surface === "outside" ? 1 : 0), bounds.centerY)
|
||||
case "right":
|
||||
return point(bounds.left + bounds.width - (surface === "outside" ? 0 : 1), bounds.centerY)
|
||||
case "top":
|
||||
return point(bounds.centerX, bounds.top - (surface === "outside" ? 1 : 0))
|
||||
case "bottom":
|
||||
return point(bounds.centerX, bounds.top + bounds.height - (surface === "outside" ? 0 : 1))
|
||||
}
|
||||
}
|
||||
|
||||
export function centerCoordinate(bounds: DiagramBounds, axis: DiagramAxis): number {
|
||||
return axis === "x" ? bounds.centerX : bounds.centerY
|
||||
}
|
||||
|
||||
export function snapCoordinate(source: number, target: number, tolerance: number): number {
|
||||
return Math.abs(source - target) <= tolerance ? target : source
|
||||
}
|
||||
|
||||
export function pathThrough(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (const next of points) {
|
||||
if (!path.length || !samePoint(path[path.length - 1]!, next)) path.push(point(next.x, next.y))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function lane(axis: DiagramAxis, coordinate: number): DiagramLane {
|
||||
return { axis, coordinate }
|
||||
}
|
||||
|
||||
export function pathViaLane(start: DiagramPoint, routeLane: DiagramLane, end: DiagramPoint): DiagramPoint[] {
|
||||
return pathThrough([
|
||||
start,
|
||||
withCoordinate(start, routeLane.axis, routeLane.coordinate),
|
||||
withCoordinate(end, routeLane.axis, routeLane.coordinate),
|
||||
end,
|
||||
])
|
||||
}
|
||||
|
||||
function dominantAxis(start: DiagramPoint, end: DiagramPoint): DiagramAxis {
|
||||
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y"
|
||||
}
|
||||
|
||||
function terminalLane(start: DiagramPoint, end: DiagramPoint, axis: DiagramAxis, terminalClearance: number): number {
|
||||
const startCoordinate = coordinate(start, axis)
|
||||
const endCoordinate = coordinate(end, axis)
|
||||
const delta = endCoordinate - startCoordinate
|
||||
const sign = Math.sign(delta)
|
||||
if (sign === 0) return endCoordinate
|
||||
return endCoordinate - sign * Math.min(terminalClearance, Math.max(1, Math.abs(delta) - 1))
|
||||
}
|
||||
|
||||
export function orthogonalPath(
|
||||
start: DiagramPoint,
|
||||
end: DiagramPoint,
|
||||
options: { preferredAxis?: DiagramAxis; terminalClearance?: number } = {},
|
||||
): DiagramPoint[] {
|
||||
if (start.x === end.x || start.y === end.y) return pathThrough([start, end])
|
||||
|
||||
const laneAxis = options.preferredAxis ?? dominantAxis(start, end)
|
||||
return pathViaLane(start, lane(laneAxis, terminalLane(start, end, laneAxis, options.terminalClearance ?? 4)), end)
|
||||
}
|
||||
|
||||
export function segmentBetween(from: DiagramPoint, to: DiagramPoint): DiagramSegment | undefined {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return undefined
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
axis: directionAxis(direction),
|
||||
direction,
|
||||
length: Math.abs(coordinate(to, directionAxis(direction)) - coordinate(from, directionAxis(direction))),
|
||||
}
|
||||
}
|
||||
|
||||
export function walkOrthogonalSegment(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
includeStart: boolean,
|
||||
visit: (point: DiagramPoint) => boolean | void,
|
||||
): void {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = includeStart ? from : point(from.x + dx, from.y + dy)
|
||||
|
||||
while (!samePoint(cursor, to)) {
|
||||
if (visit(cursor) === false) return
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
|
||||
export function orthogonalPathPoints(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = path.length === 0 ? point(from.x, from.y) : point(from.x + dx, from.y + dy)
|
||||
while (true) {
|
||||
path.push(cursor)
|
||||
if (samePoint(cursor, to)) break
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function orderedSpan(left: number, right: number): DiagramSpan {
|
||||
return left <= right ? { start: left, end: right } : { start: right, end: left }
|
||||
}
|
||||
|
||||
export function segmentSpan(segment: DiagramSegment): DiagramSpan {
|
||||
return orderedSpan(coordinate(segment.from, segment.axis), coordinate(segment.to, segment.axis))
|
||||
}
|
||||
|
||||
export function pointOnSegment(segment: DiagramSegment, coordinateValue: number): DiagramPoint {
|
||||
return withCoordinate(segment.from, segment.axis, coordinateValue)
|
||||
}
|
||||
|
||||
export function insetSpan(span: DiagramSpan, amount: number): DiagramSpan {
|
||||
return { start: span.start + amount, end: span.end - amount }
|
||||
}
|
||||
|
||||
export function spanCapacity(span: DiagramSpan): number {
|
||||
return Math.max(0, span.end - span.start + 1)
|
||||
}
|
||||
|
||||
export function centeredSpanStart(span: DiagramSpan, width: number): number {
|
||||
return span.start + Math.floor((spanCapacity(span) - width) / 2)
|
||||
}
|
||||
|
||||
export function midpoint(span: DiagramSpan): number {
|
||||
return Math.round((span.start + span.end) / 2)
|
||||
}
|
||||
|
||||
export function advanceCoordinate(origin: number, direction: DiagramDirection, distance: number): number {
|
||||
return origin + directionSign(direction) * distance
|
||||
}
|
||||
|
||||
export function beforeNearestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const nearest = directionSign(direction) > 0 ? Math.min(...coordinates) : Math.max(...coordinates)
|
||||
return advanceCoordinate(nearest, direction, -clearance)
|
||||
}
|
||||
|
||||
export function afterFarthestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const farthest = directionSign(direction) > 0 ? Math.max(...coordinates) : Math.min(...coordinates)
|
||||
return advanceCoordinate(farthest, direction, clearance)
|
||||
}
|
||||
|
||||
export function keepBefore(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.min(preferred, boundary) : Math.max(preferred, boundary)
|
||||
}
|
||||
|
||||
export function keepAfter(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.max(preferred, boundary) : Math.min(preferred, boundary)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface NumberedMermaidLine {
|
||||
readonly lineNumber: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export function* numberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
const lines = content.split(/\r?\n/)
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
yield { lineNumber: index + 1, text: lines[index]!.trim() }
|
||||
}
|
||||
}
|
||||
|
||||
export function* meaningfulNumberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
for (const line of numberedMermaidLines(content)) {
|
||||
if (line.text && !line.text.startsWith("%%")) yield line
|
||||
}
|
||||
}
|
||||
|
||||
export function firstMeaningfulMermaidLine(content: string): string | undefined {
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (line && !line.startsWith("%%")) return line
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function stripMermaidQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { StyledText, type TextChunk } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
|
||||
import { ANSI } from "./terminal/ansi.js"
|
||||
|
||||
export interface RenderDiagramGridAnsiOptions<
|
||||
Style extends string,
|
||||
Metadata extends object = object,
|
||||
> extends DiagramCanvasRunOptions<Style, Metadata> {
|
||||
trimOutputEnd?: boolean
|
||||
}
|
||||
|
||||
export function renderDiagramGridAnsi<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
styleAnsi: (run: DiagramCanvasRun<Style, Metadata>) => string | undefined,
|
||||
options: RenderDiagramGridAnsiOptions<Style, Metadata> = {},
|
||||
): string {
|
||||
const { trimOutputEnd, ...runOptions } = options
|
||||
const output: string[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
const ansi = styleAnsi(run)
|
||||
output.push(ansi ? `${ansi}${run.text}${ANSI.reset}` : run.text)
|
||||
},
|
||||
() => {
|
||||
output.push("\n")
|
||||
},
|
||||
runOptions,
|
||||
)
|
||||
|
||||
const text = output.join("")
|
||||
return trimOutputEnd ? text.trimEnd() : text
|
||||
}
|
||||
|
||||
export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
|
||||
bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
|
||||
options?: DiagramCanvasRunOptions<Style, Metadata>,
|
||||
): StyledText {
|
||||
const chunks: TextChunk[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
chunks.push({ __isChunk: true, text: run.text, fg: fg(run), bg: bg?.(run) })
|
||||
},
|
||||
() => {
|
||||
chunks.push({ __isChunk: true, text: "\n" })
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function splitDiagramLines(value: string): string[] {
|
||||
return value.split(/<br\s*\/?>/i).map((line) => line.trim())
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "./text.js"
|
||||
|
||||
describe("diagram text helpers", () => {
|
||||
test("splits Mermaid-style line breaks", () => {
|
||||
expect(splitDiagramLines("one<br/> two <br>three")).toEqual(["one", "two", "three"])
|
||||
})
|
||||
|
||||
test("measures padded text boxes", () => {
|
||||
expect(measureDiagramTextBox("wide<br/>x", { paddingX: 2, paddingY: 1 })).toEqual({
|
||||
width: 8,
|
||||
height: 4,
|
||||
lines: ["wide", "x"],
|
||||
})
|
||||
})
|
||||
|
||||
test("measures terminal cell width", () => {
|
||||
expect(diagramTextWidth("abc")).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import stringWidth from "string-width"
|
||||
import { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export interface DiagramTextBoxSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export function diagramTextWidth(value: string): number {
|
||||
return stringWidth(value)
|
||||
}
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function* diagramTextGraphemes(value: string): Generator<string> {
|
||||
for (const { segment } of graphemeSegmenter.segment(value)) yield segment
|
||||
}
|
||||
|
||||
export function measureDiagramTextBox(
|
||||
value: string,
|
||||
options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
|
||||
): DiagramTextBoxSize {
|
||||
const lines = splitDiagramLines(value)
|
||||
const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
|
||||
return {
|
||||
width: innerWidth + (options.paddingX ?? 0) * 2,
|
||||
height: lines.length + (options.paddingY ?? 0) * 2,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { isMermaidStateDiagram } from "./state/parser.js"
|
||||
|
||||
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
|
||||
if (isMermaidFlowchartDiagram(content)) return "flowchart"
|
||||
if (isMermaidSequenceDiagram(content)) return "sequence"
|
||||
if (isMermaidStateDiagram(content)) return "state"
|
||||
return undefined
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that merman does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
readonly _tag = "MermaidSyntaxError"
|
||||
|
||||
constructor(
|
||||
readonly kind: MermaidDiagramKind,
|
||||
readonly lineNumber: number,
|
||||
readonly sourceLine: string,
|
||||
reason = "Unsupported syntax",
|
||||
) {
|
||||
super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
|
||||
this.name = "MermaidSyntaxError"
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { orthogonalPathPoints, walkOrthogonalSegment } from "../core/geometry.js"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { diagramRadialCellColorLevel } from "../core/color/map.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
DIAGRAM_ARROW_HEADS,
|
||||
diagramArrowHeadBetween,
|
||||
diagramDiamondCharactersFromBorder,
|
||||
diagramLineGlyph,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "../core/drawing.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
|
||||
import {
|
||||
DATABASE_EDGE_FADE_STYLES,
|
||||
flowchartNodeColorKey,
|
||||
NODE_EDGE_FADE_STYLES,
|
||||
type FlowchartCellMetadata,
|
||||
type FlowchartCellStyle,
|
||||
type FlowchartEdgeFadeStyle,
|
||||
type FlowchartGrid,
|
||||
} from "./style.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartActiveEdgeSelection,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
function mergeFlowchartCell(
|
||||
existing: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
|
||||
incoming: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
|
||||
): DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata> {
|
||||
if (incoming.style !== "edge" && incoming.style !== "activeEdge") return incoming
|
||||
if (existing.style === "label") return incoming.style === "activeEdge" ? incoming : existing
|
||||
if (incoming.char === " ") return existing
|
||||
if ((existing.style !== "edge" && existing.style !== "activeEdge") || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
char: mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char,
|
||||
} as DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>
|
||||
}
|
||||
|
||||
function nodeMetadataForCell(
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
border = false,
|
||||
): FlowchartCellMetadata {
|
||||
const key = flowchartNodeColorKey(nodeId, diagramRadialCellColorLevel(bounds, x, y, border))
|
||||
return { nodeId: key, bgNodeId: key }
|
||||
}
|
||||
|
||||
function setNodeText(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, (cellX, cellY) => nodeMetadataForCell(bounds, nodeId, cellX, cellY))
|
||||
}
|
||||
|
||||
function fillNodeInterior(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, " ", style, nodeMetadataForCell(bounds, nodeId, x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawNode(
|
||||
grid: FlowchartGrid,
|
||||
node: FlowchartNode,
|
||||
bounds: FlowchartNodeBounds,
|
||||
borderStyle: BorderStyle,
|
||||
active: boolean,
|
||||
): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
const style: FlowchartCellStyle = active ? "activeNode" : node.shape === "database" ? "database" : "node"
|
||||
|
||||
if (node.shape === "decision") {
|
||||
drawDiagramDiamond(
|
||||
bounds,
|
||||
(x, y, char) => grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
|
||||
diagramDiamondCharactersFromBorder(chars),
|
||||
)
|
||||
} else if (node.shape === "subroutine") {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawSubroutineNode(grid, bounds, chars, style, node.id)
|
||||
} else if (node.shape === "database") {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawDatabaseNode(grid, bounds, chars, style, node.id)
|
||||
} else {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
|
||||
)
|
||||
}
|
||||
|
||||
const textTop =
|
||||
node.shape === "decision"
|
||||
? bounds.top + Math.floor((bounds.height - bounds.lines.length) / 2)
|
||||
: node.shape === "database"
|
||||
? bounds.top + 2
|
||||
: bounds.top + 1
|
||||
for (const [index, line] of bounds.lines.entries()) {
|
||||
const lineX =
|
||||
node.shape === "subroutine"
|
||||
? bounds.left + 3
|
||||
: bounds.left + Math.max(1, Math.floor((bounds.width - visualLength(line)) / 2))
|
||||
setNodeText(grid, bounds, node.id, lineX, textTop + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubroutineNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
nodeId: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
|
||||
)
|
||||
const leftRailX = bounds.left + 2
|
||||
const rightRailX = bounds.left + bounds.width - 3
|
||||
grid.setCell(
|
||||
leftRailX,
|
||||
bounds.top,
|
||||
chars.topT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top, true),
|
||||
)
|
||||
grid.setCell(
|
||||
rightRailX,
|
||||
bounds.top,
|
||||
chars.topT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top, true),
|
||||
)
|
||||
grid.setCell(
|
||||
leftRailX,
|
||||
bounds.top + bounds.height - 1,
|
||||
chars.bottomT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top + bounds.height - 1, true),
|
||||
)
|
||||
grid.setCell(
|
||||
rightRailX,
|
||||
bounds.top + bounds.height - 1,
|
||||
chars.bottomT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top + bounds.height - 1, true),
|
||||
)
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
grid.setCell(leftRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, leftRailX, y, true))
|
||||
grid.setCell(rightRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, rightRailX, y, true))
|
||||
}
|
||||
}
|
||||
|
||||
function drawDatabaseNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
nodeId: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
|
||||
)
|
||||
const topRailY = bounds.top + 1
|
||||
const bottomRailY = bounds.top + bounds.height - 2
|
||||
for (const y of [topRailY, bottomRailY]) {
|
||||
grid.setCell(bounds.left, y, chars.leftT, style, nodeMetadataForCell(bounds, nodeId, bounds.left, y, true))
|
||||
grid.setCell(
|
||||
bounds.left + bounds.width - 1,
|
||||
y,
|
||||
chars.rightT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, bounds.left + bounds.width - 1, y, true),
|
||||
)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, chars.horizontal, style, nodeMetadataForCell(bounds, nodeId, x, y, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubgraphFrame(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds, borderStyle: BorderStyle): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, "group"))
|
||||
}
|
||||
|
||||
function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds): void {
|
||||
if (bounds.label) {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const labelY = bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length
|
||||
for (const [index, line] of lines.entries()) {
|
||||
grid.setText(bounds.left + 2, labelY + index, ` ${line} `, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
for (const [index, line] of label.lines.entries()) {
|
||||
grid.setText(label.point.x, label.point.y + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute, active = false): void {
|
||||
const { edge, points } = route
|
||||
if (points.length < 2) return
|
||||
const style: FlowchartCellStyle = active ? "activeEdge" : "edge"
|
||||
|
||||
drawOrthogonalPath(points, (x, y, char) => grid.setCell(x, y, char, style), {
|
||||
cornerStyle: "rounded",
|
||||
lineStyle: edge.style === "thick" ? "heavy" : edge.style === "dashed" ? "dashed" : "single",
|
||||
})
|
||||
const end = points[points.length - 1]!
|
||||
const arrowFrom = points[points.length - 2]!
|
||||
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
|
||||
if (edge.label) {
|
||||
drawEdgeLabel(grid, route, active ? "activeEdge" : "label")
|
||||
}
|
||||
}
|
||||
|
||||
function activeEdgeMatches(
|
||||
route: FlowchartEdgeRoute,
|
||||
edgeIndex: number,
|
||||
activeEdge: FlowchartActiveEdgeSelection,
|
||||
): boolean {
|
||||
return (
|
||||
route.edge.from === activeEdge.from &&
|
||||
route.edge.to === activeEdge.to &&
|
||||
(activeEdge.index ?? edgeIndex) === edgeIndex
|
||||
)
|
||||
}
|
||||
|
||||
function activeRoute(
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
diagram: FlowchartDiagram,
|
||||
activeEdge: FlowchartActiveEdgeSelection | undefined,
|
||||
): FlowchartEdgeRoute | undefined {
|
||||
if (!activeEdge) return undefined
|
||||
const edgeIndexes = new Map(diagram.edges.map((edge, index) => [edge, index]))
|
||||
for (let index = 0; index < routes.length; index++) {
|
||||
const route = routes[index]!
|
||||
if (activeEdgeMatches(route, edgeIndexes.get(route.edge) ?? index, activeEdge)) return route
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function activeRoutePoints(
|
||||
route: FlowchartEdgeRoute,
|
||||
from: FlowchartNodeBounds | undefined,
|
||||
): readonly FlowchartPoint[] {
|
||||
const sourcePoint = route.points[0]
|
||||
return from && sourcePoint ? [{ ...flowchartSourceConnector(from, sourcePoint) }, ...route.points] : route.points
|
||||
}
|
||||
|
||||
function styleActivePathCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartCellStyle): void {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (cell && cell.char !== " ") cell.style = style
|
||||
}
|
||||
|
||||
function drawActiveRoute(grid: FlowchartGrid, route: FlowchartEdgeRoute, from: FlowchartNodeBounds | undefined): void {
|
||||
for (const point of orthogonalPathPoints(activeRoutePoints(route, from))) {
|
||||
styleActivePathCell(grid, point.x, point.y, "activeEdge")
|
||||
}
|
||||
if (route.edge.label) {
|
||||
drawEdgeLabel(grid, route, "activeEdge")
|
||||
}
|
||||
}
|
||||
|
||||
function flowchartNodeStyle(node: FlowchartNode | undefined): "node" | "database" {
|
||||
return node?.shape === "database" ? "database" : "node"
|
||||
}
|
||||
|
||||
function sourceFadeStyles(sourceStyle: "node" | "database"): readonly FlowchartEdgeFadeStyle[] {
|
||||
return sourceStyle === "database" ? DATABASE_EDGE_FADE_STYLES : NODE_EDGE_FADE_STYLES
|
||||
}
|
||||
|
||||
function styleExistingEdgeCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartEdgeFadeStyle): boolean {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (!cell || cell.char === " " || cell.style === "label" || DIAGRAM_ARROW_HEADS.has(cell.char)) return false
|
||||
grid.setCell(x, y, cell.char, style)
|
||||
return true
|
||||
}
|
||||
|
||||
function fadeSourcePath(
|
||||
grid: FlowchartGrid,
|
||||
points: FlowchartPoint[],
|
||||
styles: readonly FlowchartEdgeFadeStyle[],
|
||||
): void {
|
||||
let styleIndex = 1
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (let index = 1; index < points.length && styleIndex < styles.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = flowchartDirectionBetween(from, to)
|
||||
if (!direction) continue
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (styleIndex >= styles.length) return false
|
||||
const key = `${point.x}:${point.y}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
if (styleExistingEdgeCell(grid, point.x, point.y, styles[styleIndex]!)) styleIndex += 1
|
||||
}
|
||||
return styleIndex < styles.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function drawSourceConnectors(
|
||||
grid: FlowchartGrid,
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const nodesById = new Map(diagram.nodes.map((node) => [node.id, node]))
|
||||
|
||||
for (const route of routes) {
|
||||
const from = bounds.get(route.edge.from)
|
||||
const sourcePoint = route.points[0]
|
||||
if (!from || !sourcePoint) continue
|
||||
const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from)))
|
||||
const connector = flowchartSourceConnector(from, sourcePoint)
|
||||
grid.setCell(connector.x, connector.y, connector.char, styles[0])
|
||||
const routeDirection = route.points[1] ? flowchartDirectionBetween(sourcePoint, route.points[1]!) : undefined
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell) {
|
||||
cell.char = diagramLineGlyph(
|
||||
new Set([routeDirection, connectorDirection]),
|
||||
"rounded",
|
||||
route.edge.style === "thick" ? "heavy" : "single",
|
||||
)
|
||||
cell.style = "edge"
|
||||
}
|
||||
}
|
||||
fadeSourcePath(grid, route.points, styles)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFlowchartDiagramGrid(
|
||||
diagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartGrid {
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const { bounds, routes, subgraphBounds, width, height } = layout
|
||||
diagram = layout.diagram
|
||||
const grid = new DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>(width, height, {
|
||||
mergeCell: mergeFlowchartCell,
|
||||
})
|
||||
const selectedRoute = activeRoute(routes, diagram, options.activeEdge)
|
||||
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphFrame(grid, bound, borderStyle)
|
||||
}
|
||||
for (const route of routes) drawRoutedEdge(grid, route)
|
||||
for (const node of diagram.nodes) {
|
||||
const bound = bounds.get(node.id)
|
||||
if (bound) drawNode(grid, node, bound, borderStyle, node.id === options.activeNode)
|
||||
}
|
||||
drawSourceConnectors(grid, diagram, bounds, routes)
|
||||
if (selectedRoute) {
|
||||
const from = bounds.get(selectedRoute.edge.from)
|
||||
drawActiveRoute(grid, selectedRoute, from)
|
||||
}
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphLabel(grid, bound)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
import { renderFlowchartDiagram, renderFlowchartDiagramAnsi } from "./render.js"
|
||||
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
|
||||
|
||||
export type {
|
||||
FlowchartDiagram as Diagram,
|
||||
FlowchartActiveEdgeSelection as ActiveEdgeSelection,
|
||||
FlowchartDirection as Direction,
|
||||
FlowchartEdge as Edge,
|
||||
FlowchartEdgeDirection as EdgeDirection,
|
||||
FlowchartEdgeRoute as EdgeRoute,
|
||||
FlowchartNode as Node,
|
||||
FlowchartNodeBounds as NodeBounds,
|
||||
FlowchartNodeShape as NodeShape,
|
||||
FlowchartPoint as Point,
|
||||
FlowchartSubgraph as Subgraph,
|
||||
FlowchartSubgraphBounds as SubgraphBounds,
|
||||
} from "./types.js"
|
||||
export type {
|
||||
FlowchartDiagramAnsiOptions as AnsiRenderOptions,
|
||||
FlowchartDiagramOptions as RenderableOptions,
|
||||
FlowchartDiagramRenderOptions as PlainRenderOptions,
|
||||
} from "./options.js"
|
||||
export type { FlowchartDiagramAnsiTheme as Theme, FlowchartNodeColors as NodeColors } from "./style.js"
|
||||
export { flowchartNodeColorKey as nodeColorKey } from "./style.js"
|
||||
export { isMermaidFlowchartDiagram as is, parseMermaidFlowchartDiagram as parse } from "./parser.js"
|
||||
export { FlowchartDiagramRenderable as Renderable } from "./renderable.js"
|
||||
|
||||
export interface RenderOptions extends FlowchartDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid flowchart string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color
|
||||
? renderFlowchartDiagramAnsi(content, rest)
|
||||
: renderFlowchartDiagram(content, rest as FlowchartDiagramRenderOptions)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
|
||||
const measure = (text: string): number => text.length
|
||||
|
||||
describe("flowchart edge labels", () => {
|
||||
test("places vertical-route labels beside the bus", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 23, y: 5 })
|
||||
})
|
||||
|
||||
test("places labels inline only when padded text fits with clearance", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 13, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 2 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 9, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
})
|
||||
|
||||
test("uses vertical bus labels before short terminal branches", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 155, y: 5 },
|
||||
{ x: 150, y: 5 },
|
||||
{ x: 150, y: 9 },
|
||||
{ x: 146, y: 9 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 151, y: 7 })
|
||||
})
|
||||
|
||||
test("measures br-delimited edge label lines as a block", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 20, y: 2 },
|
||||
],
|
||||
"first<br/>second line",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.lines).toEqual([" first ", " second line "])
|
||||
expect(layout.width).toBe(13)
|
||||
expect(layout.height).toBe(2)
|
||||
})
|
||||
|
||||
test("places multiline horizontal edge labels outside the route row", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 20, y: 5 },
|
||||
],
|
||||
"first<br/>second",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point.y + layout.height).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
test("centers multiline vertical edge labels beside their route", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 2 },
|
||||
{ x: 22, y: 10 },
|
||||
],
|
||||
"one<br/>two<br/>three",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point).toEqual({ x: 23, y: 5 })
|
||||
expect(layout.height).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
clampPoint,
|
||||
centeredSpanStart,
|
||||
insetSpan,
|
||||
midpoint,
|
||||
point,
|
||||
pointOnSegment,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
shiftPoint,
|
||||
spanCapacity,
|
||||
type DiagramSegment,
|
||||
} from "../core/geometry.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import type { FlowchartPoint } from "./types.js"
|
||||
|
||||
const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
const LABEL_LINE_CLEARANCE = 2
|
||||
const LABEL_PADDING = 1
|
||||
|
||||
export interface FlowchartEdgeLabelLayout {
|
||||
lines: string[]
|
||||
point: FlowchartPoint
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function flowchartLabelText(label: string): string {
|
||||
return `${" ".repeat(LABEL_PADDING)}${label}${" ".repeat(LABEL_PADDING)}`
|
||||
}
|
||||
|
||||
export function flowchartLabelWidth(label: string, measure: (text: string) => number): number {
|
||||
return Math.max(...splitDiagramLines(label).map((line) => measure(line) + LABEL_PADDING * 2))
|
||||
}
|
||||
|
||||
function minimumInlineLabelLength(labelWidth: number): number {
|
||||
return labelWidth + LABEL_LINE_CLEARANCE * 2 - 1
|
||||
}
|
||||
|
||||
export function flowchartHorizontalLabelRankGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + 1
|
||||
}
|
||||
|
||||
export function flowchartVerticalBranchLabelGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + LABEL_NODE_CLEARANCE
|
||||
}
|
||||
|
||||
function inlineLabelSlot(segment: DiagramSegment, labelWidth: number): { x: number; fits: boolean } {
|
||||
const slot = insetSpan(segmentSpan(segment), LABEL_LINE_CLEARANCE)
|
||||
return { x: centeredSpanStart(slot, labelWidth), fits: spanCapacity(slot) >= labelWidth }
|
||||
}
|
||||
|
||||
function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHeight: number): FlowchartPoint {
|
||||
if (segment.axis === "x") {
|
||||
const slot = inlineLabelSlot(segment, labelWidth)
|
||||
if (labelHeight === 1 && slot.fits) return point(slot.x, segment.from.y)
|
||||
|
||||
if (labelHeight > 1) {
|
||||
return shiftPoint(point(slot.x, segment.from.y), "up", labelHeight)
|
||||
}
|
||||
|
||||
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
|
||||
}
|
||||
|
||||
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
|
||||
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
|
||||
}
|
||||
|
||||
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
|
||||
let roomyHorizontal: DiagramSegment | undefined
|
||||
let verticalBus: DiagramSegment | undefined
|
||||
let longest: DiagramSegment | undefined
|
||||
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = segmentBetween(points[index - 1]!, points[index]!)
|
||||
if (!segment) continue
|
||||
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
|
||||
if (!verticalBus && segment.axis === "y") verticalBus = segment
|
||||
if (!longest || segment.length > longest.length) longest = segment
|
||||
}
|
||||
|
||||
return roomyHorizontal ?? verticalBus ?? longest
|
||||
}
|
||||
|
||||
function flowchartLabelPoint(
|
||||
points: readonly FlowchartPoint[],
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
): FlowchartPoint {
|
||||
const segment = bestLabelSegment(points, labelWidth)
|
||||
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
|
||||
}
|
||||
|
||||
export function flowchartEdgeLabelLayout(
|
||||
points: readonly FlowchartPoint[],
|
||||
label: string,
|
||||
measure: (text: string) => number,
|
||||
): FlowchartEdgeLabelLayout {
|
||||
const lines = splitDiagramLines(label).map(flowchartLabelText)
|
||||
const width = flowchartLabelWidth(label, measure)
|
||||
const height = lines.length
|
||||
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
translateDiagramBounds,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
flowchartEdgeLabelLayout,
|
||||
flowchartHorizontalLabelRankGap,
|
||||
flowchartLabelWidth,
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartNodeSize,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_MIN_NODE_GAP = 5
|
||||
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
|
||||
export const DEFAULT_MIN_RANK_GAP = 10
|
||||
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
|
||||
export const COMPACT_MIN_RANK_GAP = 4
|
||||
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
|
||||
const SUBGRAPH_PADDING_X = 2
|
||||
const SUBGRAPH_PADDING_TOP = 1
|
||||
const SUBGRAPH_PADDING_BOTTOM = 1
|
||||
|
||||
export interface FlowchartLayout {
|
||||
diagram: FlowchartDiagram
|
||||
bounds: Map<string, FlowchartNodeBounds>
|
||||
routes: FlowchartEdgeRoute[]
|
||||
subgraphBounds: Map<string, FlowchartSubgraphBounds>
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
|
||||
|
||||
function horizontalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelGap)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function verticalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelHeight = splitDiagramLines(edge.label).length
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function isHorizontalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "LR" || direction === "RL"
|
||||
}
|
||||
|
||||
export function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback
|
||||
return Math.max(1, Math.trunc(value))
|
||||
}
|
||||
|
||||
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
|
||||
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
|
||||
const innerWidth = width - 4
|
||||
if (node.shape === "decision") {
|
||||
const width = innerWidth + 6
|
||||
return {
|
||||
width: width % 2 === 0 ? width + 1 : width,
|
||||
height: Math.max(5, lines.length + 4),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
|
||||
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
|
||||
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
||||
}
|
||||
|
||||
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
||||
const ranks = new Map<string, number>()
|
||||
const outgoing = new Map<string, string[]>()
|
||||
const incoming = new Set<string>()
|
||||
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
const list = outgoing.get(edge.from) ?? []
|
||||
list.push(edge.to)
|
||||
outgoing.set(edge.from, list)
|
||||
incoming.add(edge.to)
|
||||
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
|
||||
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
|
||||
|
||||
const queue = starts.map((node) => node.id)
|
||||
for (const node of starts) ranks.set(node.id, 0)
|
||||
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index]!
|
||||
const rank = ranks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
const nextRank = rank + 1
|
||||
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
||||
ranks.set(to, nextRank)
|
||||
queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
|
||||
}
|
||||
|
||||
const acyclicRanks = new Map<string, number>()
|
||||
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
|
||||
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
|
||||
for (let index = 0; index < acyclicQueue.length; index++) {
|
||||
const id = acyclicQueue[index]!
|
||||
const rank = acyclicRanks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
|
||||
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
|
||||
incomingCounts.set(to, remainingIncoming)
|
||||
if (remainingIncoming === 0) acyclicQueue.push(to)
|
||||
}
|
||||
}
|
||||
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
|
||||
|
||||
return ranks
|
||||
}
|
||||
|
||||
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
|
||||
translateDiagramBounds(bounds, dx, dy)
|
||||
}
|
||||
|
||||
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
|
||||
for (const route of routes) {
|
||||
for (const point of route.points) {
|
||||
point.x += dx
|
||||
point.y += dy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromBounds(children)
|
||||
}
|
||||
|
||||
function subgraphBoundFromChildren(
|
||||
id: string,
|
||||
label: string,
|
||||
children: readonly FlowchartBounds[],
|
||||
): FlowchartSubgraphBounds {
|
||||
const labelLines = splitDiagramLines(label)
|
||||
const labelHeight = labelLines.length
|
||||
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
|
||||
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
|
||||
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
|
||||
const bottom =
|
||||
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
|
||||
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
|
||||
|
||||
if (right - left < minWidth) {
|
||||
const extra = minWidth - (right - left)
|
||||
left -= Math.floor(extra / 2)
|
||||
right += Math.ceil(extra / 2)
|
||||
}
|
||||
|
||||
const width = right - left
|
||||
const height = Math.max(3, bottom - top)
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
labelSide: "top",
|
||||
}
|
||||
}
|
||||
|
||||
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
|
||||
return leftStart <= rightEnd && rightStart <= leftEnd
|
||||
}
|
||||
|
||||
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const left = bounds.left + 2
|
||||
const height = lines.length
|
||||
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
|
||||
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
|
||||
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
|
||||
}
|
||||
|
||||
function segmentOverlapsSlot(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
slot: FlowchartBounds,
|
||||
): boolean {
|
||||
const segment = segmentBetween(from, to)
|
||||
if (!segment) return false
|
||||
|
||||
const slotRight = slot.left + slot.width - 1
|
||||
const slotBottom = slot.top + slot.height - 1
|
||||
const span = segmentSpan(segment)
|
||||
if (segment.axis === "x") {
|
||||
return (
|
||||
segment.from.y >= slot.top &&
|
||||
segment.from.y <= slotBottom &&
|
||||
spansOverlap(span.start, span.end, slot.left, slotRight)
|
||||
)
|
||||
}
|
||||
return (
|
||||
segment.from.x >= slot.left &&
|
||||
segment.from.x <= slotRight &&
|
||||
spansOverlap(span.start, span.end, slot.top, slotBottom)
|
||||
)
|
||||
}
|
||||
|
||||
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
|
||||
}
|
||||
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (
|
||||
!routeLabelBounds ||
|
||||
!spansOverlap(
|
||||
routeLabelBounds.top,
|
||||
routeLabelBounds.top + routeLabelBounds.height - 1,
|
||||
slot.top,
|
||||
slot.top + slot.height - 1,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return spansOverlap(
|
||||
routeLabelBounds.left,
|
||||
routeLabelBounds.left + routeLabelBounds.width - 1,
|
||||
slot.left,
|
||||
slot.left + slot.width - 1,
|
||||
)
|
||||
}
|
||||
|
||||
function chooseSubgraphLabelSide(
|
||||
bounds: FlowchartSubgraphBounds,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): FlowchartSubgraphBounds["labelSide"] {
|
||||
const topSlot = labelSlot(bounds, "top")
|
||||
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
|
||||
|
||||
const bottomSlot = labelSlot(bounds, "bottom")
|
||||
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
|
||||
}
|
||||
|
||||
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromPoints(points)
|
||||
}
|
||||
|
||||
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
|
||||
if (!route.edge.label) return undefined
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
const { point, width, height } = label
|
||||
return {
|
||||
left: point.x,
|
||||
top: point.y,
|
||||
width,
|
||||
height,
|
||||
centerX: point.x + Math.floor(width / 2),
|
||||
centerY: point.y + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
return routeRenderBounds(
|
||||
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
|
||||
)
|
||||
}
|
||||
|
||||
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
const bounds: FlowchartBounds[] = []
|
||||
for (const route of routes) {
|
||||
const routeBounds = pathBounds(route.points)
|
||||
if (routeBounds) bounds.push(routeBounds)
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (routeLabelBounds) bounds.push(routeLabelBounds)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutRankedNodes(
|
||||
diagram: FlowchartDiagram,
|
||||
direction: FlowchartDirection,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): Map<string, FlowchartNodeBounds> {
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
let widestPaddedEdgeLabel = 0
|
||||
for (const edge of diagram.edges) {
|
||||
if (edge.label)
|
||||
widestPaddedEdgeLabel = Math.max(widestPaddedEdgeLabel, flowchartLabelWidth(edge.label, visualLength))
|
||||
}
|
||||
const rankNodeGap = horizontal
|
||||
? minNodeGap
|
||||
: Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
|
||||
const ranks = rankNodes(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
const ranksByIndex = new Map<number, FlowchartNode[]>()
|
||||
const normalizedRanks = new Map<string, number>()
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
const rank = ranks.get(node.id) ?? 0
|
||||
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
|
||||
normalizedRanks.set(node.id, normalizedRank)
|
||||
const nodes = ranksByIndex.get(normalizedRank) ?? []
|
||||
nodes.push(node)
|
||||
ranksByIndex.set(normalizedRank, nodes)
|
||||
}
|
||||
|
||||
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
|
||||
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
||||
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
||||
const bounds = new Map<string, FlowchartNodeBounds>()
|
||||
|
||||
if (horizontal) {
|
||||
const columnWidths = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
|
||||
)
|
||||
const columnHeights = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasHeight = Math.max(1, ...columnHeights)
|
||||
let x = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const columnWidth = columnWidths[rankIndex]!
|
||||
let y = Math.floor((canvasHeight - columnHeights[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const left = x + Math.floor((columnWidth - size.width) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left,
|
||||
top: y,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: y + Math.floor(size.height / 2),
|
||||
})
|
||||
y += size.height + rankNodeGap
|
||||
}
|
||||
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
} else {
|
||||
const rowHeights = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
|
||||
)
|
||||
const rowWidths = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rowWidths)
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const rowHeight = rowHeights[rankIndex]!
|
||||
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left: x,
|
||||
top,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + rankNodeGap
|
||||
}
|
||||
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): void {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
|
||||
const nodeIds = new Set(subgraph.nodeIds)
|
||||
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
|
||||
if (!currentBounds) continue
|
||||
|
||||
const localDiagram: FlowchartDiagram = {
|
||||
direction: subgraph.direction,
|
||||
nodes,
|
||||
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
|
||||
subgraphs: [],
|
||||
}
|
||||
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
|
||||
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
|
||||
const localExtent = boundsFromChildren([...localBounds.values()])
|
||||
if (!localExtent) continue
|
||||
|
||||
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
|
||||
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
|
||||
const dx = targetLeft - localExtent.left
|
||||
const dy = targetTop - localExtent.top
|
||||
|
||||
for (const [nodeId, bound] of localBounds) {
|
||||
translateBounds(bound, dx, dy)
|
||||
nodeBounds.set(nodeId, bound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction) continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
|
||||
}
|
||||
return diagram.direction
|
||||
}
|
||||
|
||||
function hasLocalSubgraphDirection(diagram: FlowchartDiagram): boolean {
|
||||
return (diagram.subgraphs ?? []).some((subgraph) => subgraph.direction && subgraph.direction !== diagram.direction)
|
||||
}
|
||||
|
||||
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
|
||||
const nodeIds = new Set<string>()
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
|
||||
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
|
||||
if (subgraph.parentId === subgraphId) {
|
||||
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
return nodeIds
|
||||
}
|
||||
|
||||
function separateLocalSubgraphItems(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
gap: number,
|
||||
): void {
|
||||
if (!hasLocalSubgraphDirection(diagram)) return
|
||||
|
||||
const coveredNodeIds = new Set<string>()
|
||||
const items: { bounds: FlowchartBounds; nodeIds: Set<string> }[] = []
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.parentId) continue
|
||||
const bounds = subgraphBounds.get(subgraph.id)
|
||||
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
||||
if (!bounds || nodeIds.size === 0) continue
|
||||
items.push({ bounds, nodeIds })
|
||||
for (const nodeId of nodeIds) coveredNodeIds.add(nodeId)
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (coveredNodeIds.has(node.id)) continue
|
||||
const bounds = nodeBounds.get(node.id)
|
||||
if (bounds) items.push({ bounds, nodeIds: new Set([node.id]) })
|
||||
}
|
||||
|
||||
const horizontal = isHorizontalDirection(diagram.direction)
|
||||
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
|
||||
|
||||
let cursor: number | undefined
|
||||
for (const item of items) {
|
||||
const start = horizontal ? item.bounds.left : item.bounds.top
|
||||
const size = horizontal ? item.bounds.width : item.bounds.height
|
||||
if (cursor === undefined) {
|
||||
cursor = start + size + gap
|
||||
continue
|
||||
}
|
||||
const shift = cursor - start
|
||||
if (shift !== 0) {
|
||||
for (const nodeId of item.nodeIds) {
|
||||
const bounds = nodeBounds.get(nodeId)
|
||||
if (bounds) translateBounds(bounds, horizontal ? shift : 0, horizontal ? 0 : shift)
|
||||
}
|
||||
}
|
||||
cursor = start + shift + size + gap
|
||||
}
|
||||
}
|
||||
|
||||
function layoutSubgraphs(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): Map<string, FlowchartSubgraphBounds> {
|
||||
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
|
||||
for (const subgraph of [...subgraphs].reverse()) {
|
||||
const children: FlowchartBounds[] = []
|
||||
for (const nodeId of subgraph.nodeIds) {
|
||||
const bound = nodeBounds.get(nodeId)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
|
||||
for (const childSubgraph of subgraphs) {
|
||||
if (childSubgraph.parentId !== subgraph.id) continue
|
||||
const bound = subgraphBounds.get(childSubgraph.id)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
if (children.length > 0) {
|
||||
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
|
||||
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
||||
subgraphBounds.set(subgraph.id, bound)
|
||||
}
|
||||
}
|
||||
|
||||
return subgraphBounds
|
||||
}
|
||||
|
||||
function layoutFlowchartWithDirection(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartLayout {
|
||||
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
|
||||
const requestedMinRankGap = normalizePositiveInt(
|
||||
options.minRankGap,
|
||||
options.compact
|
||||
? horizontal
|
||||
? COMPACT_MIN_RANK_GAP
|
||||
: COMPACT_MIN_VERTICAL_RANK_GAP
|
||||
: horizontal
|
||||
? DEFAULT_MIN_RANK_GAP
|
||||
: DEFAULT_MIN_VERTICAL_RANK_GAP,
|
||||
)
|
||||
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
|
||||
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
|
||||
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
|
||||
|
||||
let routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
let subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
separateLocalSubgraphItems(diagram, bounds, subgraphBounds, Math.max(1, Math.floor(requestedMinRankGap / 2)))
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
|
||||
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
|
||||
if (dx > 0 || dy > 0) {
|
||||
for (const bound of allBounds) translateBounds(bound, dx, dy)
|
||||
translateRoutes(routes, dx, dy)
|
||||
}
|
||||
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
|
||||
}
|
||||
|
||||
export function layoutFlowchartDiagram(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartLayout {
|
||||
const direction = options.direction ?? sourceDiagram.direction
|
||||
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
|
||||
const maxWidth = options.layoutMaxWidth
|
||||
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
|
||||
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
|
||||
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
|
||||
import type { FlowchartDiagramAnsiTheme, FlowchartNodeColors } from "./style.js"
|
||||
import type { FlowchartActiveEdgeSelection, FlowchartDirection } from "./types.js"
|
||||
|
||||
export interface FlowchartDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
direction?: FlowchartDirection
|
||||
borderStyle?: BorderStyle
|
||||
minNodeGap?: number
|
||||
minRankGap?: number
|
||||
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
|
||||
layoutMaxWidth?: number
|
||||
activeNode?: string
|
||||
activeEdge?: FlowchartActiveEdgeSelection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagramAnsiOptions extends FlowchartDiagramRenderOptions {
|
||||
theme?: FlowchartDiagramAnsiTheme
|
||||
}
|
||||
|
||||
export interface FlowchartDiagramOptions extends TextBufferOptions, FlowchartDiagramRenderOptions {
|
||||
content?: string
|
||||
nodeColor?: ColorInput
|
||||
nodeColors?: FlowchartNodeColors
|
||||
nodeBgColors?: FlowchartNodeColors
|
||||
databaseColor?: ColorInput
|
||||
edgeColor?: ColorInput
|
||||
activeNodeColor?: ColorInput
|
||||
activeEdgeColor?: ColorInput
|
||||
labelColor?: ColorInput
|
||||
groupColor?: ColorInput
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeStyle,
|
||||
FlowchartNode,
|
||||
FlowchartSubgraph,
|
||||
} from "./types.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
|
||||
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
|
||||
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
|
||||
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
|
||||
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
|
||||
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
|
||||
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
|
||||
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
|
||||
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
|
||||
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
|
||||
const ROUNDED_NODE_RE = new RegExp(`^(${ID_RE})\\((.+)\\)$`)
|
||||
const DECISION_NODE_RE = new RegExp(`^(${ID_RE})\\{(.+)\\}$`)
|
||||
const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
|
||||
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
|
||||
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
|
||||
|
||||
function normalizeDirection(value?: string): FlowchartDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
function normalizeSubgraphId(value: string, index: number): string {
|
||||
const stripped = stripQuotes(value)
|
||||
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
|
||||
}
|
||||
|
||||
function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgraph, "id" | "label"> {
|
||||
const trimmed = token
|
||||
.trim()
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
|
||||
if (withLabel) {
|
||||
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
|
||||
}
|
||||
|
||||
const label = stripQuotes(trimmed)
|
||||
return { id: normalizeSubgraphId(trimmed, index), label }
|
||||
}
|
||||
|
||||
function parseNodeToken(token: string): FlowchartNode {
|
||||
const trimmed = token.trim().replace(/;$/, "")
|
||||
const database = trimmed.match(DATABASE_NODE_RE)
|
||||
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
|
||||
|
||||
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
|
||||
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
|
||||
|
||||
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
|
||||
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
|
||||
|
||||
const rounded = trimmed.match(ROUNDED_NODE_RE)
|
||||
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
|
||||
|
||||
const decision = trimmed.match(DECISION_NODE_RE)
|
||||
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
|
||||
|
||||
const box = trimmed.match(BOX_NODE_RE)
|
||||
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
|
||||
|
||||
return { id: trimmed, label: trimmed, shape: "box" }
|
||||
}
|
||||
|
||||
function hasExplicitNodeShape(token: string): boolean {
|
||||
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
|
||||
}
|
||||
|
||||
function ensureNode(nodes: Map<string, FlowchartNode>, token: string): FlowchartNode {
|
||||
const node = parseNodeToken(token)
|
||||
const existing = nodes.get(node.id)
|
||||
if (!existing) {
|
||||
nodes.set(node.id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(token)) {
|
||||
existing.label = node.label
|
||||
existing.shape = node.shape
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
function addNodeToSubgraph(subgraph: FlowchartSubgraph | undefined, nodeId: string): void {
|
||||
if (!subgraph || subgraph.nodeIds.includes(nodeId)) return
|
||||
subgraph.nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
function stripNodeToken(token: string): string {
|
||||
return token
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
|
||||
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
|
||||
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createEdge(from: string, to: string, label: string, style: FlowchartEdgeStyle | undefined): FlowchartEdge {
|
||||
return style ? { from, to, label, style } : { from, to, label }
|
||||
}
|
||||
|
||||
export function isMermaidFlowchartDiagram(content: string): boolean {
|
||||
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
|
||||
const nodes = new Map<string, FlowchartNode>()
|
||||
const edges: FlowchartEdge[] = []
|
||||
const subgraphs: FlowchartSubgraph[] = []
|
||||
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
|
||||
let direction: FlowchartDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
const header = line.match(FLOWCHART_HEADER_RE)
|
||||
if (header) {
|
||||
direction = normalizeDirection(header[2])
|
||||
continue
|
||||
}
|
||||
|
||||
// Mermaid CSS styling does not apply to terminal theme rendering.
|
||||
if (IGNORED_PRESENTATION_RE.test(line)) continue
|
||||
|
||||
const subgraphMatch = line.match(SUBGRAPH_RE)
|
||||
if (subgraphMatch) {
|
||||
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
|
||||
const subgraph: FlowchartSubgraph = {
|
||||
...parsed,
|
||||
nodeIds: [],
|
||||
parentId: subgraphStack[subgraphStack.length - 1]?.subgraph.id,
|
||||
}
|
||||
subgraphs.push(subgraph)
|
||||
subgraphStack.push({ subgraph, lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^end$/i.test(line)) {
|
||||
if (subgraphStack.length === 0) {
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, 'Unexpected "end" without an open subgraph')
|
||||
}
|
||||
subgraphStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const currentSubgraph = subgraphStack[subgraphStack.length - 1]?.subgraph
|
||||
|
||||
const subgraphDirection = line.match(SUBGRAPH_DIRECTION_RE)
|
||||
if (subgraphDirection) {
|
||||
if (!currentSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'A "direction" statement requires an open subgraph',
|
||||
)
|
||||
}
|
||||
currentSubgraph.direction = normalizeDirection(subgraphDirection[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeEdge = line.match(/^(.+?)\s*(-->|==>|-\.->)\s*(?:\|([^|]*)\|\s*)?(.+)$/)
|
||||
const textEdge = line.match(/^(.+?)\s*(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)\s*(.+)$/)
|
||||
const edgeMatch = textEdge ?? pipeEdge
|
||||
if (edgeMatch) {
|
||||
const from = ensureNode(nodes, stripNodeToken(edgeMatch[1]!))
|
||||
const toToken = textEdge ? edgeMatch[5]! : edgeMatch[4]!
|
||||
const to = ensureNode(nodes, stripNodeToken(toToken))
|
||||
addNodeToSubgraph(currentSubgraph, from.id)
|
||||
addNodeToSubgraph(currentSubgraph, to.id)
|
||||
const arrow = textEdge ? edgeMatch[4]! : edgeMatch[2]!
|
||||
const label = textEdge ? edgeMatch[3]! : (edgeMatch[3] ?? "")
|
||||
edges.push(createEdge(from.id, to.id, label.trim(), edgeStyleFromArrow(textEdge ? edgeMatch[2]! : arrow, arrow)))
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(line)) {
|
||||
const node = ensureNode(nodes, line)
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedSubgraph = subgraphStack[subgraphStack.length - 1]
|
||||
if (unclosedSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
unclosedSubgraph.lineNumber,
|
||||
unclosedSubgraph.sourceLine,
|
||||
'Unclosed subgraph; expected "end"',
|
||||
)
|
||||
}
|
||||
|
||||
return { direction, nodes: [...nodes.values()], edges, subgraphs }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import { renderGridAnsi } from "./style.js"
|
||||
|
||||
export function renderFlowchartDiagram(content: string, options: FlowchartDiagramRenderOptions = {}): string {
|
||||
return drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options).toString({
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function renderFlowchartDiagramAnsi(content: string, options: FlowchartDiagramAnsiOptions = {}): string {
|
||||
return renderGridAnsi(drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { RGBA, type BorderStyle, type ColorInput, type RenderContext } from "@opentui/core"
|
||||
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
|
||||
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
|
||||
import { diagramColorMapsEqual, normalizeDiagramColorMap } from "../core/color/map.js"
|
||||
import { DEFAULT_BORDER_STYLE, drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramRenderOptions, FlowchartDiagramOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import {
|
||||
renderGridStyledText,
|
||||
resolveFlowchartStyleColors,
|
||||
type FlowchartGrid,
|
||||
type FlowchartNodeColors,
|
||||
} from "./style.js"
|
||||
import type { FlowchartActiveEdgeSelection, FlowchartDiagram, FlowchartDirection, FlowchartEdge } from "./types.js"
|
||||
|
||||
interface IndexedFlowchartEdge {
|
||||
edge: FlowchartEdge
|
||||
index: number
|
||||
}
|
||||
|
||||
function flowchartActiveEdgesEqual(
|
||||
left: FlowchartActiveEdgeSelection | undefined,
|
||||
right: FlowchartActiveEdgeSelection | undefined,
|
||||
): boolean {
|
||||
return left?.from === right?.from && left?.to === right?.to && left?.index === right?.index
|
||||
}
|
||||
|
||||
export class FlowchartDiagramRenderable extends DiagramRenderable<FlowchartDiagram, FlowchartGrid> {
|
||||
private _compact: boolean
|
||||
private _direction?: FlowchartDirection
|
||||
private _borderStyle: BorderStyle
|
||||
private _minNodeGap?: number
|
||||
private _minRankGap?: number
|
||||
private _layoutMaxWidth?: number
|
||||
private _nodeColor?: RGBA
|
||||
private _databaseColor?: RGBA
|
||||
private _edgeColor?: RGBA
|
||||
private _activeNodeColor?: RGBA
|
||||
private _activeEdgeColor?: RGBA
|
||||
private _nodeColors: Map<string, RGBA>
|
||||
private _nodeBgColors: Map<string, RGBA>
|
||||
private _labelColor?: RGBA
|
||||
private _groupColor?: RGBA
|
||||
private _activeNode?: string
|
||||
private _activeEdge?: FlowchartActiveEdgeSelection
|
||||
private _selectedConnectionIndex = 0
|
||||
constructor(ctx: RenderContext, options: FlowchartDiagramOptions = {}) {
|
||||
super(ctx, options)
|
||||
this._compact = options.compact ?? false
|
||||
this._direction = options.direction
|
||||
this._borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
this._minNodeGap = options.minNodeGap
|
||||
this._minRankGap = options.minRankGap
|
||||
this._layoutMaxWidth = options.layoutMaxWidth
|
||||
this._nodeColor = parseDiagramRenderableColor(options.nodeColor)
|
||||
this._databaseColor = parseDiagramRenderableColor(options.databaseColor)
|
||||
this._edgeColor = parseDiagramRenderableColor(options.edgeColor)
|
||||
this._activeNodeColor = parseDiagramRenderableColor(options.activeNodeColor)
|
||||
this._activeEdgeColor = parseDiagramRenderableColor(options.activeEdgeColor)
|
||||
this._nodeColors = normalizeDiagramColorMap(options.nodeColors)
|
||||
this._nodeBgColors = normalizeDiagramColorMap(options.nodeBgColors)
|
||||
this._labelColor = parseDiagramRenderableColor(options.labelColor)
|
||||
this._groupColor = parseDiagramRenderableColor(options.groupColor)
|
||||
this._activeNode = options.activeNode
|
||||
this._activeEdge = options.activeEdge
|
||||
this.initializeDiagram({
|
||||
parse: () => parseMermaidFlowchartDiagram(this.content),
|
||||
draw: (diagram) => drawFlowchartDiagramGrid(diagram, this.renderOptions()),
|
||||
publish: (grid) => this.styledText(grid),
|
||||
measure: { trimTop: true, trimBottom: true },
|
||||
})
|
||||
}
|
||||
|
||||
protected override contentChanged(): void {
|
||||
this._activeNode = undefined
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
}
|
||||
|
||||
get compact(): boolean {
|
||||
return this._compact
|
||||
}
|
||||
|
||||
set compact(value: boolean) {
|
||||
if (this._compact === value) return
|
||||
this._compact = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set direction(value: FlowchartDirection | undefined) {
|
||||
if (this._direction === value) return
|
||||
this._direction = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set borderStyle(value: BorderStyle | undefined) {
|
||||
const next = value ?? DEFAULT_BORDER_STYLE
|
||||
if (this._borderStyle === next) return
|
||||
this._borderStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get layoutMaxWidth(): number | undefined {
|
||||
return this._layoutMaxWidth
|
||||
}
|
||||
|
||||
set layoutMaxWidth(value: number | undefined) {
|
||||
if (this._layoutMaxWidth === value) return
|
||||
this._layoutMaxWidth = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set nodeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._nodeColor, value, (color) => (this._nodeColor = color))
|
||||
}
|
||||
|
||||
set databaseColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._databaseColor, value, (color) => (this._databaseColor = color))
|
||||
}
|
||||
|
||||
set edgeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._edgeColor, value, (color) => (this._edgeColor = color))
|
||||
}
|
||||
|
||||
set activeNodeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeNodeColor, value, (color) => (this._activeNodeColor = color))
|
||||
}
|
||||
|
||||
set activeEdgeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeEdgeColor, value, (color) => (this._activeEdgeColor = color))
|
||||
}
|
||||
|
||||
set nodeColors(value: FlowchartNodeColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._nodeColors, next)) return
|
||||
this._nodeColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
set nodeBgColors(value: FlowchartNodeColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._nodeBgColors, next)) return
|
||||
this._nodeBgColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
get activeNode(): string | undefined {
|
||||
return this._activeNode
|
||||
}
|
||||
|
||||
set activeNode(value: string | undefined) {
|
||||
if (this._activeNode === value) return
|
||||
this._activeNode = value
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get selectedConnectionIndex(): number {
|
||||
return this._selectedConnectionIndex
|
||||
}
|
||||
|
||||
get selectedConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
const selected = this.selectedOutgoingEdge()
|
||||
return selected ? { from: selected.edge.from, to: selected.edge.to, index: selected.index } : undefined
|
||||
}
|
||||
|
||||
get activeEdge(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this._activeEdge
|
||||
}
|
||||
|
||||
set activeEdge(value: FlowchartActiveEdgeSelection | undefined) {
|
||||
if (flowchartActiveEdgesEqual(this._activeEdge, value)) return
|
||||
this._activeEdge = value ? { ...value } : undefined
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set labelColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._labelColor, value, (color) => (this._labelColor = color))
|
||||
}
|
||||
|
||||
set groupColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._groupColor, value, (color) => (this._groupColor = color))
|
||||
}
|
||||
|
||||
activateFirstNode(): string | undefined {
|
||||
if (this._activeNode) return this._activeNode
|
||||
const node = this.parsedDiagram().nodes[0]
|
||||
if (!node) return undefined
|
||||
this.activeNode = node.id
|
||||
return node.id
|
||||
}
|
||||
|
||||
selectNextConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this.selectConnection(1)
|
||||
}
|
||||
|
||||
selectPreviousConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this.selectConnection(-1)
|
||||
}
|
||||
|
||||
private selectConnection(delta: 1 | -1): FlowchartActiveEdgeSelection | undefined {
|
||||
this.activateFirstNode()
|
||||
const outgoing = this.activeOutgoingEdges()
|
||||
if (outgoing.length === 0) return undefined
|
||||
this._activeEdge = undefined
|
||||
if (outgoing.length === 1) {
|
||||
this.invalidateGrid()
|
||||
return this.selectedConnection
|
||||
}
|
||||
this._selectedConnectionIndex = (this._selectedConnectionIndex + delta + outgoing.length) % outgoing.length
|
||||
this.invalidateGrid()
|
||||
return this.selectedConnection
|
||||
}
|
||||
|
||||
followSelectedConnection(): string | undefined {
|
||||
const selected = this.selectedOutgoingEdge()
|
||||
if (!selected) return undefined
|
||||
this._activeNode = selected.edge.to
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
this.invalidateGrid()
|
||||
return selected.edge.to
|
||||
}
|
||||
|
||||
private activeOutgoingEdges(): IndexedFlowchartEdge[] {
|
||||
if (!this._activeNode) return []
|
||||
return this.parsedDiagram().edges.flatMap((edge, index) =>
|
||||
edge.from === this._activeNode ? [{ edge, index }] : [],
|
||||
)
|
||||
}
|
||||
|
||||
private selectedOutgoingEdge(): IndexedFlowchartEdge | undefined {
|
||||
const outgoing = this.activeOutgoingEdges()
|
||||
if (outgoing.length === 0) return undefined
|
||||
const index = ((this._selectedConnectionIndex % outgoing.length) + outgoing.length) % outgoing.length
|
||||
return outgoing[index]
|
||||
}
|
||||
|
||||
private renderOptions(): FlowchartDiagramRenderOptions {
|
||||
return {
|
||||
compact: this._compact,
|
||||
direction: this._direction,
|
||||
borderStyle: this._borderStyle,
|
||||
minNodeGap: this._minNodeGap,
|
||||
minRankGap: this._minRankGap,
|
||||
layoutMaxWidth: this._layoutMaxWidth,
|
||||
activeNode: this._activeNode,
|
||||
activeEdge: this._activeEdge ?? this.selectedConnection,
|
||||
}
|
||||
}
|
||||
|
||||
private styledText(grid: FlowchartGrid) {
|
||||
return renderGridStyledText(
|
||||
grid,
|
||||
resolveFlowchartStyleColors({
|
||||
node: this._nodeColor,
|
||||
database: this._databaseColor,
|
||||
edge: this._edgeColor,
|
||||
activeNode: this._activeNodeColor,
|
||||
activeEdge: this._activeEdgeColor,
|
||||
label: this._labelColor,
|
||||
group: this._groupColor,
|
||||
}),
|
||||
this._nodeColors,
|
||||
this._nodeBgColors,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
|
||||
function bounds(id: string, left: number, top: number): FlowchartNodeBounds {
|
||||
const width = 5
|
||||
const height = 3
|
||||
return {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
lines: [id],
|
||||
left,
|
||||
top,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagram["edges"]): FlowchartDiagram {
|
||||
return { direction, nodes: [], edges, subgraphs: [] }
|
||||
}
|
||||
|
||||
describe("flowchart routing", () => {
|
||||
test("routes a simple horizontal edge from source port to target port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes a simple reverse horizontal edge into the target right port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 19, y: 1 },
|
||||
{ x: 5, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes horizontal back-edges above forward lanes", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: -1 },
|
||||
{ x: 22, y: -4 },
|
||||
{ x: 2, y: -4 },
|
||||
{ x: 2, y: -1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes parallel horizontal edges on independent lanes", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 3 },
|
||||
{ x: 2, y: 6 },
|
||||
{ x: 22, y: 6 },
|
||||
{ x: 22, y: 3 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("spaces parallel horizontal lanes for multiline labels", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second 1<br/>second 2<br/>second 3" },
|
||||
{ from: "A", to: "B", label: "third 1<br/>third 2<br/>third 3" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes[2]!.points[1]!.y).toBeGreaterThan(routes[1]!.points[1]!.y + 3)
|
||||
})
|
||||
|
||||
test("routes horizontal fan-out through a shared bus lane", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "" },
|
||||
{ from: "A", to: "C", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 6)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
["C", bounds("C", 20, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 13 },
|
||||
{ x: 19, y: 13 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("routes each horizontal edge once when fan-in and fan-out overlap", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "A", to: "D", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
{ from: "B", to: "D", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
["C", bounds("C", 24, 0)],
|
||||
["D", bounds("D", 24, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toHaveLength(edges.length)
|
||||
expect(routes.map((route) => `${route.edge.from}->${route.edge.to}`).sort()).toEqual([
|
||||
"A->C",
|
||||
"A->D",
|
||||
"B->C",
|
||||
"B->D",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes vertical back-edges around the left side", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("TD", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: -1, y: 13 },
|
||||
{ x: -4, y: 13 },
|
||||
{ x: -4, y: 1 },
|
||||
{ x: -1, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes self edges below the source node", () => {
|
||||
const edge = { from: "A", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(diagram("TD", [edge]), new Map([["A", bounds("A", 0, 0)]]))
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 8, y: 4 },
|
||||
{ x: 2, y: 4 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes same-column horizontal-flow edges through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "rollback" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 20, 8)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes overlapping horizontal-flow columns through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "merge" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 1, 8)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 2, y: 7 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,703 +0,0 @@
|
||||
import {
|
||||
advanceCoordinate,
|
||||
afterFarthestCoordinate,
|
||||
beforeNearestCoordinate,
|
||||
boundsCenter,
|
||||
boundsSidePoint,
|
||||
centerCoordinate,
|
||||
coordinate,
|
||||
keepAfter,
|
||||
keepBefore,
|
||||
lane,
|
||||
oppositeSide,
|
||||
orthogonalPath,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
withCoordinate,
|
||||
type DiagramAxis,
|
||||
type DiagramDirection,
|
||||
type DiagramLane,
|
||||
type DiagramSide,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraph,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
|
||||
|
||||
const BUS_CLEARANCE = 3
|
||||
const NODE_CLEARANCE = 2
|
||||
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
|
||||
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
|
||||
type PortRole = "source" | "target"
|
||||
|
||||
interface EdgeRecord {
|
||||
edge: FlowchartEdge
|
||||
sourcePort: FlowchartPoint
|
||||
targetPort: FlowchartPoint
|
||||
}
|
||||
|
||||
function isVerticalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "TB" || direction === "TD" || direction === "BT"
|
||||
}
|
||||
|
||||
function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
|
||||
return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
|
||||
}
|
||||
|
||||
function isVerticalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "BT"
|
||||
? centerCoordinate(to, "y") > centerCoordinate(from, "y")
|
||||
: centerCoordinate(to, "y") < centerCoordinate(from, "y")
|
||||
}
|
||||
|
||||
function isHorizontalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "RL"
|
||||
? centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
: centerCoordinate(to, "x") < centerCoordinate(from, "x")
|
||||
}
|
||||
|
||||
function horizontalTravel(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): HorizontalTravel {
|
||||
const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
|
||||
return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
|
||||
}
|
||||
|
||||
function verticalBackEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "left")
|
||||
const end = boundsSidePoint(to, "left")
|
||||
const busX = Math.min(
|
||||
afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
|
||||
leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
|
||||
)
|
||||
return pathViaLane(start, lane("x", busX), end)
|
||||
}
|
||||
|
||||
function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const travel = verticalTravel(from, to)
|
||||
const startSide = sideForDirection(travel)
|
||||
const endSide = oppositeSide(startSide)
|
||||
const sourceCenter = boundsCenter(from)
|
||||
const targetCenter = boundsCenter(to)
|
||||
const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
|
||||
const end = boundsSidePoint(to, endSide)
|
||||
return orthogonalPath(start, end, { preferredAxis: "y" })
|
||||
}
|
||||
|
||||
function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
|
||||
return pathViaLane(start, lane("y", busY), end)
|
||||
}
|
||||
|
||||
function horizontalEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartPoint[] {
|
||||
const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
|
||||
if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
|
||||
|
||||
if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
|
||||
|
||||
const travel = horizontalTravel(from, to, direction)
|
||||
const startSide = sideForDirection(travel)
|
||||
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
|
||||
}
|
||||
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(bounds, "right")
|
||||
const end = boundsSidePoint(bounds, "bottom")
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
|
||||
const bottomLaneY = bounds.top + bounds.height + 1
|
||||
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
|
||||
}
|
||||
|
||||
function parallelEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
laneCoordinate: number,
|
||||
): FlowchartPoint[] {
|
||||
if (!isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "bottom")
|
||||
const end = boundsSidePoint(to, "bottom")
|
||||
return pathViaLane(start, lane("y", laneCoordinate), end)
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
return pathViaLane(start, lane("x", laneCoordinate), end)
|
||||
}
|
||||
|
||||
function labelHeight(edge: FlowchartEdge): number {
|
||||
return edge.label ? splitDiagramLines(edge.label).length : 0
|
||||
}
|
||||
|
||||
function rightRenderExtent(route: FlowchartEdgeRoute): number {
|
||||
let right = Math.max(...route.points.map((point) => point.x))
|
||||
if (route.edge.label) {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth)
|
||||
right = Math.max(right, label.point.x + label.width - 1)
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
function edgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
if (from.id === to.id) return selfEdgePath(from)
|
||||
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
|
||||
return isVerticalBackEdge(from, to, direction)
|
||||
? verticalBackEdgePath(from, to, leftBoundary)
|
||||
: verticalForwardEdgePath(from, to)
|
||||
}
|
||||
|
||||
function sourceFanOutLane(
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPorts: readonly FlowchartPoint[],
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const sourceCoordinate = coordinate(sourcePort, axis)
|
||||
const unclamped = keepBefore(
|
||||
advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
|
||||
beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepAfter(unclamped, sourceCoordinate, travel)
|
||||
}
|
||||
|
||||
function targetFanInLane(
|
||||
sourcePorts: readonly FlowchartPoint[],
|
||||
targetPort: FlowchartPoint,
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const targetCoordinate = coordinate(targetPort, axis)
|
||||
const unclamped = keepAfter(
|
||||
advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
|
||||
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepBefore(unclamped, targetCoordinate, travel)
|
||||
}
|
||||
|
||||
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
|
||||
const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
|
||||
return boundsSidePoint(bounds, side)
|
||||
}
|
||||
|
||||
function horizontalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "RL"
|
||||
? centerCoordinate(target, "x") < centerCoordinate(source, "x")
|
||||
: centerCoordinate(target, "x") > centerCoordinate(source, "x")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function verticalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "BT"
|
||||
? centerCoordinate(target, "y") < centerCoordinate(source, "y")
|
||||
: centerCoordinate(target, "y") > centerCoordinate(source, "y")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
|
||||
return subgraph.direction === "RL" ? "left" : "right"
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
|
||||
return subgraph.direction === "RL"
|
||||
? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
: subgraphBound.left - BUS_CLEARANCE
|
||||
}
|
||||
|
||||
function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
|
||||
if (from.centerY <= targetSubgraphBound.centerY) {
|
||||
const start = from.top + from.height
|
||||
const end = targetSubgraphBound.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : start
|
||||
}
|
||||
|
||||
const start = targetSubgraphBound.top + targetSubgraphBound.height
|
||||
const end = from.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : end
|
||||
}
|
||||
|
||||
function horizontalSubgraphExitJoinY(
|
||||
from: FlowchartSubgraphBounds,
|
||||
targetPort: FlowchartPoint,
|
||||
targetBelow: boolean,
|
||||
): number {
|
||||
if (targetBelow) {
|
||||
const outside = from.top + from.height
|
||||
const beforeTarget = targetPort.y - 1
|
||||
const preferred = targetPort.y - BUS_CLEARANCE
|
||||
return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
|
||||
}
|
||||
|
||||
const outside = from.top - 1
|
||||
const afterTarget = targetPort.y + 1
|
||||
const preferred = targetPort.y + BUS_CLEARANCE
|
||||
return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
|
||||
}
|
||||
|
||||
function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
|
||||
const groups = new Map<string, Record[]>()
|
||||
for (const record of records) {
|
||||
const groupKey = key(record)
|
||||
const group = groups.get(groupKey) ?? []
|
||||
group.push(record)
|
||||
groups.set(groupKey, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function fanRoute(
|
||||
edge: FlowchartEdge,
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPort: FlowchartPoint,
|
||||
routeLane: DiagramLane,
|
||||
): FlowchartEdgeRoute {
|
||||
return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
|
||||
}
|
||||
|
||||
function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
|
||||
const xs = records.map((record) => record.sourcePort.x)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
if (maxX - minX > 1) return [...records]
|
||||
|
||||
const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
|
||||
return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
|
||||
}
|
||||
|
||||
function routeHorizontalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
leftBoundary: number | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`)
|
||||
for (const edges of groups.values()) {
|
||||
if (edges.length < 2) continue
|
||||
const from = bounds.get(edges[0]!.from)
|
||||
const to = bounds.get(edges[0]!.to)
|
||||
if (!from || !to || from.id === to.id) continue
|
||||
const direction = directionForEdge(edges[0]!)
|
||||
const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) }
|
||||
routes.push(canonicalRoute)
|
||||
handled.add(edges[0]!)
|
||||
let previousRoute = canonicalRoute
|
||||
for (let index = 1; index < edges.length; index++) {
|
||||
const edge = edges[index]!
|
||||
const laneCoordinate = isVerticalDirection(direction)
|
||||
? Math.max(
|
||||
Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE,
|
||||
rightRenderExtent(previousRoute) + NODE_CLEARANCE,
|
||||
)
|
||||
: Math.max(
|
||||
Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE,
|
||||
Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1),
|
||||
)
|
||||
const route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
|
||||
routes.push(route)
|
||||
handled.add(edge)
|
||||
previousRoute = route
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphExitFanIn(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalExitSubgraph(diagram, edge)
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!subgraph || !source || !target) continue
|
||||
|
||||
const key = `${subgraph.id}:${edge.to}`
|
||||
const group = groups.get(key) ?? []
|
||||
group.push({ edge, subgraph, source })
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const subgraph = group[0]!.subgraph
|
||||
const subgraphBound = subgraphBounds.get(subgraph.id)
|
||||
const target = bounds.get(group[0]!.edge.to)
|
||||
if (!subgraphBound || !target) continue
|
||||
|
||||
const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
|
||||
const busX =
|
||||
subgraph.direction === "RL"
|
||||
? subgraphBound.left - BUS_CLEARANCE
|
||||
: subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
|
||||
const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
|
||||
const targetBelow = target.centerY >= subgraphBound.centerY
|
||||
const targetPort = targetSubgraph
|
||||
? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
|
||||
: boundsSidePoint(target, targetBelow ? "top" : "bottom")
|
||||
const joinY = targetSubgraphBound
|
||||
? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
|
||||
: horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
|
||||
const entryX =
|
||||
targetSubgraph && targetSubgraphBound
|
||||
? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
|
||||
: targetPort.x
|
||||
|
||||
for (const record of group) {
|
||||
const sourcePort = portForTravel(record.source, travel, "source")
|
||||
routes.push({
|
||||
edge: record.edge,
|
||||
points: pathThrough([
|
||||
sourcePort,
|
||||
{ x: busX, y: sourcePort.y },
|
||||
{ x: busX, y: joinY },
|
||||
{ x: entryX, y: joinY },
|
||||
{ x: entryX, y: targetPort.y },
|
||||
targetPort,
|
||||
]),
|
||||
})
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphEntries(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalEntrySubgraph(diagram, edge)
|
||||
const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!subgraph || !subgraphBound || !from || !to) continue
|
||||
|
||||
const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
|
||||
const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
|
||||
const travel = verticalTravel(from, to)
|
||||
const sourcePort = portForTravel(from, travel, "source")
|
||||
routes.push({
|
||||
edge,
|
||||
points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
|
||||
})
|
||||
handled.add(edge)
|
||||
}
|
||||
}
|
||||
|
||||
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
if (from.x === to.x) {
|
||||
if (
|
||||
from.x >= bounds.left &&
|
||||
from.x <= right &&
|
||||
Math.max(from.y, to.y) >= bounds.top &&
|
||||
Math.min(from.y, to.y) <= bottom
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
from.y >= bounds.top &&
|
||||
from.y <= bottom &&
|
||||
Math.max(from.x, to.x) >= bounds.left &&
|
||||
Math.min(from.x, to.x) <= right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartEdgeRoute {
|
||||
const obstacle = [...bounds.values()].some(
|
||||
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
|
||||
)
|
||||
if (!obstacle) return route
|
||||
|
||||
const from = bounds.get(route.edge.from)
|
||||
const to = bounds.get(route.edge.to)
|
||||
if (!from || !to) return route
|
||||
if (isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
const busX = Math.max(...[...bounds.values()].map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("x", busX), end) }
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = Math.min(...[...bounds.values()].map((bound) => bound.top)) - BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("y", busY), end) }
|
||||
}
|
||||
|
||||
export function routeFlowchartEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
|
||||
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): FlowchartEdgeRoute[] {
|
||||
const handled = new Set<FlowchartEdge>()
|
||||
const routes: FlowchartEdgeRoute[] = []
|
||||
const leftBoundary = subgraphBounds
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(diagram, bounds, directionForEdge, leftBoundary, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
|
||||
if (horizontalEdges.length === 0) continue
|
||||
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
|
||||
routeHorizontalFanOut(records, direction, handled, routes)
|
||||
routeHorizontalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
routeHorizontalSubgraphExitFanIn(diagram, bounds, subgraphBounds, handled, routes)
|
||||
routeHorizontalSubgraphEntries(diagram, bounds, subgraphBounds, handled, routes)
|
||||
|
||||
for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
|
||||
const verticalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
|
||||
if (verticalEdges.length === 0) continue
|
||||
const records = verticalForwardRecords(verticalEdges, bounds, direction)
|
||||
routeVerticalFanOut(records, direction, handled, routes)
|
||||
routeVerticalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!from || !to) continue
|
||||
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
|
||||
}
|
||||
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
if (sourcePoint.x < bounds.left) return "left"
|
||||
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
|
||||
if (sourcePoint.y < bounds.top) return "top"
|
||||
return "bottom"
|
||||
}
|
||||
|
||||
function connectorChar(side: DiagramSide): string {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return "┤"
|
||||
case "right":
|
||||
return "├"
|
||||
case "top":
|
||||
return "┴"
|
||||
case "bottom":
|
||||
return "┬"
|
||||
}
|
||||
}
|
||||
|
||||
export function flowchartSourceConnector(
|
||||
from: FlowchartNodeBounds,
|
||||
sourcePoint: FlowchartPoint,
|
||||
): { x: number; y: number; char: string } {
|
||||
const side = sideForOutsidePoint(from, sourcePoint)
|
||||
const connector = boundsSidePoint(from, side, "border")
|
||||
return {
|
||||
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
|
||||
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
|
||||
char: connectorChar(side),
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { RGBA, type ColorInput, type StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRunOptions } from "../core/canvas.js"
|
||||
import { diagramCellColorKey, mappedDiagramColor } from "../core/color/map.js"
|
||||
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import {
|
||||
ansiFg,
|
||||
createColorRampTheme,
|
||||
createAnsiRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramFadeStep,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
|
||||
export type FlowchartBaseCellStyle = "node" | "activeNode" | "database" | "edge" | "activeEdge" | "label" | "group"
|
||||
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
||||
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
|
||||
export interface FlowchartCellMetadata {
|
||||
nodeId?: string
|
||||
bgNodeId?: string
|
||||
}
|
||||
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>
|
||||
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
||||
export type FlowchartDiagramAnsiTheme = Partial<Record<FlowchartCellStyle, string>>
|
||||
export type FlowchartNodeColorMap = ReadonlyMap<string, RGBA>
|
||||
export type FlowchartNodeColors = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
|
||||
|
||||
export function flowchartNodeColorKey(nodeId: string, level: number): string {
|
||||
return diagramCellColorKey(nodeId, level)
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_RGB = {
|
||||
node: [228, 239, 232],
|
||||
activeNode: [221, 255, 246],
|
||||
database: [228, 239, 232],
|
||||
edge: [134, 225, 200],
|
||||
activeEdge: [221, 255, 246],
|
||||
label: [134, 225, 200],
|
||||
group: [76, 99, 89],
|
||||
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
|
||||
|
||||
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
|
||||
const DEFAULT_ANSI_THEME: Required<Record<FlowchartCellStyle, string>> = {
|
||||
node: ansiFg(DEFAULT_THEME_RGB.node),
|
||||
activeNode: ansiFg(DEFAULT_THEME_RGB.activeNode),
|
||||
database: ansiFg(DEFAULT_THEME_RGB.database),
|
||||
edge: ansiFg(DEFAULT_THEME_RGB.edge),
|
||||
activeEdge: ansiFg(DEFAULT_THEME_RGB.activeEdge),
|
||||
label: ansiFg(DEFAULT_THEME_RGB.label),
|
||||
group: ansiFg(DEFAULT_THEME_RGB.group),
|
||||
...createAnsiRampTheme(NODE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.node, DEFAULT_THEME_RGB.edge),
|
||||
...createAnsiRampTheme(DATABASE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.database, DEFAULT_THEME_RGB.edge),
|
||||
}
|
||||
|
||||
export function resolveFlowchartStyleColors(
|
||||
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
||||
): FlowchartStyleColors {
|
||||
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
||||
const activeNode = colors.activeNode ?? rgba(DEFAULT_THEME_RGB.activeNode)
|
||||
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
||||
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
||||
const activeEdge = colors.activeEdge ?? rgba(DEFAULT_THEME_RGB.activeEdge)
|
||||
return {
|
||||
node,
|
||||
activeNode,
|
||||
database,
|
||||
edge,
|
||||
activeEdge,
|
||||
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
|
||||
}
|
||||
}
|
||||
|
||||
function nodeMappedColor(colors: FlowchartNodeColorMap | undefined, nodeId: string | undefined): RGBA | undefined {
|
||||
return mappedDiagramColor(colors, nodeId)
|
||||
}
|
||||
|
||||
export function renderGridStyledText(
|
||||
grid: FlowchartGrid,
|
||||
colors: FlowchartStyleColors,
|
||||
nodeColors?: FlowchartNodeColorMap,
|
||||
nodeBgColors?: FlowchartNodeColorMap,
|
||||
): StyledText {
|
||||
const useNodeRuns = Boolean(nodeColors?.size || nodeBgColors?.size)
|
||||
const runOptions: DiagramCanvasRunOptions<FlowchartCellStyle, FlowchartCellMetadata> = useNodeRuns
|
||||
? { trimTop: true, trimBottom: true, key: (cell) => [cell.style, cell.nodeId, cell.bgNodeId] }
|
||||
: { trimTop: true, trimBottom: true }
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => nodeMappedColor(nodeColors, run.cell.nodeId) ?? (run.style ? colors[run.style] : undefined),
|
||||
(run) => nodeMappedColor(nodeBgColors, run.cell.bgNodeId),
|
||||
runOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function renderGridAnsi(grid: FlowchartGrid, theme: FlowchartDiagramAnsiTheme = {}): string {
|
||||
const resolved = { ...DEFAULT_ANSI_THEME, ...theme }
|
||||
return renderDiagramGridAnsi(grid, (run) => (run.style ? resolved[run.style] : undefined), {
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
|
||||
|
||||
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
|
||||
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
|
||||
export type FlowchartEdgeStyle = "thick" | "dashed"
|
||||
|
||||
export interface FlowchartNode {
|
||||
id: string
|
||||
label: string
|
||||
shape: FlowchartNodeShape
|
||||
}
|
||||
|
||||
export interface FlowchartEdge {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style?: FlowchartEdgeStyle
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraph {
|
||||
id: string
|
||||
label: string
|
||||
nodeIds: string[]
|
||||
parentId?: string
|
||||
direction?: FlowchartDirection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagram {
|
||||
direction: FlowchartDirection
|
||||
nodes: FlowchartNode[]
|
||||
edges: FlowchartEdge[]
|
||||
subgraphs?: FlowchartSubgraph[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraphBounds extends DiagramBounds {
|
||||
id: string
|
||||
label: string
|
||||
labelSide: "top" | "bottom"
|
||||
}
|
||||
|
||||
export type FlowchartPoint = DiagramPoint
|
||||
|
||||
export interface FlowchartEdgeRoute {
|
||||
edge: FlowchartEdge
|
||||
points: FlowchartPoint[]
|
||||
}
|
||||
|
||||
export interface FlowchartActiveEdgeSelection {
|
||||
from: string
|
||||
to: string
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type FlowchartEdgeDirection = DiagramDirection
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as Flowchart from "./flowchart/index.js"
|
||||
import * as Sequence from "./sequence/index.js"
|
||||
import * as State from "./state/index.js"
|
||||
import { type MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
|
||||
export { Flowchart, Sequence, State }
|
||||
export { MermaidSyntaxError } from "./diagnostics.js"
|
||||
export { createMermaidMarkdownRenderer, type MermaidMarkdownRendererOptions } from "./markdown.js"
|
||||
|
||||
export type DiagramKind = MermaidDiagramKind
|
||||
|
||||
export type ParsedDiagram =
|
||||
| { readonly kind: "flowchart"; readonly diagram: Flowchart.Diagram }
|
||||
| { readonly kind: "sequence"; readonly diagram: Sequence.Diagram }
|
||||
| { readonly kind: "state"; readonly diagram: State.Diagram }
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
/** Theme override. Forwarded to the matching renderer. */
|
||||
theme?: Flowchart.Theme | Sequence.Theme | State.Theme
|
||||
}
|
||||
|
||||
export class UnknownDiagramError extends Error {
|
||||
readonly _tag = "UnknownDiagramError"
|
||||
constructor(content: string) {
|
||||
const head = firstMeaningfulLine(content) ?? "(empty)"
|
||||
super(
|
||||
`Could not detect diagram kind. Expected the first non-empty line to start with ` +
|
||||
`"flowchart", "graph", "sequenceDiagram", or "stateDiagram[-v2]". Got: "${head}"`,
|
||||
)
|
||||
this.name = "UnknownDiagramError"
|
||||
}
|
||||
}
|
||||
|
||||
function firstMeaningfulLine(content: string): string | undefined {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("%%"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the diagram kind from the leading Mermaid declaration.
|
||||
*
|
||||
* Returns `undefined` if the content does not look like any supported diagram.
|
||||
*/
|
||||
export function detect(content: string): DiagramKind | undefined {
|
||||
return detectMermaidDiagram(content)
|
||||
}
|
||||
|
||||
/** True if the content is recognizably a supported Mermaid diagram. */
|
||||
export function isMermaid(content: string): boolean {
|
||||
return detect(content) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render any supported Mermaid string for the terminal.
|
||||
*
|
||||
* The leading `flowchart`/`sequenceDiagram`/`stateDiagram-v2` line picks the
|
||||
* right renderer. Defaults to ANSI-colored output; pass `{ color: false }`
|
||||
* for plain text.
|
||||
*
|
||||
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const kind = detect(content)
|
||||
if (!kind) throw new UnknownDiagramError(content)
|
||||
const opts = options as { color?: boolean; theme?: unknown }
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return Flowchart.render(content, opts as Flowchart.RenderOptions)
|
||||
case "sequence":
|
||||
return Sequence.render(content, opts as Sequence.RenderOptions)
|
||||
case "state":
|
||||
return State.render(content, opts as State.RenderOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse any supported Mermaid string into a discriminated union AST.
|
||||
*
|
||||
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
|
||||
*/
|
||||
export function parse(content: string): ParsedDiagram {
|
||||
const kind = detect(content)
|
||||
if (!kind) throw new UnknownDiagramError(content)
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return { kind, diagram: Flowchart.parse(content) }
|
||||
case "sequence":
|
||||
return { kind, diagram: Sequence.parse(content) }
|
||||
case "state":
|
||||
return { kind, diagram: State.parse(content) }
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
createMarkdownCodeBlockRenderer,
|
||||
type ColorInput,
|
||||
type MarkdownOptions,
|
||||
type MouseEvent,
|
||||
type RenderContext,
|
||||
} from "@opentui/core"
|
||||
import { MermaidSyntaxError } from "./diagnostics.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
import { FlowchartDiagramRenderable } from "./flowchart/renderable.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { SequenceDiagramRenderable } from "./sequence/renderable.js"
|
||||
import { parseMermaidStateDiagram } from "./state/parser.js"
|
||||
import { StateDiagramRenderable } from "./state/renderable.js"
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
secondary?: ColorInput
|
||||
muted?: ColorInput
|
||||
accent?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an OpenTUI Markdown node renderer for fenced Mermaid diagrams. */
|
||||
export function createMermaidMarkdownRenderer(
|
||||
ctx: RenderContext,
|
||||
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
|
||||
): NonNullable<MarkdownOptions["renderNode"]> {
|
||||
return createMarkdownCodeBlockRenderer({
|
||||
mermaid: (token) => {
|
||||
const kind = detectMermaidDiagram(token.text)
|
||||
if (!kind) return undefined
|
||||
|
||||
try {
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
parseMermaidFlowchartDiagram(token.text)
|
||||
break
|
||||
case "sequence":
|
||||
parseMermaidSequenceDiagram(token.text)
|
||||
break
|
||||
case "state":
|
||||
parseMermaidStateDiagram(token.text)
|
||||
break
|
||||
}
|
||||
const options = typeof input === "function" ? input() : input
|
||||
const colors = options.colors ?? {}
|
||||
const diagram = (() => {
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return new FlowchartDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
compact: options.compact,
|
||||
nodeColor: colors.primary,
|
||||
databaseColor: colors.secondary,
|
||||
edgeColor: colors.secondary,
|
||||
labelColor: colors.text,
|
||||
groupColor: colors.muted,
|
||||
})
|
||||
case "sequence":
|
||||
return new SequenceDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
compact: options.compact,
|
||||
participantColor: colors.primary,
|
||||
lifelineColor: colors.muted,
|
||||
groupColor: colors.secondary,
|
||||
requestColor: colors.primary,
|
||||
responseColor: colors.primary,
|
||||
noteColor: colors.warning,
|
||||
noteBackgroundColor: colors.background,
|
||||
})
|
||||
case "state":
|
||||
return new StateDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
stateColor: colors.primary,
|
||||
compositeColor: colors.muted,
|
||||
transitionColor: colors.secondary,
|
||||
labelColor: colors.text,
|
||||
noteBorderColor: colors.warning,
|
||||
noteTextColor: colors.warning,
|
||||
noteConnectorColor: colors.muted,
|
||||
activeStateColor: colors.accent,
|
||||
activeTransitionColor: colors.accent,
|
||||
startColor: colors.muted,
|
||||
endColor: colors.muted,
|
||||
choiceColor: colors.primary,
|
||||
})
|
||||
}
|
||||
})()
|
||||
diagram.width = "100%"
|
||||
diagram.height = diagram.renderedHeight
|
||||
diagram.marginTop = 1
|
||||
diagram.selectable = false
|
||||
let drag: { x: number; y: number } | undefined
|
||||
diagram.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
drag = { x: event.x, y: event.y }
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
diagram.onMouseDrag = (event: MouseEvent) => {
|
||||
if (!drag) return
|
||||
const dx = event.x - drag.x
|
||||
drag = { x: event.x, y: event.y }
|
||||
if (dx) diagram.scrollX -= dx
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
diagram.onMouseDragEnd = () => {
|
||||
drag = undefined
|
||||
}
|
||||
diagram.onMouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
drag = undefined
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
return diagram
|
||||
} catch (error) {
|
||||
if (error instanceof MermaidSyntaxError) return undefined
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})!
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMermaidMarkdownRenderer } from "./markdown.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.merman",
|
||||
setup(context) {
|
||||
context.markdown.registerCodeBlockRenderer(
|
||||
"mermaid",
|
||||
createMermaidMarkdownRenderer(context.renderer, () => ({
|
||||
compact: true,
|
||||
colors: {
|
||||
text: context.theme.markdown.text,
|
||||
primary: context.theme.text.default,
|
||||
secondary: context.theme.text.subdued,
|
||||
muted: context.theme.border.default,
|
||||
accent: context.theme.text.action.primary.focused,
|
||||
warning: context.theme.text.default,
|
||||
background: context.theme.background.default,
|
||||
},
|
||||
})),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -1,896 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderSequenceDiagram, renderSequenceDiagramAnsi } from "./diagram.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { SequenceDiagramRenderable } from "./renderable.js"
|
||||
|
||||
describe("SequenceDiagram", () => {
|
||||
test("parses Mermaid sequenceDiagram participants and messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expect(diagram.participants).toEqual([
|
||||
{ id: "B", label: "Browser" },
|
||||
{ id: "S", label: "Server" },
|
||||
])
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "B", to: "S", label: "GET /", style: "solid" },
|
||||
{ from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "B", to: "S", label: "GET /", style: "solid" } },
|
||||
{ type: "message", message: { from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a terminal sequence diagram", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────▶
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◀─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders a compact terminal sequence diagram without participant boxes", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Leaf as leaf tool
|
||||
participant Location as LocationMutation
|
||||
participant File as FileMutation
|
||||
Leaf->>Location: resolve(path)
|
||||
Location-->>Leaf: Plan(target, authority anchor)
|
||||
Leaf->>File: commit(plan)
|
||||
File->>Location: revalidate(plan)
|
||||
Location-->>File: same target or reject
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
leaf tool LocationMutation FileMutation
|
||||
│ │ │
|
||||
├─ resolve(path) ───────────────────▶ │
|
||||
│ │ │
|
||||
◀─ Plan(target, authority anchor) ──┤ │
|
||||
│ │ │
|
||||
├─ commit(plan) ───────────────────────────────────────────────▶
|
||||
│ │ │
|
||||
│ ◀─ revalidate(plan) ───────┤
|
||||
│ │ │
|
||||
│ ├─ same target or reject ──▶
|
||||
│ │ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps structured sequence steps visible in compact mode", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Worker
|
||||
participant Store
|
||||
Note over Worker,Store: transaction
|
||||
alt accepted
|
||||
Worker->>Worker: prepare
|
||||
Worker->>Store: commit
|
||||
end
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toContainInOrder("Worker", "Store", "transaction", "alt: accepted", "prepare", "commit")
|
||||
})
|
||||
|
||||
test("keeps compact labels above arrows when they do not fit inline", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
"sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: this label is deliberately much too long to fit between endpoints despite intermediate spacing",
|
||||
{ compact: true },
|
||||
)
|
||||
const lines = output.split("\n")
|
||||
|
||||
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
|
||||
lines.findIndex((line) => line.includes("▶")),
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes invalid participant gaps for text and live rendering", async () => {
|
||||
const content = "sequenceDiagram\n A->>B: hello"
|
||||
|
||||
expect(renderSequenceDiagram(content, { minParticipantGap: Number.NaN })).toContain("hello")
|
||||
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content,
|
||||
minParticipantGap: Number.NaN,
|
||||
})
|
||||
|
||||
expect(diagram.minParticipantGap).toBe(18)
|
||||
diagram.minParticipantGap = 0
|
||||
expect(diagram.minParticipantGap).toBe(1)
|
||||
diagram.minParticipantGap = 3.9
|
||||
expect(diagram.minParticipantGap).toBe(3)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("connects participant headers to lifelines", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("renders notes and long cross-participant messages in order", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
participant Store as Ticket store
|
||||
Note over Browser,Server: native browser Basic prompt
|
||||
Browser->>Server: POST connect-token
|
||||
Server->>Store: issue { ptyID, scope }
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder(
|
||||
"native browser Basic prompt",
|
||||
"POST connect-token",
|
||||
"issue { ptyID, scope }",
|
||||
)
|
||||
})
|
||||
|
||||
test("renders notes to the left and right of a participant", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant OpenTUI
|
||||
Note right of OpenTUI: child is not renderable
|
||||
Note left of OpenTUI: remove failed`)
|
||||
|
||||
expect(output).toContain("child is not renderable")
|
||||
expect(output).toContain("remove failed")
|
||||
})
|
||||
|
||||
test("preserves the parsed shape of notes over participants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
Note over A: hello`)
|
||||
|
||||
expect(diagram.steps).toContainEqual({ type: "note", note: { over: ["A"], label: "hello" } })
|
||||
})
|
||||
|
||||
test("parses activation shorthand and control blocks", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
alt accepted
|
||||
Server-->>-Browser: response
|
||||
else rejected
|
||||
activate Server
|
||||
Server-->>Browser: error
|
||||
deactivate Server
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Browser",
|
||||
to: "Server",
|
||||
label: "request",
|
||||
style: "solid",
|
||||
activate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "alt", label: "accepted" } },
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Server",
|
||||
to: "Browser",
|
||||
label: "response",
|
||||
style: "dashed",
|
||||
deactivate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "else", label: "rejected" } },
|
||||
{ type: "activation", activation: { participant: "Server", active: true } },
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Server", to: "Browser", label: "error", style: "dashed" },
|
||||
},
|
||||
{ type: "activation", activation: { participant: "Server", active: false } },
|
||||
{ type: "fragment", fragment: { kind: "end", label: "alt" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses activation syntax without rendering activation bars", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
Server-->>-Browser: response
|
||||
`)
|
||||
|
||||
expect(output).not.toContain("┃")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "A", to: "B", label: "open solid", style: "solid", head: "open" },
|
||||
{ from: "B", to: "A", label: "open dashed", style: "dashed", head: "open" },
|
||||
{ from: "A", to: "B", label: "failed solid", style: "solid", head: "cross" },
|
||||
{ from: "B", to: "A", label: "failed dashed", style: "dashed", head: "cross" },
|
||||
{ from: "A", to: "B", label: "async solid", style: "solid", head: "async" },
|
||||
{ from: "B", to: "A", label: "async dashed", style: "dashed", head: "async" },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders Mermaid arrow head variants", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders boxed alt else regions", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
alt accepted
|
||||
Browser->>Server: ok
|
||||
else rejected
|
||||
Server-->>Browser: no
|
||||
end
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder("╭─ alt: accepted", "ok", "├─ else: rejected", "no", "╰")
|
||||
expect(output).not.toContain("end alt")
|
||||
})
|
||||
|
||||
test("expands a fragment frame for a longer else label", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`)
|
||||
const lines = output.split("\n")
|
||||
const elseRow = lines.find((line) => line.includes("validation failed"))!
|
||||
const endRow = [...lines].reverse().find((line) => line.includes("╰"))!
|
||||
|
||||
expect(elseRow.lastIndexOf("┤")).toBe(endRow.lastIndexOf("╯"))
|
||||
})
|
||||
|
||||
test("preserves combined graphemes in participant names", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A as Cafe\u0301
|
||||
participant B
|
||||
A->>B: hi`)
|
||||
|
||||
expect(output).toContain("Cafe\u0301")
|
||||
})
|
||||
|
||||
test("renders fragment boxes with lifeline overhang", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
})
|
||||
|
||||
test("supports configurable fragment border styles", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else no
|
||||
B-->>A: no
|
||||
end
|
||||
`,
|
||||
{ fragmentBorderStyle: "double" },
|
||||
)
|
||||
|
||||
expect(output).toContain("╔")
|
||||
expect(output).toContain("╠")
|
||||
expect(output).toContain("╚")
|
||||
expect(output).toContain("═")
|
||||
expect(output).toContain("║")
|
||||
})
|
||||
|
||||
test("parses and renders autonumbered messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([1, 2])
|
||||
expect(output).toContain("1. request")
|
||||
expect(output).toContain("2. response")
|
||||
})
|
||||
|
||||
test("supports autonumber start and increment", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([10, 15])
|
||||
expect(output).toContain("10. first")
|
||||
expect(output).toContain("15. second")
|
||||
})
|
||||
|
||||
test("parses and renders loop regions", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps[0]).toEqual({
|
||||
type: "fragment",
|
||||
fragment: { kind: "loop", label: "retry up to 3x" },
|
||||
})
|
||||
expect(output).toContain("╭─ ↻ loop: retry up to 3x")
|
||||
expect(output).not.toContain("end loop")
|
||||
expect(output.indexOf("loop: retry up to 3x")).toBeLessThan(output.indexOf("GET /users/42"))
|
||||
})
|
||||
|
||||
test("parses Mermaid box participant groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
end
|
||||
box Purple Storage Layer
|
||||
participant DB
|
||||
end
|
||||
box "Purple Literal Label"
|
||||
participant Worker
|
||||
end
|
||||
Browser->>API: request
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([
|
||||
{ label: "Backend", participantIds: ["API", "Cache"] },
|
||||
{ label: "Storage Layer", participantIds: ["DB"] },
|
||||
{ label: "Purple Literal Label", participantIds: ["Worker"] },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Browser", to: "API", label: "request", style: "solid" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("adds implicit participants inside box groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant API
|
||||
box Backend
|
||||
API->>DB: query
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([{ label: "Backend", participantIds: ["API", "DB"] }])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "API", to: "DB", label: "query", style: "solid" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not clip long non-adjacent messages or notes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room
|
||||
`)
|
||||
|
||||
expect(output).toContain("this message needs room past the final participant")
|
||||
expect(output).toContain("this note also needs full horizontal room")
|
||||
})
|
||||
|
||||
test("keeps long content inside participant groups and fragment frames", () => {
|
||||
const group = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragment = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`)
|
||||
|
||||
const groupMessageRow = group.split("\n").find((line) => line.includes("this message text"))!
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
const groupedNote = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note over A,C: this note text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragmentNote = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
Note over A,C: this non adjacent note is deliberately much wider than the frame
|
||||
end`)
|
||||
const nested = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`)
|
||||
|
||||
expect(groupedNote).toContain("this note text runs far outside of the group container boundary")
|
||||
expect(fragmentNote).toContain("this non adjacent note is deliberately much wider than the frame")
|
||||
expect(nested).toContain("span ─╮│")
|
||||
expect(nested).toContain("──────╯│")
|
||||
})
|
||||
|
||||
test("does not draw external participants inside groups expanded by self messages", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`)
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
|
||||
test("renders full-height participant group boxes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
participant DB
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
API->>Cache: get user:42
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────▶ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("lets message lines pass through group borders without intersections", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
`)
|
||||
const arrowLine = output.split("\n").find((line) => line.includes("▶"))!
|
||||
|
||||
expect(arrowLine).toContain("───────────────▶")
|
||||
expect(arrowLine).not.toContain("┼")
|
||||
})
|
||||
|
||||
test("renders self messages as loopback arrows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◀────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("places two spacer rows above note badges and one below", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
Note over Browser,Server: phase
|
||||
Browser->>Server: two
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const noteRow = lines.findIndex((line) => line.includes("phase"))
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
test("renders br-delimited message labels across multiple rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
`)
|
||||
|
||||
expect(output).toContain("POST connect-token")
|
||||
expect(output).toContain("· Basic (cached by browser)")
|
||||
expect(output).toContain("· X-OpenCode-Ticket: 1")
|
||||
expect(output.indexOf("· X-OpenCode-Ticket: 1")).toBeLessThan(output.indexOf("├"))
|
||||
})
|
||||
|
||||
test("colors request and response messages differently", async () => {
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const responseColor = parseColor("#F59E0B")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Browser->>Server: request
|
||||
Server-->>Browser: response`,
|
||||
requestColor,
|
||||
responseColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const requestSpan = spans.find((span) => span.text.includes("request"))
|
||||
const responseSpan = spans.find((span) => span.text.includes("response"))
|
||||
|
||||
expect(requestSpan?.fg.equals(requestColor)).toBe(true)
|
||||
expect(responseSpan?.fg.equals(responseColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("repaints sequence colors after mounting without changing diagram text", async () => {
|
||||
const initialColor = parseColor("#38BDF8")
|
||||
const updatedColor = parseColor("#A78BFA")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: "sequenceDiagram\n Browser->>Server: request",
|
||||
requestColor: initialColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
const before = testRenderer.captureCharFrame()
|
||||
|
||||
diagram.batchUpdate(() => {
|
||||
diagram.requestColor = updatedColor
|
||||
diagram.lifelineColor = updatedColor
|
||||
})
|
||||
await testRenderer.renderOnce()
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(testRenderer.captureCharFrame()).toBe(before)
|
||||
expect(spans.find((span) => span.text.includes("request"))?.fg.equals(updatedColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tweens arrow departure colors away from lifelines over five cells", async () => {
|
||||
const lifelineColor = parseColor("#94A3B8")
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Browser->>Server: request`,
|
||||
lifelineColor,
|
||||
requestColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const arrowLine = testRenderer
|
||||
.captureSpans()
|
||||
.lines.find((line) => line.spans.some((span) => span.text.includes("▶")))
|
||||
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan).toBeDefined()
|
||||
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tweens self-message departure colors away from lifelines", async () => {
|
||||
const lifelineColor = parseColor("#94A3B8")
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Service->>Service: validate`,
|
||||
lifelineColor,
|
||||
requestColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const arrowLine = testRenderer
|
||||
.captureSpans()
|
||||
.lines.find((line) => line.spans.some((span) => span.text.includes("├")))
|
||||
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan).toBeDefined()
|
||||
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors headers, header rules, and note badges separately", async () => {
|
||||
const participantColor = parseColor("#E5E7EB")
|
||||
const lifelineColor = parseColor("#64748B")
|
||||
const noteColor = parseColor("#A78BFA")
|
||||
const noteBackgroundColor = parseColor("#312E81")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
Note over Browser,Server: native browser Basic prompt`,
|
||||
participantColor,
|
||||
lifelineColor,
|
||||
noteColor,
|
||||
noteBackgroundColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const participantSpan = spans.find((span) => span.text.includes("Browser"))
|
||||
const headerRuleSpan = spans.find((span) => span.text.includes("┬"))
|
||||
const noteSpan = spans.find((span) => span.text.includes("native browser Basic prompt"))
|
||||
|
||||
expect(participantSpan?.fg.equals(participantColor)).toBe(true)
|
||||
expect(headerRuleSpan?.fg.equals(lifelineColor)).toBe(true)
|
||||
expect(noteSpan?.fg.equals(noteColor)).toBe(true)
|
||||
expect(noteSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors group boxes separately from fragments", async () => {
|
||||
const groupColor = parseColor("#8BA394")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
alt ok
|
||||
API->>API: validate
|
||||
end`,
|
||||
groupColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const groupSpan = spans.find((span) => span.text.includes("Backend"))
|
||||
const fragmentSpan = spans.find((span) => span.text.includes("alt: ok"))
|
||||
|
||||
expect(groupSpan?.fg.equals(groupColor)).toBe(true)
|
||||
expect(fragmentSpan?.fg.equals(groupColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("adds a background to fragment labels", async () => {
|
||||
const noteBackgroundColor = parseColor("#24382F")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
alt ok
|
||||
Browser->>Server: request
|
||||
else no
|
||||
Server-->>Browser: response
|
||||
end`,
|
||||
noteBackgroundColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const altSpan = spans.find((span) => span.text.includes("alt: ok"))
|
||||
const elseSpan = spans.find((span) => span.text.includes("else: no"))
|
||||
|
||||
expect(altSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
expect(elseSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("prints ANSI colors for terminal dumps", () => {
|
||||
const output = renderSequenceDiagramAnsi(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: request
|
||||
Server-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(output).toContain("\x1b[38;2;134;225;200m")
|
||||
expect(output).toContain("\x1b[38;2;230;177;126m")
|
||||
expect(output).toContain("\x1b[38;2;115;153;138m")
|
||||
expect(output).toContain("\x1b[38;2;130;211;188m")
|
||||
expect(output).toContain("\x1b[38;2;131;145;126m")
|
||||
expect(output).toContain("\x1b[38;2;210;171;126m")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
expect(output).toContain("◀")
|
||||
expect(output).toContain("┤")
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { renderSequenceGridAnsi, renderSequenceGridText } from "./render-grid.js"
|
||||
import type { SequenceDiagramAnsiOptions, SequenceDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderSequenceDiagram(content: string, options: SequenceDiagramRenderOptions = {}): string {
|
||||
return renderSequenceGridText(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options))
|
||||
}
|
||||
|
||||
export function renderSequenceDiagramAnsi(content: string, options: SequenceDiagramAnsiOptions = {}): string {
|
||||
return renderSequenceGridAnsi(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { BorderChars, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas } from "../core/canvas.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
|
||||
import {
|
||||
createSequencePlacementPlan,
|
||||
type SequenceGroupPlacement,
|
||||
type SequenceHorizontalBounds,
|
||||
type SequenceStepPlacement,
|
||||
} from "./placement.js"
|
||||
import type { SequenceGrid } from "./render-grid.js"
|
||||
import { SEQUENCE_FADE_STEPS as FADE_STEPS } from "./style.js"
|
||||
import type {
|
||||
MessageStyle,
|
||||
SequenceArrowHead,
|
||||
SequenceCellStyle,
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
} from "./types.js"
|
||||
|
||||
const SEQUENCE_BORDER = BorderChars.rounded
|
||||
|
||||
function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1): string {
|
||||
switch (head) {
|
||||
case "open":
|
||||
return direction === 1 ? ">" : "<"
|
||||
case "cross":
|
||||
return "✕"
|
||||
case "async":
|
||||
return direction === 1 ? ")" : "("
|
||||
default:
|
||||
return direction === 1 ? "▶" : "◀"
|
||||
}
|
||||
}
|
||||
|
||||
function createGrid(width: number, height: number): SequenceGrid {
|
||||
return new DiagramCanvas(width, height)
|
||||
}
|
||||
|
||||
function setCell(grid: SequenceGrid, x: number, y: number, char: string, style?: SequenceCellStyle): void {
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(grid: SequenceGrid, x: number, y: number, text: string, style?: SequenceCellStyle): void {
|
||||
grid.setText(Math.max(0, x), y, text, style)
|
||||
}
|
||||
|
||||
function setArrowDepartureFade(
|
||||
grid: SequenceGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
direction: 1 | -1,
|
||||
style: SequenceCellStyle,
|
||||
): void {
|
||||
setCell(
|
||||
grid,
|
||||
x,
|
||||
y,
|
||||
direction === 1 ? SEQUENCE_BORDER.leftT : SEQUENCE_BORDER.rightT,
|
||||
`${style}Fade1` as SequenceCellStyle,
|
||||
)
|
||||
for (let step = 2; step <= 5; step++) {
|
||||
setCell(grid, x + direction * (step - 1), y, SEQUENCE_BORDER.horizontal, `${style}Fade${step}` as SequenceCellStyle)
|
||||
}
|
||||
}
|
||||
|
||||
function groupVerticalChar(existing: string | undefined): string | undefined {
|
||||
switch (existing) {
|
||||
case undefined:
|
||||
case " ":
|
||||
return SEQUENCE_BORDER.vertical
|
||||
case SEQUENCE_BORDER.vertical:
|
||||
return SEQUENCE_BORDER.vertical
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function setGroupVerticalCell(grid: SequenceGrid, x: number, y: number): void {
|
||||
const existing = grid.getCell(x, y)?.char
|
||||
const char = groupVerticalChar(existing)
|
||||
if (char) setCell(grid, x, y, char, "group")
|
||||
}
|
||||
|
||||
function renderParticipantGroups(
|
||||
grid: SequenceGrid,
|
||||
groupBounds: readonly SequenceGroupPlacement[],
|
||||
bottomY: number,
|
||||
): void {
|
||||
for (const bounds of groupBounds) {
|
||||
for (let x = bounds.leftX; x <= bounds.rightX; x++) {
|
||||
setCell(grid, x, 0, SEQUENCE_BORDER.horizontal, "group")
|
||||
setCell(grid, x, bottomY, SEQUENCE_BORDER.horizontal, "group")
|
||||
}
|
||||
|
||||
setCell(grid, bounds.leftX, 0, SEQUENCE_BORDER.topLeft, "group")
|
||||
setCell(grid, bounds.rightX, 0, SEQUENCE_BORDER.topRight, "group")
|
||||
setCell(grid, bounds.leftX, bottomY, SEQUENCE_BORDER.bottomLeft, "group")
|
||||
setCell(grid, bounds.rightX, bottomY, SEQUENCE_BORDER.bottomRight, "group")
|
||||
|
||||
for (let y = 1; y < bottomY; y++) {
|
||||
setGroupVerticalCell(grid, bounds.leftX, y)
|
||||
setGroupVerticalCell(grid, bounds.rightX, y)
|
||||
}
|
||||
|
||||
if (bounds.labelText) {
|
||||
setText(grid, bounds.leftX + 2, 0, bounds.labelText, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFragmentWalls(
|
||||
grid: SequenceGrid,
|
||||
bounds: SequenceHorizontalBounds,
|
||||
startY: number,
|
||||
endY: number,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
if (endY < startY) return
|
||||
const border = BorderChars[borderStyle]
|
||||
|
||||
for (let y = startY; y <= endY; y++) {
|
||||
setCell(grid, bounds.leftX, y, border.vertical, "fragment")
|
||||
setCell(grid, bounds.rightX, y, border.vertical, "fragment")
|
||||
}
|
||||
}
|
||||
|
||||
function renderFragment(
|
||||
grid: SequenceGrid,
|
||||
placement: Extract<SequenceStepPlacement, { type: "fragment" }>,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
const { bounds, fragment, labelText: label, y } = placement
|
||||
const border = BorderChars[borderStyle]
|
||||
const { leftX, rightX } = bounds
|
||||
|
||||
const leftChar =
|
||||
fragment.kind === "alt" || fragment.kind === "loop"
|
||||
? border.topLeft
|
||||
: fragment.kind === "else"
|
||||
? border.leftT
|
||||
: border.bottomLeft
|
||||
const rightChar =
|
||||
fragment.kind === "alt" || fragment.kind === "loop"
|
||||
? border.topRight
|
||||
: fragment.kind === "else"
|
||||
? border.rightT
|
||||
: border.bottomRight
|
||||
|
||||
for (let x = leftX; x <= rightX; x++) {
|
||||
setCell(grid, x, y, border.horizontal, "fragment")
|
||||
}
|
||||
|
||||
setCell(grid, leftX, y, leftChar, "fragment")
|
||||
setCell(grid, rightX, y, rightChar, "fragment")
|
||||
if (label) {
|
||||
setText(grid, leftX + 2, y, label, "fragmentLabel")
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelfMessage(
|
||||
grid: SequenceGrid,
|
||||
placement: Extract<SequenceStepPlacement, { type: "selfMessage" }>,
|
||||
style: MessageStyle,
|
||||
): void {
|
||||
const { centerX, rightX, topY: topRow, bottomY: bottomRow, labelLines, message } = placement
|
||||
|
||||
setArrowDepartureFade(grid, centerX, topRow, 1, style)
|
||||
for (let x = centerX + FADE_STEPS.length; x < rightX; x++) {
|
||||
setCell(grid, x, topRow, SEQUENCE_BORDER.horizontal, style)
|
||||
}
|
||||
setCell(grid, rightX, topRow, SEQUENCE_BORDER.topRight, style)
|
||||
|
||||
for (let lineIndex = 0; lineIndex < labelLines.length; lineIndex++) {
|
||||
const y = topRow + lineIndex + 1
|
||||
setCell(grid, centerX, y, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setText(grid, centerX + 2, y, labelLines[lineIndex]!, style)
|
||||
setCell(grid, rightX, y, SEQUENCE_BORDER.vertical, style)
|
||||
}
|
||||
|
||||
for (let x = centerX + 1; x < rightX; x++) {
|
||||
setCell(grid, x, bottomRow, SEQUENCE_BORDER.horizontal, style)
|
||||
}
|
||||
const headX = message.head === undefined ? centerX : centerX + 1
|
||||
setCell(grid, headX, bottomRow, arrowHeadChar(message.head, -1), style)
|
||||
setCell(grid, rightX, bottomRow, SEQUENCE_BORDER.bottomRight, style)
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
diagram: SequenceDiagram,
|
||||
options: SequenceDiagramRenderOptions = {},
|
||||
): SequenceGrid {
|
||||
const plan = createSequencePlacementPlan(diagram, options)
|
||||
if (plan.width === 0 || plan.height === 0) return createGrid(0, 0)
|
||||
const fragmentBorderStyle = options.fragmentBorderStyle ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
const grid = createGrid(plan.width, plan.height)
|
||||
|
||||
if (plan.groups.length > 0) renderParticipantGroups(grid, plan.groups, plan.height - 1)
|
||||
|
||||
for (const placement of plan.participants) {
|
||||
const { participant, centerX: center, headerLeftX, headerRightX, labelX } = placement
|
||||
const { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY } = plan.rows
|
||||
|
||||
if (options.compact) {
|
||||
setText(grid, labelX, participantHeaderY, participant.label, "participant")
|
||||
} else {
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "lifeline")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "lifeline")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "lifeline")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "lifeline")
|
||||
setCell(grid, headerLeftX, participantHeaderY, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setCell(grid, headerRightX, participantHeaderY, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "lifeline")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "lifeline")
|
||||
setText(grid, labelX, participantHeaderY, participant.label, "participant")
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "lifeline")
|
||||
}
|
||||
|
||||
for (let y = lifelineStartY; y <= lifelineEndY; y++) {
|
||||
setCell(grid, center, y, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
}
|
||||
}
|
||||
|
||||
for (const placement of plan.steps) {
|
||||
if (placement.type === "note") {
|
||||
setText(grid, placement.textX, placement.textY, placement.text, "noteBadge")
|
||||
continue
|
||||
}
|
||||
|
||||
if (placement.type === "fragment") {
|
||||
if (placement.wallsBefore) {
|
||||
drawFragmentWalls(
|
||||
grid,
|
||||
placement.wallsBefore.bounds,
|
||||
placement.wallsBefore.startY,
|
||||
placement.wallsBefore.endY,
|
||||
fragmentBorderStyle,
|
||||
)
|
||||
}
|
||||
renderFragment(grid, placement, fragmentBorderStyle)
|
||||
continue
|
||||
}
|
||||
|
||||
const message = placement.message
|
||||
const messageStyle: MessageStyle = message.style === "dashed" ? "response" : "request"
|
||||
|
||||
if (placement.type === "selfMessage") {
|
||||
renderSelfMessage(grid, placement, messageStyle)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!placement.inlineLabel) {
|
||||
for (let lineIndex = 0; lineIndex < placement.labelLines.length; lineIndex++) {
|
||||
setText(grid, placement.labelX, placement.labelY + lineIndex, placement.labelLines[lineIndex]!, messageStyle)
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = placement.leftX + 1; x < placement.rightX; x++) {
|
||||
setCell(grid, x, placement.arrowY, SEQUENCE_BORDER.horizontal, messageStyle)
|
||||
}
|
||||
|
||||
setArrowDepartureFade(grid, placement.fromX, placement.arrowY, placement.direction, messageStyle)
|
||||
setCell(grid, placement.headX, placement.arrowY, arrowHeadChar(message.head, placement.direction), messageStyle)
|
||||
if (placement.inlineLabel) setText(grid, placement.labelX, placement.labelY, placement.inlineLabel, messageStyle)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { renderSequenceDiagram, renderSequenceDiagramAnsi } from "./diagram.js"
|
||||
import type { SequenceDiagramAnsiOptions, SequenceDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export type {
|
||||
SequenceParticipant as Participant,
|
||||
SequenceParticipantGroup as ParticipantGroup,
|
||||
SequenceMessage as Message,
|
||||
SequenceArrowHead as ArrowHead,
|
||||
SequenceNote as Note,
|
||||
SequenceActivation as Activation,
|
||||
SequenceFragment as Fragment,
|
||||
SequenceStep as Step,
|
||||
SequenceDiagram as Diagram,
|
||||
SequenceDiagramRenderOptions as PlainRenderOptions,
|
||||
SequenceDiagramAnsiTheme as Theme,
|
||||
SequenceDiagramAnsiOptions as AnsiRenderOptions,
|
||||
SequenceDiagramOptions as RenderableOptions,
|
||||
} from "./types.js"
|
||||
|
||||
export { isMermaidSequenceDiagram as is, parseMermaidSequenceDiagram as parse } from "./parser.js"
|
||||
export { SequenceDiagramRenderable as Renderable } from "./renderable.js"
|
||||
|
||||
export interface RenderOptions extends SequenceDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid sequence diagram string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color
|
||||
? renderSequenceDiagramAnsi(content, rest)
|
||||
: renderSequenceDiagram(content, rest as SequenceDiagramRenderOptions)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export const DEFAULT_MIN_PARTICIPANT_GAP = 18
|
||||
export const DEFAULT_FRAGMENT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
|
||||
export function normalizeSequenceMinParticipantGap(value: number | undefined): number {
|
||||
return value === undefined || !Number.isFinite(value) ? DEFAULT_MIN_PARTICIPANT_GAP : Math.max(1, Math.floor(value))
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import type {
|
||||
SequenceArrowHead,
|
||||
SequenceDiagram,
|
||||
SequenceMessage,
|
||||
SequenceParticipant,
|
||||
SequenceParticipantGroup,
|
||||
SequenceStep,
|
||||
} from "./types.js"
|
||||
|
||||
const MESSAGE_RE = /^(.+?)\s*(-->>|->>|--x|-x|--\)|-\)|-->|->)([+-]?)\s*(.+?)\s*:\s*(.*)$/
|
||||
const NOTE_RE = /^note\s+(over|left\s+of|right\s+of)\s+(.+?)\s*:\s*(.*)$/i
|
||||
const PARTICIPANT_RE = /^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?$/i
|
||||
const ACTIVATION_RE = /^(activate|deactivate)\s+(.+)$/i
|
||||
const BOX_RE = /^box(?:\s+(.+))?$/i
|
||||
const ALT_RE = /^alt\s+(.+)$/i
|
||||
const ELSE_RE = /^else(?:\s+(.+))?$/i
|
||||
const LOOP_RE = /^loop\s+(.+)$/i
|
||||
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
|
||||
const CSS_COLOR_NAMES = new Set([
|
||||
"black",
|
||||
"white",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yellow",
|
||||
"cyan",
|
||||
"magenta",
|
||||
"silver",
|
||||
"gray",
|
||||
"grey",
|
||||
"maroon",
|
||||
"olive",
|
||||
"lime",
|
||||
"aqua",
|
||||
"teal",
|
||||
"navy",
|
||||
"fuchsia",
|
||||
"purple",
|
||||
"orange",
|
||||
"brightblack",
|
||||
"brightred",
|
||||
"brightgreen",
|
||||
"brightblue",
|
||||
"brightyellow",
|
||||
"brightcyan",
|
||||
"brightmagenta",
|
||||
"brightwhite",
|
||||
])
|
||||
|
||||
function arrowHeadForSyntax(arrow: string): SequenceArrowHead | undefined {
|
||||
if (arrow.endsWith("x")) return "cross"
|
||||
if (arrow.endsWith(")")) return "async"
|
||||
if (arrow.endsWith(">") && !arrow.endsWith(">>")) return "open"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isBoxColorToken(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase()
|
||||
return (
|
||||
lowerValue === "transparent" ||
|
||||
CSS_COLOR_NAMES.has(lowerValue) ||
|
||||
/^#[0-9a-f]{3,8}$/i.test(value) ||
|
||||
/^rgba?\(.+\)$/i.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function splitLeadingBoxToken(value: string): { token: string; rest: string } {
|
||||
if (/^rgba?\(/i.test(value)) {
|
||||
const closeIndex = value.indexOf(")")
|
||||
if (closeIndex >= 0) {
|
||||
return { token: value.slice(0, closeIndex + 1), rest: value.slice(closeIndex + 1).trim() }
|
||||
}
|
||||
}
|
||||
|
||||
const firstSpace = value.search(/\s/)
|
||||
return firstSpace < 0
|
||||
? { token: value, rest: "" }
|
||||
: { token: value.slice(0, firstSpace), rest: value.slice(firstSpace + 1).trim() }
|
||||
}
|
||||
|
||||
function boxLabelText(value: string | undefined): string {
|
||||
const rawLabel = (value ?? "").trim()
|
||||
if ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) || (rawLabel.startsWith("'") && rawLabel.endsWith("'"))) {
|
||||
return stripQuotes(rawLabel)
|
||||
}
|
||||
|
||||
const label = stripQuotes(rawLabel)
|
||||
if (!label) return ""
|
||||
const { token, rest } = splitLeadingBoxToken(label)
|
||||
return isBoxColorToken(token) ? stripQuotes(rest) : label
|
||||
}
|
||||
|
||||
function addParticipantToGroup(group: SequenceParticipantGroup | undefined, participantId: string): void {
|
||||
if (!group || group.participantIds.includes(participantId)) return
|
||||
group.participantIds.push(participantId)
|
||||
}
|
||||
|
||||
function ensureParticipant(
|
||||
participants: SequenceParticipant[],
|
||||
id: string,
|
||||
label: string = id,
|
||||
replaceExistingLabel: boolean = false,
|
||||
): void {
|
||||
const existing = participants.find((participant) => participant.id === id)
|
||||
if (existing) {
|
||||
if (replaceExistingLabel) existing.label = label
|
||||
return
|
||||
}
|
||||
participants.push({ id, label })
|
||||
}
|
||||
|
||||
export function isMermaidSequenceDiagram(content: string): boolean {
|
||||
return firstMeaningfulMermaidLine(content)?.toLowerCase() === "sequencediagram"
|
||||
}
|
||||
|
||||
export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
|
||||
const participants: SequenceParticipant[] = []
|
||||
const messages: SequenceMessage[] = []
|
||||
const steps: SequenceStep[] = []
|
||||
const groups: SequenceParticipantGroup[] = []
|
||||
const blockStack: Array<{ kind: "box" | "alt" | "loop"; lineNumber: number; sourceLine: string }> = []
|
||||
const groupStack: SequenceParticipantGroup[] = []
|
||||
let nextMessageNumber: number | undefined
|
||||
let messageNumberIncrement = 1
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (line.toLowerCase() === "sequencediagram") continue
|
||||
|
||||
const autonumberMatch = line.match(AUTONUMBER_RE)
|
||||
if (autonumberMatch) {
|
||||
nextMessageNumber = Number.parseInt(autonumberMatch[1] ?? "1", 10)
|
||||
messageNumberIncrement = Number.parseInt(autonumberMatch[2] ?? "1", 10)
|
||||
continue
|
||||
}
|
||||
|
||||
const boxMatch = line.match(BOX_RE)
|
||||
if (boxMatch) {
|
||||
const group: SequenceParticipantGroup = { label: boxLabelText(boxMatch[1]), participantIds: [] }
|
||||
groups.push(group)
|
||||
groupStack.push(group)
|
||||
blockStack.push({ kind: "box", lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
const participantMatch = line.match(PARTICIPANT_RE)
|
||||
if (participantMatch) {
|
||||
const id = stripQuotes(participantMatch[1]!)
|
||||
ensureParticipant(participants, id, stripQuotes(participantMatch[2] ?? id), true)
|
||||
addParticipantToGroup(groupStack[groupStack.length - 1], id)
|
||||
continue
|
||||
}
|
||||
|
||||
const noteMatch = line.match(NOTE_RE)
|
||||
if (noteMatch) {
|
||||
const position = noteMatch[1]!.toLowerCase().replace(/\s+of$/, "") as "over" | "left" | "right"
|
||||
const over = noteMatch[2]!
|
||||
.split(",")
|
||||
.map((participant) => stripQuotes(participant))
|
||||
.filter((participant) => participant.length > 0)
|
||||
for (const participant of over) ensureParticipant(participants, participant)
|
||||
steps.push({
|
||||
type: "note",
|
||||
note: {
|
||||
over,
|
||||
label: stripQuotes(noteMatch[3]!),
|
||||
...(position === "over" ? {} : { position }),
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const activationMatch = line.match(ACTIVATION_RE)
|
||||
if (activationMatch) {
|
||||
const participant = stripQuotes(activationMatch[2]!)
|
||||
ensureParticipant(participants, participant)
|
||||
steps.push({
|
||||
type: "activation",
|
||||
activation: { participant, active: activationMatch[1]!.toLowerCase() === "activate" },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const altMatch = line.match(ALT_RE)
|
||||
if (altMatch) {
|
||||
blockStack.push({ kind: "alt", lineNumber: source.lineNumber, sourceLine: line })
|
||||
steps.push({ type: "fragment", fragment: { kind: "alt", label: stripQuotes(altMatch[1]!) } })
|
||||
continue
|
||||
}
|
||||
|
||||
const loopMatch = line.match(LOOP_RE)
|
||||
if (loopMatch) {
|
||||
blockStack.push({ kind: "loop", lineNumber: source.lineNumber, sourceLine: line })
|
||||
steps.push({ type: "fragment", fragment: { kind: "loop", label: stripQuotes(loopMatch[1]!) } })
|
||||
continue
|
||||
}
|
||||
|
||||
const elseMatch = line.match(ELSE_RE)
|
||||
if (elseMatch) {
|
||||
if (blockStack[blockStack.length - 1]?.kind !== "alt") {
|
||||
throw new MermaidSyntaxError(
|
||||
"sequence",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'Unexpected "else" without an open "alt" block',
|
||||
)
|
||||
}
|
||||
steps.push({ type: "fragment", fragment: { kind: "else", label: stripQuotes(elseMatch[1] ?? "") } })
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.toLowerCase() === "end") {
|
||||
const block = blockStack.pop()
|
||||
if (!block)
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line, 'Unexpected "end" without an open block')
|
||||
if (block.kind === "box") {
|
||||
groupStack.pop()
|
||||
continue
|
||||
}
|
||||
steps.push({ type: "fragment", fragment: { kind: "end", label: block.kind } })
|
||||
continue
|
||||
}
|
||||
|
||||
const messageMatch = line.match(MESSAGE_RE)
|
||||
if (messageMatch) {
|
||||
const from = stripQuotes(messageMatch[1]!)
|
||||
const arrow = messageMatch[2]!
|
||||
const activationMarker = messageMatch[3]!
|
||||
const to = stripQuotes(messageMatch[4]!)
|
||||
const message: SequenceMessage = {
|
||||
from,
|
||||
to,
|
||||
label: stripQuotes(messageMatch[5]!),
|
||||
style: arrow.startsWith("--") ? "dashed" : "solid",
|
||||
}
|
||||
const activeGroup = groupStack[groupStack.length - 1]
|
||||
ensureParticipant(participants, from)
|
||||
ensureParticipant(participants, to)
|
||||
addParticipantToGroup(activeGroup, from)
|
||||
addParticipantToGroup(activeGroup, to)
|
||||
const head = arrowHeadForSyntax(arrow)
|
||||
if (head) message.head = head
|
||||
if (nextMessageNumber !== undefined) {
|
||||
message.number = nextMessageNumber
|
||||
nextMessageNumber += messageNumberIncrement
|
||||
}
|
||||
if (activationMarker === "+") message.activate = to
|
||||
else if (activationMarker === "-") message.deactivate = from
|
||||
messages.push(message)
|
||||
steps.push({ type: "message", message })
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedBlock = blockStack[blockStack.length - 1]
|
||||
if (unclosedBlock) {
|
||||
throw new MermaidSyntaxError(
|
||||
"sequence",
|
||||
unclosedBlock.lineNumber,
|
||||
unclosedBlock.sourceLine,
|
||||
`Unclosed ${unclosedBlock.kind} block; expected "end"`,
|
||||
)
|
||||
}
|
||||
|
||||
return { participants, messages, steps, groups }
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { createSequencePlacementPlan } from "./placement.js"
|
||||
|
||||
describe("createSequencePlacementPlan", () => {
|
||||
test("expands one fragment frame for a longer else label", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`),
|
||||
)
|
||||
const fragments = plan.steps.filter((step) => step.type === "fragment")
|
||||
|
||||
expect(fragments).toHaveLength(3)
|
||||
expect(fragments.map((fragment) => fragment.bounds.rightX)).toEqual([
|
||||
fragments[0]!.bounds.rightX,
|
||||
fragments[0]!.bounds.rightX,
|
||||
fragments[0]!.bounds.rightX,
|
||||
])
|
||||
expect(fragments[1]!.labelText).toContain("validation failed")
|
||||
})
|
||||
|
||||
test("includes non-adjacent message and note labels within planned width", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
const note = plan.steps.find((step) => step.type === "note")!
|
||||
|
||||
const messageWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
expect(message.labelX + messageWidth).toBeLessThanOrEqual(plan.width)
|
||||
expect(note.textX + diagramTextWidth(note.text)).toBeLessThanOrEqual(plan.width)
|
||||
})
|
||||
|
||||
test("keeps side notes clear of adjacent participant lifelines", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note right of A: a long note between the first two participants
|
||||
Note left of C: another long note between the final participants`),
|
||||
)
|
||||
const notes = plan.steps.filter((step) => step.type === "note")
|
||||
|
||||
expect(notes[0]!.textX + diagramTextWidth(notes[0]!.text)).toBeLessThan(plan.participants[1]!.centerX)
|
||||
expect(notes[1]!.textX).toBeGreaterThan(plan.participants[1]!.centerX)
|
||||
})
|
||||
|
||||
test("allocates group space around a contained self-message loop", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Backend
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
end`),
|
||||
)
|
||||
const group = plan.groups[0]!
|
||||
const message = plan.steps.find((step) => step.type === "selfMessage")!
|
||||
|
||||
expect(group.leftX).toBeGreaterThanOrEqual(0)
|
||||
expect(group.rightX).toBeGreaterThanOrEqual(message.rightX + 2)
|
||||
expect(plan.width).toBeGreaterThan(group.rightX)
|
||||
})
|
||||
|
||||
test("keeps external participants outside a group expanded by internal content", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`),
|
||||
)
|
||||
const group = plan.groups[0]!
|
||||
const external = plan.participants.find((participant) => participant.participant.id === "B")!
|
||||
|
||||
expect(external.headerLeftX).toBeGreaterThan(group.rightX)
|
||||
})
|
||||
|
||||
test("expands group and fragment frames around contained long content", () => {
|
||||
const groupPlan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`),
|
||||
)
|
||||
const group = groupPlan.groups[0]!
|
||||
const groupedMessage = groupPlan.steps.find((step) => step.type === "message")!
|
||||
const groupedMessageRight = groupedMessage.labelX + Math.max(...groupedMessage.labelLines.map(diagramTextWidth)) - 1
|
||||
|
||||
expect(group.rightX).toBeGreaterThan(groupedMessageRight)
|
||||
|
||||
const fragmentPlan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`),
|
||||
)
|
||||
const fragment = fragmentPlan.steps
|
||||
.filter((step) => step.type === "fragment")
|
||||
.find((step) => step.fragment.kind === "alt")!
|
||||
const fragmentMessage = fragmentPlan.steps.find((step) => step.type === "message")!
|
||||
const fragmentMessageRight =
|
||||
fragmentMessage.labelX + Math.max(...fragmentMessage.labelLines.map(diagramTextWidth)) - 1
|
||||
|
||||
expect(fragment.bounds.rightX).toBeGreaterThan(fragmentMessageRight)
|
||||
})
|
||||
|
||||
test("preserves nesting inset when a child fragment has a wide heading", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`),
|
||||
)
|
||||
const starts = plan.steps
|
||||
.filter((step) => step.type === "fragment")
|
||||
.filter((step) => step.fragment.kind === "alt" || step.fragment.kind === "loop")
|
||||
|
||||
expect(starts[0]!.bounds.rightX).toBeGreaterThan(starts[1]!.bounds.rightX)
|
||||
})
|
||||
})
|
||||
@@ -1,625 +0,0 @@
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { normalizeSequenceMinParticipantGap } from "./options.js"
|
||||
import type {
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
SequenceFragment,
|
||||
SequenceMessage,
|
||||
SequenceNote,
|
||||
SequenceParticipant,
|
||||
SequenceParticipantGroup,
|
||||
SequenceStep,
|
||||
} from "./types.js"
|
||||
|
||||
const NOTE_HORIZONTAL_PADDING = 1
|
||||
const GROUP_HORIZONTAL_PADDING = 2
|
||||
const FRAGMENT_HORIZONTAL_OVERHANG = 3
|
||||
|
||||
export interface SequenceHorizontalBounds {
|
||||
leftX: number
|
||||
rightX: number
|
||||
}
|
||||
|
||||
export interface SequenceParticipantPlacement {
|
||||
participant: SequenceParticipant
|
||||
centerX: number
|
||||
headerLeftX: number
|
||||
headerRightX: number
|
||||
labelX: number
|
||||
}
|
||||
|
||||
export interface SequenceGroupPlacement {
|
||||
group: SequenceParticipantGroup
|
||||
labelText: string
|
||||
leftX: number
|
||||
rightX: number
|
||||
}
|
||||
|
||||
export interface SequenceWallPlacement {
|
||||
bounds: SequenceHorizontalBounds
|
||||
startY: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export type SequenceStepPlacement =
|
||||
| { type: "note"; note: SequenceNote; text: string; textX: number; textY: number }
|
||||
| {
|
||||
type: "fragment"
|
||||
fragment: SequenceFragment
|
||||
labelText: string
|
||||
bounds: SequenceHorizontalBounds
|
||||
y: number
|
||||
wallsBefore?: SequenceWallPlacement
|
||||
}
|
||||
| {
|
||||
type: "message"
|
||||
message: SequenceMessage
|
||||
labelLines: string[]
|
||||
labelX: number
|
||||
labelY: number
|
||||
arrowY: number
|
||||
fromX: number
|
||||
toX: number
|
||||
leftX: number
|
||||
rightX: number
|
||||
headX: number
|
||||
direction: 1 | -1
|
||||
inlineLabel?: string
|
||||
}
|
||||
| {
|
||||
type: "selfMessage"
|
||||
message: SequenceMessage
|
||||
labelLines: string[]
|
||||
centerX: number
|
||||
rightX: number
|
||||
topY: number
|
||||
bottomY: number
|
||||
}
|
||||
|
||||
export interface SequencePlacementPlan {
|
||||
width: number
|
||||
height: number
|
||||
rows: {
|
||||
participantHeaderTopY: number
|
||||
participantHeaderY: number
|
||||
participantRuleY: number
|
||||
lifelineStartY: number
|
||||
lifelineEndY: number
|
||||
}
|
||||
participants: SequenceParticipantPlacement[]
|
||||
groups: SequenceGroupPlacement[]
|
||||
steps: SequenceStepPlacement[]
|
||||
}
|
||||
|
||||
interface SequenceGroupRange {
|
||||
group: SequenceParticipantGroup
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
interface PendingFragmentFrame {
|
||||
startIndex: number
|
||||
bounds: SequenceHorizontalBounds
|
||||
}
|
||||
|
||||
interface ActiveFragmentFrame {
|
||||
bounds: SequenceHorizontalBounds
|
||||
boundaryY: number
|
||||
}
|
||||
|
||||
function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
function centeredStart(center: number, text: string): number {
|
||||
return center - Math.floor(visualLength(text) / 2)
|
||||
}
|
||||
|
||||
function noteLabelText(label: string): string {
|
||||
const padding = " ".repeat(NOTE_HORIZONTAL_PADDING)
|
||||
return `${padding}${label}${padding}`
|
||||
}
|
||||
|
||||
function messageLabelText(message: SequenceMessage): string {
|
||||
return message.number === undefined ? message.label : `${message.number}. ${message.label}`
|
||||
}
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
return compact ? visualLength(label) : Math.max(5, visualLength(label) + 4)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
if (fragment.kind === "end") return ""
|
||||
const prefix = fragment.kind === "loop" ? "↻ loop" : fragment.kind
|
||||
return ` ${prefix}: ${fragment.label} `
|
||||
}
|
||||
|
||||
function messageLabelLines(label: string): string[] {
|
||||
const lines = label.split(/(?:<br\s*\/?\s*>|\\n)/i).map((line) => line.trimEnd())
|
||||
return lines.length > 0 ? lines : [""]
|
||||
}
|
||||
|
||||
function labelLinesWidth(lines: string[]): number {
|
||||
return lines.reduce((max, line) => Math.max(max, visualLength(line)), 0)
|
||||
}
|
||||
|
||||
function messageWidth(message: SequenceMessage): number {
|
||||
return labelLinesWidth(messageLabelLines(messageLabelText(message)))
|
||||
}
|
||||
|
||||
function selfMessageLoopWidthForLines(labelLines: string[]): number {
|
||||
return Math.max(10, labelLinesWidth(labelLines) + 4)
|
||||
}
|
||||
|
||||
function selfMessageLoopWidth(message: SequenceMessage): number {
|
||||
return selfMessageLoopWidthForLines(messageLabelLines(messageLabelText(message)))
|
||||
}
|
||||
|
||||
function arrowHeadX(toX: number, direction: 1 | -1, head: SequenceMessage["head"]): number {
|
||||
return head === undefined ? toX : toX - direction
|
||||
}
|
||||
|
||||
function inlineMessageLabel(
|
||||
message: SequenceMessage,
|
||||
labelLines: string[],
|
||||
fromX: number,
|
||||
toX: number,
|
||||
compact: boolean,
|
||||
): string | undefined {
|
||||
if (!compact || message.from === message.to || labelLines.length !== 1) return undefined
|
||||
const label = ` ${labelLines[0]} `
|
||||
return visualLength(label) <= Math.abs(toX - fromX) - 3 ? label : undefined
|
||||
}
|
||||
|
||||
function getStepHeight(
|
||||
step: SequenceStep,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
compact: boolean,
|
||||
): number {
|
||||
if (step.type === "note") return 3
|
||||
if (step.type === "activation") return 0
|
||||
if (step.type === "fragment") return 2
|
||||
const labelLines = messageLabelLines(messageLabelText(step.message))
|
||||
const fromX = centers[participantIndexes.get(step.message.from) ?? -1]
|
||||
const toX = centers[participantIndexes.get(step.message.to) ?? -1]
|
||||
if (fromX !== undefined && toX !== undefined && inlineMessageLabel(step.message, labelLines, fromX, toX, compact)) {
|
||||
return 2
|
||||
}
|
||||
return labelLines.length + (step.message.from === step.message.to ? 3 : 2)
|
||||
}
|
||||
|
||||
function createParticipantIndexMap(diagram: SequenceDiagram): Map<string, number> {
|
||||
return new Map(diagram.participants.map((participant, index) => [participant.id, index]))
|
||||
}
|
||||
|
||||
function getParticipantIndexes(participantIndexes: Map<string, number>, participantIds: string[]): number[] {
|
||||
return participantIds.map((id) => participantIndexes.get(id) ?? -1).filter((index) => index >= 0)
|
||||
}
|
||||
|
||||
function groupLabelText(group: SequenceParticipantGroup): string {
|
||||
return group.label ? ` ${group.label} ` : ""
|
||||
}
|
||||
|
||||
function getGroupRanges(diagram: SequenceDiagram, participantIndexes: Map<string, number>): SequenceGroupRange[] {
|
||||
return diagram.groups.flatMap((group) => {
|
||||
const indexes = getParticipantIndexes(participantIndexes, group.participantIds)
|
||||
return indexes.length === 0 ? [] : [{ group, startIndex: Math.min(...indexes), endIndex: Math.max(...indexes) }]
|
||||
})
|
||||
}
|
||||
|
||||
function getStepParticipantIndexes(step: SequenceStep, participantIndexes: Map<string, number>): number[] {
|
||||
if (step.type === "message") {
|
||||
return getParticipantIndexes(participantIndexes, [step.message.from, step.message.to])
|
||||
}
|
||||
if (step.type === "note") return getParticipantIndexes(participantIndexes, step.note.over)
|
||||
return []
|
||||
}
|
||||
|
||||
function getStepContentBounds(
|
||||
step: SequenceStep,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
): SequenceHorizontalBounds | undefined {
|
||||
if (step.type === "message") {
|
||||
const fromIndex = participantIndexes.get(step.message.from) ?? -1
|
||||
const toIndex = participantIndexes.get(step.message.to) ?? -1
|
||||
if (fromIndex < 0 || toIndex < 0) return undefined
|
||||
const fromX = centers[fromIndex]!
|
||||
const toX = centers[toIndex]!
|
||||
if (fromIndex === toIndex) return { leftX: fromX, rightX: fromX + selfMessageLoopWidth(step.message) }
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
return { leftX, rightX: Math.max(rightX, leftX + 2 + messageWidth(step.message) - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
|
||||
if (indexes.length === 0) return undefined
|
||||
const centerX = Math.floor((centers[Math.min(...indexes)]! + centers[Math.max(...indexes)]!) / 2)
|
||||
const text = noteLabelText(step.note.label)
|
||||
const leftX =
|
||||
step.note.position === "left"
|
||||
? centerX - visualLength(text) - 2
|
||||
: step.note.position === "right"
|
||||
? centerX + 2
|
||||
: centeredStart(centerX, text)
|
||||
return { leftX, rightX: leftX + visualLength(text) - 1 }
|
||||
}
|
||||
|
||||
function rangeContainsIndexes(range: SequenceGroupRange, indexes: readonly number[]): boolean {
|
||||
return indexes.length > 0 && indexes.every((index) => index >= range.startIndex && index <= range.endIndex)
|
||||
}
|
||||
|
||||
function resolveGroupBounds(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
groupRanges: SequenceGroupRange[],
|
||||
compact: boolean,
|
||||
): SequenceGroupPlacement[] {
|
||||
return groupRanges.map((range) => {
|
||||
let contentLeftX = centers[range.startIndex]!
|
||||
let contentRightX = centers[range.endIndex]!
|
||||
for (let i = range.startIndex; i <= range.endIndex; i++) {
|
||||
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
|
||||
const headerStartX = centers[i]! - Math.floor(headerWidth / 2)
|
||||
contentLeftX = Math.min(contentLeftX, headerStartX)
|
||||
contentRightX = Math.max(contentRightX, headerStartX + headerWidth - 1)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
const indexes = getStepParticipantIndexes(step, participantIndexes)
|
||||
if (!rangeContainsIndexes(range, indexes)) continue
|
||||
const bounds = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (bounds) {
|
||||
contentLeftX = Math.min(contentLeftX, bounds.leftX)
|
||||
contentRightX = Math.max(contentRightX, bounds.rightX)
|
||||
}
|
||||
}
|
||||
const labelText = groupLabelText(range.group)
|
||||
let leftX = contentLeftX - GROUP_HORIZONTAL_PADDING
|
||||
let rightX = contentRightX + GROUP_HORIZONTAL_PADDING
|
||||
const extraWidth = Math.max(0, visualLength(labelText) + 4 - (rightX - leftX + 1))
|
||||
leftX -= Math.floor(extraWidth / 2)
|
||||
rightX += Math.ceil(extraWidth / 2)
|
||||
return { group: range.group, labelText, leftX, rightX }
|
||||
})
|
||||
}
|
||||
|
||||
function expandHorizontalBounds(bounds: SequenceHorizontalBounds, leftX: number, rightX: number): void {
|
||||
bounds.leftX = Math.min(bounds.leftX, leftX)
|
||||
bounds.rightX = Math.max(bounds.rightX, rightX)
|
||||
}
|
||||
|
||||
function getDiagramContentBounds(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
compact: boolean,
|
||||
): SequenceHorizontalBounds {
|
||||
const bounds = { leftX: 0, rightX: 0 }
|
||||
for (let i = 0; i < diagram.participants.length; i++) {
|
||||
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
|
||||
const labelStartX = centers[i]! - Math.floor(headerWidth / 2)
|
||||
expandHorizontalBounds(bounds, labelStartX, labelStartX + headerWidth - 1)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
const content = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (content) expandHorizontalBounds(bounds, content.leftX, content.rightX)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function getFragmentFrameBounds(
|
||||
centers: number[],
|
||||
fragment: SequenceFragment,
|
||||
nestingDepth = 0,
|
||||
): SequenceHorizontalBounds | undefined {
|
||||
const leftParticipantX = centers[0]
|
||||
const rightParticipantX = centers[centers.length - 1]
|
||||
if (leftParticipantX === undefined || rightParticipantX === undefined) return undefined
|
||||
const leftX = leftParticipantX - FRAGMENT_HORIZONTAL_OVERHANG + nestingDepth
|
||||
const participantRightX = rightParticipantX + FRAGMENT_HORIZONTAL_OVERHANG - nestingDepth
|
||||
return { leftX, rightX: Math.max(participantRightX, leftX + 2 + visualLength(fragmentLabelText(fragment)) + 1) }
|
||||
}
|
||||
|
||||
function getFragmentFrameBoundsByStep(
|
||||
centers: number[],
|
||||
steps: SequenceStep[],
|
||||
participantIndexes: Map<string, number>,
|
||||
): Map<number, SequenceHorizontalBounds> {
|
||||
const boundsByStep = new Map<number, SequenceHorizontalBounds>()
|
||||
const activeFrames: PendingFragmentFrame[] = []
|
||||
for (const [index, step] of steps.entries()) {
|
||||
if (step.type !== "fragment") {
|
||||
const content = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (content) {
|
||||
for (const frame of activeFrames) {
|
||||
expandHorizontalBounds(frame.bounds, content.leftX - 1, content.rightX + 1)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const bounds = getFragmentFrameBounds(centers, step.fragment, activeFrames.length)
|
||||
if (!bounds) continue
|
||||
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
|
||||
activeFrames.push({ startIndex: index, bounds: { ...bounds } })
|
||||
continue
|
||||
}
|
||||
const frame = activeFrames[activeFrames.length - 1]
|
||||
if (!frame) continue
|
||||
expandHorizontalBounds(frame.bounds, bounds.leftX, bounds.rightX)
|
||||
if (step.fragment.kind !== "end") continue
|
||||
activeFrames.pop()
|
||||
boundsByStep.set(frame.startIndex, frame.bounds)
|
||||
const parent = activeFrames[activeFrames.length - 1]
|
||||
if (parent) expandHorizontalBounds(parent.bounds, frame.bounds.leftX - 1, frame.bounds.rightX + 1)
|
||||
}
|
||||
for (const frame of activeFrames) boundsByStep.set(frame.startIndex, frame.bounds)
|
||||
return boundsByStep
|
||||
}
|
||||
|
||||
function resolveParticipantCenters(
|
||||
diagram: SequenceDiagram,
|
||||
participantIndexes: Map<string, number>,
|
||||
minParticipantGap: number,
|
||||
compact: boolean,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, diagram.participants.length - 1) }, (_, index) => {
|
||||
const left = diagram.participants[index]!
|
||||
const right = diagram.participants[index + 1]!
|
||||
return Math.max(
|
||||
minParticipantGap,
|
||||
Math.ceil(participantHeaderWidth(left.label, compact) / 2) +
|
||||
Math.ceil(participantHeaderWidth(right.label, compact) / 2) +
|
||||
6,
|
||||
)
|
||||
})
|
||||
for (const message of diagram.messages) {
|
||||
const fromIndex = participantIndexes.get(message.from) ?? -1
|
||||
const toIndex = participantIndexes.get(message.to) ?? -1
|
||||
if (fromIndex === toIndex && fromIndex >= 0 && fromIndex < diagram.participants.length - 1) {
|
||||
gaps[fromIndex] = Math.max(
|
||||
gaps[fromIndex]!,
|
||||
selfMessageLoopWidth(message) + Math.ceil(visualLength(diagram.participants[fromIndex + 1]!.label) / 2) + 2,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (fromIndex < 0 || toIndex < 0 || Math.abs(fromIndex - toIndex) !== 1) continue
|
||||
const gapIndex = Math.min(fromIndex, toIndex)
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, messageWidth(message) + 6)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
if (step.type !== "note") continue
|
||||
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
|
||||
if (indexes.length === 1 && step.note.position && step.note.position !== "over") {
|
||||
const participantIndex = indexes[0]!
|
||||
const gapIndex = step.note.position === "left" ? participantIndex - 1 : participantIndex
|
||||
if (gapIndex >= 0 && gapIndex < gaps.length) {
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, visualLength(noteLabelText(step.note.label)) + 4)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (indexes.length !== 2 || Math.abs(indexes[0]! - indexes[1]!) !== 1) continue
|
||||
const gapIndex = Math.min(indexes[0]!, indexes[1]!)
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, visualLength(noteLabelText(step.note.label)) + 4)
|
||||
}
|
||||
const centers = [Math.max(1, Math.floor(participantHeaderWidth(diagram.participants[0]?.label ?? "", compact) / 2))]
|
||||
for (let i = 1; i < diagram.participants.length; i++) centers[i] = centers[i - 1]! + gaps[i - 1]!
|
||||
return centers
|
||||
}
|
||||
|
||||
function separateExpandedGroupsFromExternalParticipants(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
ranges: SequenceGroupRange[],
|
||||
compact: boolean,
|
||||
): number[] {
|
||||
const adjusted = [...centers]
|
||||
for (let pass = 0; pass < Math.max(1, ranges.length * 2); pass++) {
|
||||
let changed = false
|
||||
const groups = resolveGroupBounds(diagram, adjusted, participantIndexes, ranges, compact)
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
const group = groups[index]!
|
||||
if (range.startIndex > 0) {
|
||||
const previousIndex = range.startIndex - 1
|
||||
const previousWidth = participantHeaderWidth(diagram.participants[previousIndex]!.label, compact)
|
||||
const previousRight = adjusted[previousIndex]! - Math.floor(previousWidth / 2) + previousWidth - 1
|
||||
const shift = previousRight + GROUP_HORIZONTAL_PADDING + 1 - group.leftX
|
||||
if (shift > 0) {
|
||||
for (let participantIndex = range.startIndex; participantIndex < adjusted.length; participantIndex++) {
|
||||
adjusted[participantIndex]! += shift
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (range.endIndex < diagram.participants.length - 1) {
|
||||
const nextIndex = range.endIndex + 1
|
||||
const nextWidth = participantHeaderWidth(diagram.participants[nextIndex]!.label, compact)
|
||||
const nextLeft = adjusted[nextIndex]! - Math.floor(nextWidth / 2)
|
||||
const shift = group.rightX + GROUP_HORIZONTAL_PADDING + 1 - nextLeft
|
||||
if (shift > 0) {
|
||||
for (let participantIndex = nextIndex; participantIndex < adjusted.length; participantIndex++) {
|
||||
adjusted[participantIndex]! += shift
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) return adjusted
|
||||
}
|
||||
return adjusted
|
||||
}
|
||||
|
||||
export function createSequencePlacementPlan(
|
||||
diagram: SequenceDiagram,
|
||||
options: Pick<SequenceDiagramRenderOptions, "compact" | "minParticipantGap"> = {},
|
||||
): SequencePlacementPlan {
|
||||
if (diagram.participants.length === 0) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
rows: {
|
||||
participantHeaderTopY: 0,
|
||||
participantHeaderY: 0,
|
||||
participantRuleY: 0,
|
||||
lifelineStartY: 0,
|
||||
lifelineEndY: 0,
|
||||
},
|
||||
participants: [],
|
||||
groups: [],
|
||||
steps: [],
|
||||
}
|
||||
}
|
||||
const indexes = createParticipantIndexMap(diagram)
|
||||
const compact = options.compact ?? false
|
||||
let centers = resolveParticipantCenters(
|
||||
diagram,
|
||||
indexes,
|
||||
normalizeSequenceMinParticipantGap(options.minParticipantGap),
|
||||
compact,
|
||||
)
|
||||
const ranges = getGroupRanges(diagram, indexes)
|
||||
centers = separateExpandedGroupsFromExternalParticipants(diagram, centers, indexes, ranges, compact)
|
||||
let groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
|
||||
let contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
|
||||
let frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
|
||||
const fragmentBounds = (): SequenceHorizontalBounds => {
|
||||
const result = { leftX: 0, rightX: 0 }
|
||||
for (const bounds of frameBounds.values()) expandHorizontalBounds(result, bounds.leftX, bounds.rightX)
|
||||
return result
|
||||
}
|
||||
let fragments = fragmentBounds()
|
||||
const leftOverflow = Math.min(
|
||||
groups.reduce((left, group) => Math.min(left, group.leftX), 0),
|
||||
contentBounds.leftX,
|
||||
fragments.leftX,
|
||||
0,
|
||||
)
|
||||
if (leftOverflow < 0) {
|
||||
centers = centers.map((center) => center - leftOverflow)
|
||||
groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
|
||||
contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
|
||||
frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
|
||||
fragments = fragmentBounds()
|
||||
}
|
||||
const hasGroups = groups.length > 0
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? 0 : 2)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
const baseHeight =
|
||||
stepStartY + diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0)
|
||||
const height = hasGroups ? Math.max(5, baseHeight + 1) : Math.max(3, baseHeight)
|
||||
const lifelineEndY = hasGroups ? height - 2 : height - 1
|
||||
const participants = diagram.participants.map((participant, index) => {
|
||||
const centerX = centers[index]!
|
||||
const width = participantHeaderWidth(participant.label, compact)
|
||||
const headerLeftX = centerX - Math.floor(width / 2)
|
||||
return {
|
||||
participant,
|
||||
centerX,
|
||||
headerLeftX,
|
||||
headerRightX: headerLeftX + width - 1,
|
||||
labelX: centeredStart(centerX, participant.label),
|
||||
}
|
||||
})
|
||||
const steps: SequenceStepPlacement[] = []
|
||||
let stepY = stepStartY
|
||||
const activeFrames: ActiveFragmentFrame[] = []
|
||||
for (const [stepIndex, step] of diagram.steps.entries()) {
|
||||
if (step.type === "activation") continue
|
||||
const stepHeight = getStepHeight(step, centers, indexes, compact)
|
||||
if (step.type === "note") {
|
||||
const noteIndexes = getParticipantIndexes(indexes, step.note.over)
|
||||
if (noteIndexes.length > 0) {
|
||||
const centerX = Math.floor((centers[Math.min(...noteIndexes)]! + centers[Math.max(...noteIndexes)]!) / 2)
|
||||
const text = noteLabelText(step.note.label)
|
||||
const textX =
|
||||
step.note.position === "left"
|
||||
? centerX - visualLength(text) - 2
|
||||
: step.note.position === "right"
|
||||
? centerX + 2
|
||||
: centeredStart(centerX, text)
|
||||
steps.push({ type: "note", note: step.note, text, textX, textY: stepY + 1 })
|
||||
}
|
||||
stepY += stepHeight
|
||||
continue
|
||||
}
|
||||
if (step.type === "fragment") {
|
||||
let bounds = frameBounds.get(stepIndex) ?? getFragmentFrameBounds(centers, step.fragment)
|
||||
let wallsBefore: SequenceWallPlacement | undefined
|
||||
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
|
||||
if (bounds) activeFrames.push({ bounds, boundaryY: stepY })
|
||||
} else {
|
||||
const frame = activeFrames[activeFrames.length - 1]
|
||||
if (frame) {
|
||||
bounds = frame.bounds
|
||||
wallsBefore = { bounds, startY: frame.boundaryY + 1, endY: stepY - 1 }
|
||||
if (step.fragment.kind === "end") activeFrames.pop()
|
||||
else frame.boundaryY = stepY
|
||||
}
|
||||
}
|
||||
if (bounds)
|
||||
steps.push({
|
||||
type: "fragment",
|
||||
fragment: step.fragment,
|
||||
labelText: fragmentLabelText(step.fragment),
|
||||
bounds,
|
||||
y: stepY,
|
||||
wallsBefore,
|
||||
})
|
||||
stepY += stepHeight
|
||||
continue
|
||||
}
|
||||
const fromIndex = indexes.get(step.message.from) ?? -1
|
||||
const toIndex = indexes.get(step.message.to) ?? -1
|
||||
if (fromIndex < 0 || toIndex < 0) continue
|
||||
const labelLines = messageLabelLines(messageLabelText(step.message))
|
||||
if (fromIndex === toIndex) {
|
||||
const centerX = centers[fromIndex]!
|
||||
steps.push({
|
||||
type: "selfMessage",
|
||||
message: step.message,
|
||||
labelLines,
|
||||
centerX,
|
||||
rightX: centerX + selfMessageLoopWidthForLines(labelLines),
|
||||
topY: stepY,
|
||||
bottomY: stepY + labelLines.length + 1,
|
||||
})
|
||||
} else {
|
||||
const fromX = centers[fromIndex]!
|
||||
const toX = centers[toIndex]!
|
||||
const direction: 1 | -1 = toX > fromX ? 1 : -1
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
labelLines,
|
||||
labelX: leftX + 2,
|
||||
labelY: stepY,
|
||||
arrowY: inlineLabel ? stepY : stepY + labelLines.length,
|
||||
fromX,
|
||||
toX,
|
||||
leftX,
|
||||
rightX,
|
||||
direction,
|
||||
headX: arrowHeadX(toX, direction, step.message.head),
|
||||
inlineLabel,
|
||||
})
|
||||
}
|
||||
stepY += stepHeight
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rows: { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY },
|
||||
participants,
|
||||
groups,
|
||||
steps,
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import {
|
||||
resolveSequenceAnsiTheme,
|
||||
sequenceStyleBackgroundColor,
|
||||
sequenceStyleColor,
|
||||
type SequenceStyleColors,
|
||||
} from "./style.js"
|
||||
import type { SequenceCellStyle, SequenceDiagramAnsiTheme } from "./types.js"
|
||||
|
||||
export type SequenceGrid = DiagramCanvas<SequenceCellStyle>
|
||||
|
||||
export function renderSequenceGridText(grid: SequenceGrid): string {
|
||||
return grid.toString()
|
||||
}
|
||||
|
||||
export function renderSequenceGridStyledText(grid: SequenceGrid, colors: SequenceStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => sequenceStyleColor(run.style, colors),
|
||||
(run) => sequenceStyleBackgroundColor(run.style, colors),
|
||||
)
|
||||
}
|
||||
|
||||
export function renderSequenceGridAnsi(grid: SequenceGrid, theme: SequenceDiagramAnsiTheme = {}): string {
|
||||
const resolvedTheme = resolveSequenceAnsiTheme(theme)
|
||||
return renderDiagramGridAnsi(grid, (run) => {
|
||||
if (run.style === "noteBadge") return resolvedTheme.note
|
||||
return run.style ? resolvedTheme[run.style] : undefined
|
||||
})
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { type BorderStyle, type ColorInput, type RenderContext, type RGBA } from "@opentui/core"
|
||||
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
|
||||
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
|
||||
import { brightenColor } from "../core/color/style.js"
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE, normalizeSequenceMinParticipantGap } from "./options.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { renderSequenceGridStyledText, type SequenceGrid } from "./render-grid.js"
|
||||
import { resolveSequenceStyleColors } from "./style.js"
|
||||
import type { SequenceDiagram, SequenceDiagramOptions } from "./types.js"
|
||||
|
||||
export class SequenceDiagramRenderable extends DiagramRenderable<SequenceDiagram, SequenceGrid> {
|
||||
private _compact: boolean
|
||||
private _minParticipantGap: number
|
||||
private _fragmentBorderStyle: BorderStyle
|
||||
private _participantColor?: RGBA
|
||||
private _lifelineColor?: RGBA
|
||||
private _groupColor?: RGBA
|
||||
private _requestColor?: RGBA
|
||||
private _responseColor?: RGBA
|
||||
private _noteColor?: RGBA
|
||||
private _noteBackgroundColor?: RGBA
|
||||
constructor(ctx: RenderContext, options: SequenceDiagramOptions = {}) {
|
||||
super(ctx, options)
|
||||
this._compact = options.compact ?? false
|
||||
this._minParticipantGap = normalizeSequenceMinParticipantGap(options.minParticipantGap)
|
||||
this._fragmentBorderStyle = options.fragmentBorderStyle ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
this._participantColor = parseDiagramRenderableColor(options.participantColor)
|
||||
this._lifelineColor = parseDiagramRenderableColor(options.lifelineColor)
|
||||
this._groupColor = parseDiagramRenderableColor(options.groupColor)
|
||||
this._requestColor = parseDiagramRenderableColor(options.requestColor)
|
||||
this._responseColor = parseDiagramRenderableColor(options.responseColor)
|
||||
this._noteColor = parseDiagramRenderableColor(options.noteColor)
|
||||
this._noteBackgroundColor = parseDiagramRenderableColor(options.noteBackgroundColor)
|
||||
this.initializeDiagram({
|
||||
parse: () => parseMermaidSequenceDiagram(this.content),
|
||||
draw: (diagram) => this.drawGrid(diagram),
|
||||
publish: (grid) => this.styledText(grid),
|
||||
})
|
||||
}
|
||||
|
||||
get compact(): boolean {
|
||||
return this._compact
|
||||
}
|
||||
|
||||
set compact(value: boolean) {
|
||||
if (this._compact === value) return
|
||||
this._compact = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get minParticipantGap(): number {
|
||||
return this._minParticipantGap
|
||||
}
|
||||
|
||||
set minParticipantGap(value: number) {
|
||||
const next = normalizeSequenceMinParticipantGap(value)
|
||||
if (this._minParticipantGap === next) return
|
||||
this._minParticipantGap = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get fragmentBorderStyle(): BorderStyle {
|
||||
return this._fragmentBorderStyle
|
||||
}
|
||||
|
||||
set fragmentBorderStyle(value: BorderStyle | undefined) {
|
||||
const next = value ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
if (this._fragmentBorderStyle === next) return
|
||||
this._fragmentBorderStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get participantColor(): RGBA | undefined {
|
||||
return this._participantColor
|
||||
}
|
||||
|
||||
set participantColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._participantColor, value, (color) => {
|
||||
this._participantColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get lifelineColor(): RGBA | undefined {
|
||||
return this._lifelineColor
|
||||
}
|
||||
|
||||
set lifelineColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._lifelineColor, value, (color) => {
|
||||
this._lifelineColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get groupColor(): RGBA | undefined {
|
||||
return this._groupColor
|
||||
}
|
||||
|
||||
set groupColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._groupColor, value, (color) => {
|
||||
this._groupColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get requestColor(): RGBA | undefined {
|
||||
return this._requestColor
|
||||
}
|
||||
|
||||
set requestColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._requestColor, value, (color) => {
|
||||
this._requestColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get responseColor(): RGBA | undefined {
|
||||
return this._responseColor
|
||||
}
|
||||
|
||||
set responseColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._responseColor, value, (color) => {
|
||||
this._responseColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get noteColor(): RGBA | undefined {
|
||||
return this._noteColor
|
||||
}
|
||||
|
||||
set noteColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteColor, value, (color) => {
|
||||
this._noteColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get noteBackgroundColor(): RGBA | undefined {
|
||||
return this._noteBackgroundColor
|
||||
}
|
||||
|
||||
set noteBackgroundColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteBackgroundColor, value, (color) => {
|
||||
this._noteBackgroundColor = color
|
||||
})
|
||||
}
|
||||
|
||||
private drawGrid(diagram: SequenceDiagram): SequenceGrid {
|
||||
return drawSequenceDiagramGrid(diagram, {
|
||||
compact: this._compact,
|
||||
minParticipantGap: this._minParticipantGap,
|
||||
fragmentBorderStyle: this._fragmentBorderStyle,
|
||||
})
|
||||
}
|
||||
|
||||
private styledText(grid: SequenceGrid) {
|
||||
return renderSequenceGridStyledText(
|
||||
grid,
|
||||
resolveSequenceStyleColors({
|
||||
participant: this._participantColor,
|
||||
lifeline: this._lifelineColor,
|
||||
group: this._groupColor ?? brightenColor(this._lifelineColor, 0.08),
|
||||
request: this._requestColor,
|
||||
response: this._responseColor,
|
||||
fragment: brightenColor(this._lifelineColor, 0.18),
|
||||
fragmentLabelBg: this._noteBackgroundColor,
|
||||
note: this._noteColor,
|
||||
noteBg: this._noteBackgroundColor,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import {
|
||||
ansiBg,
|
||||
ansiFg,
|
||||
createAnsiRampTheme,
|
||||
createColorRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
import type {
|
||||
AnsiSequenceCellStyle,
|
||||
FadeStyle,
|
||||
MessageStyle,
|
||||
SequenceCellStyle,
|
||||
SequenceDiagramAnsiTheme,
|
||||
} from "./types.js"
|
||||
|
||||
export type SequenceStyleColors = Partial<Record<AnsiSequenceCellStyle, RGBA>> & {
|
||||
noteBg?: RGBA
|
||||
fragmentLabelBg?: RGBA
|
||||
}
|
||||
|
||||
export const SEQUENCE_FADE_STEPS = DIAGRAM_FADE_STEPS
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
participant: [228, 239, 232],
|
||||
lifeline: [111, 138, 126],
|
||||
group: [76, 99, 89],
|
||||
request: [134, 225, 200],
|
||||
response: [230, 177, 126],
|
||||
fragment: [154, 184, 169],
|
||||
fragmentLabelBg: [28, 43, 36],
|
||||
noteFg: [215, 229, 221],
|
||||
noteBg: [36, 56, 47],
|
||||
} as const satisfies Record<string, DiagramRgb>
|
||||
|
||||
function createAnsiFadeTheme(style: MessageStyle, from: DiagramRgb, to: DiagramRgb): Record<FadeStyle, string> {
|
||||
return createAnsiRampTheme(numberedStyleKeys(`${style}Fade`, SEQUENCE_FADE_STEPS), from, to) as Record<
|
||||
FadeStyle,
|
||||
string
|
||||
>
|
||||
}
|
||||
|
||||
const DEFAULT_ANSI_THEME: Required<Record<AnsiSequenceCellStyle, string>> = {
|
||||
participant: ansiFg(DEFAULT_THEME_RGB.participant),
|
||||
lifeline: ansiFg(DEFAULT_THEME_RGB.lifeline),
|
||||
group: ansiFg(DEFAULT_THEME_RGB.group),
|
||||
request: ansiFg(DEFAULT_THEME_RGB.request),
|
||||
response: ansiFg(DEFAULT_THEME_RGB.response),
|
||||
fragment: ansiFg(DEFAULT_THEME_RGB.fragment),
|
||||
fragmentLabel: `${ansiFg(DEFAULT_THEME_RGB.fragment)}${ansiBg(DEFAULT_THEME_RGB.fragmentLabelBg)}`,
|
||||
note: `${ansiFg(DEFAULT_THEME_RGB.noteFg)}${ansiBg(DEFAULT_THEME_RGB.noteBg)}`,
|
||||
...createAnsiFadeTheme("request", DEFAULT_THEME_RGB.lifeline, DEFAULT_THEME_RGB.request),
|
||||
...createAnsiFadeTheme("response", DEFAULT_THEME_RGB.lifeline, DEFAULT_THEME_RGB.response),
|
||||
}
|
||||
|
||||
function createColorFadeTheme(
|
||||
style: MessageStyle,
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<FadeStyle, RGBA | undefined> {
|
||||
return createColorRampTheme(numberedStyleKeys(`${style}Fade`, SEQUENCE_FADE_STEPS), from, to) as Record<
|
||||
FadeStyle,
|
||||
RGBA | undefined
|
||||
>
|
||||
}
|
||||
|
||||
export function resolveSequenceStyleColors(colors: SequenceStyleColors): SequenceStyleColors {
|
||||
return {
|
||||
...colors,
|
||||
...createColorFadeTheme("request", colors.lifeline, colors.request),
|
||||
...createColorFadeTheme("response", colors.lifeline, colors.response),
|
||||
}
|
||||
}
|
||||
|
||||
export function sequenceStyleColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: SequenceStyleColors,
|
||||
): RGBA | undefined {
|
||||
if (style === "noteBadge") return colors.note
|
||||
if (style === "fragmentLabel") return colors.fragment
|
||||
return style ? colors[style] : undefined
|
||||
}
|
||||
|
||||
export function sequenceStyleBackgroundColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: SequenceStyleColors,
|
||||
): RGBA | undefined {
|
||||
if (style === "fragmentLabel") return colors.fragmentLabelBg
|
||||
return style === "noteBadge" ? colors.noteBg : undefined
|
||||
}
|
||||
|
||||
export function resolveSequenceAnsiTheme(
|
||||
theme: SequenceDiagramAnsiTheme = {},
|
||||
): Required<Record<AnsiSequenceCellStyle, string>> {
|
||||
return { ...DEFAULT_ANSI_THEME, ...theme }
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
|
||||
|
||||
export interface SequenceParticipant {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SequenceParticipantGroup {
|
||||
label: string
|
||||
participantIds: string[]
|
||||
}
|
||||
|
||||
export interface SequenceMessage {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style: "solid" | "dashed"
|
||||
head?: SequenceArrowHead
|
||||
number?: number
|
||||
activate?: string
|
||||
deactivate?: string
|
||||
}
|
||||
|
||||
export type SequenceArrowHead = "open" | "cross" | "async"
|
||||
|
||||
export interface SequenceNote {
|
||||
over: string[]
|
||||
label: string
|
||||
position?: "over" | "left" | "right"
|
||||
}
|
||||
|
||||
export interface SequenceActivation {
|
||||
participant: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface SequenceFragment {
|
||||
kind: "alt" | "else" | "loop" | "end"
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SequenceStep =
|
||||
| { type: "message"; message: SequenceMessage }
|
||||
| { type: "note"; note: SequenceNote }
|
||||
| { type: "activation"; activation: SequenceActivation }
|
||||
| { type: "fragment"; fragment: SequenceFragment }
|
||||
|
||||
export interface SequenceDiagram {
|
||||
participants: SequenceParticipant[]
|
||||
messages: SequenceMessage[]
|
||||
steps: SequenceStep[]
|
||||
groups: SequenceParticipantGroup[]
|
||||
}
|
||||
|
||||
export interface SequenceDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
minParticipantGap?: number
|
||||
fragmentBorderStyle?: BorderStyle
|
||||
}
|
||||
|
||||
export type SequenceDiagramAnsiTheme = Partial<Record<AnsiSequenceCellStyle, string>>
|
||||
|
||||
export interface SequenceDiagramAnsiOptions extends SequenceDiagramRenderOptions {
|
||||
theme?: SequenceDiagramAnsiTheme
|
||||
}
|
||||
|
||||
export interface SequenceDiagramOptions extends TextBufferOptions, SequenceDiagramRenderOptions {
|
||||
content?: string
|
||||
participantColor?: ColorInput
|
||||
lifelineColor?: ColorInput
|
||||
groupColor?: ColorInput
|
||||
requestColor?: ColorInput
|
||||
responseColor?: ColorInput
|
||||
noteColor?: ColorInput
|
||||
noteBackgroundColor?: ColorInput
|
||||
}
|
||||
|
||||
export type MessageStyle = "request" | "response"
|
||||
export type FadeStyle = `${MessageStyle}Fade${1 | 2 | 3 | 4 | 5}`
|
||||
export type AnsiSequenceCellStyle =
|
||||
| "participant"
|
||||
| "lifeline"
|
||||
| "group"
|
||||
| MessageStyle
|
||||
| FadeStyle
|
||||
| "fragment"
|
||||
| "fragmentLabel"
|
||||
| "note"
|
||||
export type SequenceCellStyle = AnsiSequenceCellStyle | "noteBadge"
|
||||
@@ -1,81 +0,0 @@
|
||||
import { normalizeStateDiagramEndpoint } from "./endpoint.js"
|
||||
import type {
|
||||
StateDiagramActiveTransition,
|
||||
StateDiagramActiveTransitionSelection,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
type ActiveTransitionMatchTransition = StateDiagramTransition & {
|
||||
sourceTransitions?: readonly StateDiagramTransition[]
|
||||
}
|
||||
|
||||
function normalizeActiveTransition(activeTransition: StateDiagramActiveTransition): StateDiagramActiveTransition {
|
||||
return {
|
||||
from: normalizeStateDiagramEndpoint(activeTransition.from, "from"),
|
||||
to: normalizeStateDiagramEndpoint(activeTransition.to, "to"),
|
||||
label: activeTransition.label,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeActiveTransitions(
|
||||
activeTransition: StateDiagramActiveTransitionSelection | undefined,
|
||||
): StateDiagramActiveTransition[] {
|
||||
if (!activeTransition) return []
|
||||
const transitions = Array.isArray(activeTransition) ? activeTransition : [activeTransition]
|
||||
return transitions.map(normalizeActiveTransition)
|
||||
}
|
||||
|
||||
function activeTransitionEqual(left: StateDiagramActiveTransition, right: StateDiagramActiveTransition): boolean {
|
||||
return left.from === right.from && left.to === right.to && left.label === right.label
|
||||
}
|
||||
|
||||
function activeTransitionMatchesTransition(
|
||||
activeTransition: StateDiagramActiveTransition,
|
||||
transition: StateDiagramTransition,
|
||||
): boolean {
|
||||
return (
|
||||
activeTransition.from === transition.from &&
|
||||
activeTransition.to === transition.to &&
|
||||
(activeTransition.label === undefined || activeTransition.label === transition.label)
|
||||
)
|
||||
}
|
||||
|
||||
export function activeTransitionListsEqual(
|
||||
left: readonly StateDiagramActiveTransition[],
|
||||
right: readonly StateDiagramActiveTransition[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length && left.every((transition, index) => activeTransitionEqual(transition, right[index]!))
|
||||
)
|
||||
}
|
||||
|
||||
export function isActiveTransition(
|
||||
transition: ActiveTransitionMatchTransition,
|
||||
activeTransitions: readonly StateDiagramActiveTransition[],
|
||||
): boolean {
|
||||
return activeTransitionIndex(transition, activeTransitions) !== -1
|
||||
}
|
||||
|
||||
export function activeTransitionIndex(
|
||||
transition: ActiveTransitionMatchTransition,
|
||||
activeTransitions: readonly StateDiagramActiveTransition[],
|
||||
): number {
|
||||
const exactIndex = activeTransitions.findIndex((activeTransition) =>
|
||||
activeTransitionMatchesTransition(activeTransition, transition),
|
||||
)
|
||||
if (exactIndex !== -1) return exactIndex
|
||||
|
||||
const sourceTransitions = transition.sourceTransitions
|
||||
if (!sourceTransitions || sourceTransitions.length <= 1 || activeTransitions.length < sourceTransitions.length)
|
||||
return -1
|
||||
|
||||
for (let index = 0; index <= activeTransitions.length - sourceTransitions.length; index++) {
|
||||
const matches = sourceTransitions.every((sourceTransition, offset) => {
|
||||
const activeTransition = activeTransitions[index + offset]!
|
||||
return activeTransitionMatchesTransition(activeTransition, sourceTransition)
|
||||
})
|
||||
if (matches) return index
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -1,894 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import stringWidth from "string-width"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderStateDiagram, renderStateDiagramAnsi } from "./diagram.js"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { StateDiagramRenderable } from "./renderable.js"
|
||||
|
||||
describe("StateDiagram", () => {
|
||||
test("detects and parses Mermaid state diagrams", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
%% request lifecycle
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: done
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(diagram.direction).toBe("LR")
|
||||
expect(diagram.states).toEqual([
|
||||
{ id: "__start", label: "●", kind: "start" },
|
||||
{ id: "Idle", label: "Idle", kind: "state" },
|
||||
{ id: "Loading", label: "Loading", kind: "state" },
|
||||
{ id: "Success", label: "Success", kind: "state" },
|
||||
{ id: "__end", label: "◎", kind: "end" },
|
||||
])
|
||||
expect(diagram.transitions).toEqual([
|
||||
{ from: "__start", to: "Idle", label: "" },
|
||||
{ from: "Idle", to: "Loading", label: "submit" },
|
||||
{ from: "Loading", to: "Success", label: "done" },
|
||||
{ from: "Success", to: "__end", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses quoted state aliases", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
state "Waiting<br/>for Payment" as WaitingPayment
|
||||
[*] --> WaitingPayment
|
||||
`)
|
||||
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "WaitingPayment",
|
||||
label: "Waiting<br/>for Payment",
|
||||
kind: "state",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses choice pseudo-states", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
[*] --> Decision
|
||||
state Decision <<choice>>
|
||||
Decision --> Accepted: yes
|
||||
`)
|
||||
|
||||
expect(diagram.states).toContainEqual({ id: "Decision", label: "┼", kind: "choice" })
|
||||
})
|
||||
|
||||
test("parses composite states and notes", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
}
|
||||
note right of Editing
|
||||
Draft changes
|
||||
end note
|
||||
`)
|
||||
|
||||
expect(diagram.composites).toContainEqual({ id: "Authenticated", label: "Authenticated" })
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "Idle",
|
||||
label: "Idle",
|
||||
kind: "state",
|
||||
parentId: "Authenticated",
|
||||
})
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "Authenticated.__start",
|
||||
label: "●",
|
||||
kind: "start",
|
||||
parentId: "Authenticated",
|
||||
})
|
||||
expect(diagram.notes).toEqual([{ target: "Editing", position: "right", lines: ["Draft changes"] }])
|
||||
})
|
||||
|
||||
test("renders a horizontal state diagram", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: done
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭──────╮ submit ╭─────────╮ done ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰─────────╯ ╰─────────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders reverse horizontal direction from right to left", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction RL
|
||||
A --> B`)
|
||||
const labelRow = output.split("\n").find((line) => line.includes(" A ") && line.includes(" B "))!
|
||||
|
||||
expect(labelRow.indexOf("B")).toBeLessThan(labelRow.indexOf("A"))
|
||||
expect(output).toContain("◀")
|
||||
})
|
||||
|
||||
test("does not mutate a parsed diagram when rendering with a direction override", () => {
|
||||
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B`)
|
||||
|
||||
drawStateDiagramGrid(diagram, { direction: "RL" })
|
||||
|
||||
expect(diagram.direction).toBe("LR")
|
||||
})
|
||||
|
||||
test("normalizes mutable renderable state gaps before exposing them", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 40, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: "stateDiagram-v2\n A --> B",
|
||||
minStateGap: Number.NaN,
|
||||
})
|
||||
|
||||
expect(diagram.minStateGap).toBe(5)
|
||||
diagram.minStateGap = 0
|
||||
expect(diagram.minStateGap).toBe(1)
|
||||
diagram.minStateGap = 3.9
|
||||
expect(diagram.minStateGap).toBe(3)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("places right-to-left transition labels between intact frames", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction RL
|
||||
A --> B: reopen after a very detailed reviewer comment`)
|
||||
|
||||
expect(output).toContain("╭───╮")
|
||||
expect(output.match(/╭───╮/g)?.length).toBe(2)
|
||||
expect(output).toContain("reopen after a very detailed reviewer comment")
|
||||
})
|
||||
|
||||
test("keeps Unicode state labels inside their measured frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state "界" as Wide`)
|
||||
const widths = output.split("\n").map((line) => stringWidth(line))
|
||||
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
expect(output).toContain("界")
|
||||
})
|
||||
|
||||
test("reserves horizontal room for long transition labels", () => {
|
||||
const label = "this transition label is much wider than the route"
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: ${label}`)
|
||||
const labelRow = output.split("\n").find((line) => line.includes(label))!
|
||||
|
||||
expect(labelRow.match(/╭───╮/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
test("renders every line of multiline transition labels", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: first<br/>second`)
|
||||
|
||||
expect(output).toContain("first")
|
||||
expect(output).toContain("second")
|
||||
})
|
||||
|
||||
test("renders a vertical state diagram", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction TB
|
||||
[*] --> Cart
|
||||
Cart --> Payment: checkout
|
||||
Payment --> Complete
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ●
|
||||
│
|
||||
│
|
||||
│
|
||||
▼
|
||||
╭──────╮
|
||||
│ Cart │
|
||||
╰───┬──╯
|
||||
│
|
||||
│ checkout
|
||||
│
|
||||
▼
|
||||
╭─────────╮
|
||||
│ Payment │
|
||||
╰────┬────╯
|
||||
│
|
||||
│
|
||||
│
|
||||
▼
|
||||
╭──────────╮
|
||||
│ Complete │
|
||||
╰──────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders branched and backward transitions visibly", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: 200 OK
|
||||
Loading --> Error: timeout
|
||||
Error --> Loading: retry
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰──┬──────╯ ╰─────────╯
|
||||
│ ▲
|
||||
timeout │ │
|
||||
▼ │ retry
|
||||
╭─────┴─╮
|
||||
│ Error │
|
||||
╰───────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("captures converging labeled branches with long state names", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Waiting
|
||||
state "Waiting for surface and workspace" as Waiting
|
||||
state "Surface bound only" as Surface
|
||||
state "Workspace bound only" as Workspace
|
||||
state "Ready with queued input" as Ready
|
||||
state "Agent activity requested" as Active
|
||||
Waiting --> Surface: InteractionSurfaceBound
|
||||
Waiting --> Workspace: WorkspaceBound
|
||||
Surface --> Ready: WorkspaceBound
|
||||
Workspace --> Ready: InteractionSurfaceBound
|
||||
Ready --> Active: AgentActivityRequested`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭───────────────────────────────────╮ InteractionSurfaceBound ╭────────────────────╮ WorkspaceBound ╭─────────────────────────╮ AgentActivityRequested ╭──────────────────────────╮
|
||||
●────────────▶│ Waiting for surface and workspace ├────────────────────────▶│ Surface bound only ├───────────────▶│ Ready with queued input ├───────────────────────▶│ Agent activity requested │
|
||||
╰───────────────┬───────────────────╯ ╰────────────────────╯ ╰─────────────────────────╯ ╰──────────────────────────╯
|
||||
│ WorkspaceBound ▲
|
||||
╰──────────────────────────────────────────────────────╮ │
|
||||
│ InteractionSurfaceBound │
|
||||
▼ ╭───────────────────────────────────────╯
|
||||
╭─────────────┴────────╮
|
||||
│ Workspace bound only │
|
||||
╰──────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps raised note connectors off outgoing transitions", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: 200 OK
|
||||
Loading --> Error: timeout
|
||||
note right of Loading : waiting for response
|
||||
Error --> Loading: retry
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╔══════════════════════╗
|
||||
╔═══╣ waiting for response ║
|
||||
║ ╚══════════════════════╝
|
||||
║
|
||||
║
|
||||
╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰──┬──────╯ ╰─────────╯
|
||||
│ ▲
|
||||
timeout │ │
|
||||
▼ │ retry
|
||||
╭─────┴─╮
|
||||
│ Error │
|
||||
╰───────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders configurable line arrowheads", () => {
|
||||
const output = renderStateDiagram(
|
||||
`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
`,
|
||||
{ arrowHeadStyle: "line" },
|
||||
)
|
||||
|
||||
expect(output).toContain("→")
|
||||
expect(output).not.toContain("▶")
|
||||
})
|
||||
|
||||
test("renders self transitions and choice branches", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
[*] --> Editing
|
||||
Editing --> Editing: type
|
||||
Editing --> Decision: submit
|
||||
Decision --> Saved: ok
|
||||
Decision --> Error: fail
|
||||
Error --> Editing: retry
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─────────╮ submit ok ╭───────╮
|
||||
●────────────▶│ Editing ├─────────────┬────────────▶│ Saved │
|
||||
╰──┬──────╯ │ ╰───────╯
|
||||
▲ │ ▲ type │ fail
|
||||
│ ╰────╯ │
|
||||
│ ▼
|
||||
│ ╭───────╮
|
||||
│ │ Error │
|
||||
│ ╰───┬───╯
|
||||
│ │
|
||||
│ │
|
||||
│ retry │
|
||||
╰──────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("connects lower routed branches into choice junctions", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
[*] --> Fork
|
||||
Fork --> Upper
|
||||
Fork --> Lower
|
||||
Upper --> Decision
|
||||
Lower --> Decision
|
||||
Decision --> Done
|
||||
Done --> [*]`)
|
||||
|
||||
expect(output).toContain("Upper ├─────────────┬────────────▶│ Done")
|
||||
})
|
||||
|
||||
test("renders self transitions as loops in vertical diagrams", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
Working --> Working: retry`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮
|
||||
│ Working │
|
||||
╰──┬──────╯
|
||||
│ ▲ retry
|
||||
╰────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders parallel transitions without losing labels", () => {
|
||||
const horizontal = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: first
|
||||
A --> B: second`)
|
||||
const vertical = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
A --> B: first
|
||||
A --> B: second`)
|
||||
|
||||
expect(horizontal).toContain("first")
|
||||
expect(horizontal).toContain("second")
|
||||
expect(vertical).toContain("first")
|
||||
expect(vertical).toContain("second")
|
||||
})
|
||||
|
||||
test("keeps independent overlapping feedback labels and paths distinct", () => {
|
||||
const content = (direction: "LR" | "RL") => `stateDiagram-v2
|
||||
direction ${direction}
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B`
|
||||
|
||||
for (const direction of ["LR", "RL"] as const) {
|
||||
const output = renderStateDiagram(content(direction))
|
||||
expect(output).toContain("reset A")
|
||||
expect(output).toContain("reset B")
|
||||
expect(output).not.toContain("res│t")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps independent internal feedback paths inside their composite frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Runtime {
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B
|
||||
}`)
|
||||
const lines = output.split("\n")
|
||||
const frameTop = lines.findIndex((line) => line.includes("Runtime"))
|
||||
const upperFeedback = lines.findIndex((line) => line.includes("reset B"))
|
||||
|
||||
expect(upperFeedback).toBeGreaterThan(frameTop)
|
||||
expect(output).toContain("reset A")
|
||||
expect(output).not.toContain("res│t")
|
||||
})
|
||||
|
||||
test("places notes away from independent feedback corridors", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B
|
||||
note right of B : note beside B`)
|
||||
|
||||
expect(output).toContain("note beside B")
|
||||
expect(output).toContain("reset B")
|
||||
expect(output).not.toContain("╭─║")
|
||||
expect(output).not.toContain("║──")
|
||||
})
|
||||
|
||||
test("keeps duplicate feedback labels away from an independent return path", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
C --> A: ca
|
||||
D --> B: db1
|
||||
D --> B: db2`)
|
||||
|
||||
expect(output).toContain("ca")
|
||||
expect(output).toContain("db1")
|
||||
expect(output).toContain("db2")
|
||||
expect(output).not.toContain("c│")
|
||||
})
|
||||
|
||||
test("renders composite state containers", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─ Authenticated ──────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ╭──────╮ open ╭─────────╮ save │
|
||||
│ ─────────────▶│ Idle ├────────────▶│ Editing ├────────────── │
|
||||
│ ╰──────╯ ╰─────────╯ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("routes transitions entering and leaving composite states through scoped markers", () => {
|
||||
const content = `
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Authenticated: login
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
Authenticated --> [*]: logout
|
||||
`
|
||||
const diagram = parseMermaidStateDiagram(content)
|
||||
const output = renderStateDiagram(content)
|
||||
|
||||
expect(diagram.transitions).toContainEqual({
|
||||
from: "__start",
|
||||
to: "Authenticated.__start",
|
||||
label: "login",
|
||||
})
|
||||
expect(diagram.transitions).toContainEqual({
|
||||
from: "Authenticated.__end",
|
||||
to: "__end",
|
||||
label: "logout",
|
||||
})
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Authenticated ──────────────────╮
|
||||
│ │
|
||||
login │ ╭──────╮ open ╭─────────╮ │ save
|
||||
●────────────▶│ Idle ├────────────▶│ Editing ├────────────▶◎
|
||||
│ ╰──────╯ ╰─────────╯ │
|
||||
│ │
|
||||
╰──────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders notes attached to states", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
note right of Loading : waits for response
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭──────╮ submit ╭─────────╮ ╔════════════════════╗
|
||||
●────────────▶│ Idle ├────────────▶│ Loading │════╣ waits for response ║
|
||||
╰──────╯ ╰─────────╯ ╚════════════════════╝"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders ANSI styles", () => {
|
||||
const output = renderStateDiagramAnsi(`
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
`)
|
||||
|
||||
expect(output).toContain("\x1b[")
|
||||
expect(output).toContain("●")
|
||||
})
|
||||
|
||||
test("colors states, transitions, labels, and markers separately", async () => {
|
||||
const stateColor = parseColor("#E5E7EB")
|
||||
const activeStateColor = parseColor("#DDFFF6")
|
||||
const transitionColor = parseColor("#86E1C8")
|
||||
const labelColor = parseColor("#E6B17E")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Loading",
|
||||
stateColor,
|
||||
activeStateColor,
|
||||
transitionColor,
|
||||
labelColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const idleSpan = spans.find((span) => span.text.includes("Idle"))
|
||||
const loadingSpan = spans.find((span) => span.text.includes("Loading"))
|
||||
const arrowSpan = spans.find((span) => span.text.includes("▶"))
|
||||
const labelSpan = spans.find((span) => span.text.includes("submit"))
|
||||
const fadeSpan = spans.find((span) => span.text.includes("├") || span.text.includes("┤"))
|
||||
|
||||
expect(idleSpan?.fg.equals(stateColor)).toBe(true)
|
||||
expect(loadingSpan?.fg.equals(activeStateColor)).toBe(true)
|
||||
expect(arrowSpan?.fg.equals(transitionColor)).toBe(true)
|
||||
expect(labelSpan?.fg.equals(labelColor)).toBe(true)
|
||||
expect(fadeSpan?.fg.equals(stateColor)).toBe(false)
|
||||
expect(fadeSpan?.fg.equals(transitionColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("repaints state colors after mounting without changing diagram text", async () => {
|
||||
const initialColor = parseColor("#86E1C8")
|
||||
const updatedColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: "stateDiagram-v2\n A --> B: next",
|
||||
transitionColor: initialColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
const before = testRenderer.captureCharFrame()
|
||||
|
||||
diagram.transitionColor = updatedColor
|
||||
await testRenderer.renderOnce()
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(testRenderer.captureCharFrame()).toBe(before)
|
||||
expect(spans.some((span) => span.text.includes("▶") && span.fg.equals(updatedColor))).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors active transitions separately", async () => {
|
||||
const transitionColor = parseColor("#86E1C8")
|
||||
const activeTransitionColor = parseColor("#E6B17E")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit`,
|
||||
activeTransition: { from: "Idle", to: "Loading" },
|
||||
transitionColor,
|
||||
activeTransitionColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const activeArrowSpan = spans.find((span) => span.text.includes("▶") && span.fg?.equals(activeTransitionColor))
|
||||
const inactiveArrowSpan = spans.find((span) => span.text.includes("▶") && span.fg?.equals(transitionColor))
|
||||
const departureSpan = spans.find((span) => span.text.includes("├"))
|
||||
const labelSpan = spans.find((span) => span.text.includes("submit"))
|
||||
|
||||
expect(activeArrowSpan).toBeTruthy()
|
||||
expect(inactiveArrowSpan).toBeTruthy()
|
||||
expect(departureSpan?.fg.equals(activeTransitionColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(transitionColor)).toBe(false)
|
||||
expect(labelSpan?.fg.equals(activeTransitionColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("active transitions preserve route glyph shape", () => {
|
||||
const content = `stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: next`
|
||||
|
||||
const inactive = renderStateDiagram(content)
|
||||
const active = renderStateDiagram(content, {
|
||||
activeTransition: { from: "A", to: "B" },
|
||||
})
|
||||
|
||||
expect(active).toBe(inactive)
|
||||
})
|
||||
|
||||
test("derives transition boundary fades from per-state colors", async () => {
|
||||
const sourceColor = parseColor("#000000")
|
||||
const activeTransitionColor = parseColor("#060000")
|
||||
const expectedBoundaryColor = parseColor("#010000")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Idle",
|
||||
activeStateColor: "#FF0000",
|
||||
activeTransition: { from: "Idle", to: "Loading" },
|
||||
activeTransitionColor,
|
||||
stateColors: { Idle: sourceColor },
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const departureSpan = spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan?.fg.equals(expectedBoundaryColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors individual states with per-state overrides", async () => {
|
||||
const stateColor = parseColor("#E4EFE8")
|
||||
const activeStateColor = parseColor("#FFD3A0")
|
||||
const outgoingColor = parseColor("#F0C198")
|
||||
const incomingColor = parseColor("#CFE4D7")
|
||||
const testRenderer = await createTestRenderer({ width: 90, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Loading",
|
||||
stateColor,
|
||||
activeStateColor,
|
||||
stateColors: {
|
||||
Idle: outgoingColor,
|
||||
Loading: incomingColor,
|
||||
},
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
let spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const idleSpan = spans.find((span) => span.text.includes("Idle"))
|
||||
const loadingSpan = spans.find((span) => span.text.includes("Loading"))
|
||||
|
||||
expect(idleSpan?.fg.equals(outgoingColor)).toBe(true)
|
||||
expect(loadingSpan?.fg.equals(incomingColor)).toBe(true)
|
||||
|
||||
diagram.stateColors = undefined
|
||||
await testRenderer.renderOnce()
|
||||
spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(spans.find((span) => span.text.includes("Idle"))?.fg.equals(stateColor)).toBe(true)
|
||||
expect(spans.find((span) => span.text.includes("Loading"))?.fg.equals(activeStateColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders state backgrounds inside the state box only", async () => {
|
||||
const activeStateColor = parseColor("#FFD3A0")
|
||||
const activeStateBg = parseColor("#26352F")
|
||||
const testRenderer = await createTestRenderer({ width: 90, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Loading",
|
||||
activeStateColor,
|
||||
stateBgColors: { Loading: activeStateBg },
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const borderSpan = spans.find((span) => span.text.includes("╭") || span.text.includes("╮"))
|
||||
const loadingSpan = spans.find((span) => span.text.includes("Loading"))
|
||||
|
||||
expect(borderSpan?.bg.equals(activeStateBg)).toBe(false)
|
||||
expect(loadingSpan?.fg.equals(activeStateColor)).toBe(true)
|
||||
expect(loadingSpan?.bg.equals(activeStateBg)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("fades active transitions from the active state color", () => {
|
||||
const output = renderStateDiagramAnsi(
|
||||
`
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
`,
|
||||
{
|
||||
activeState: "__start",
|
||||
activeTransition: { from: "__start", to: "Idle" },
|
||||
theme: {
|
||||
activeStateActiveTransitionFade1: "[active-state-fade]",
|
||||
startActiveTransitionFade1: "[start-fade]",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(output).toContain("[active-state-fade]")
|
||||
expect(output).not.toContain("[start-fade]")
|
||||
})
|
||||
|
||||
test("colors note connector, border, and text separately", async () => {
|
||||
const noteConnectorColor = parseColor("#8DA99B")
|
||||
const noteBorderColor = parseColor("#B68B68")
|
||||
const noteTextColor = parseColor("#F1D9BE")
|
||||
const testRenderer = await createTestRenderer({ width: 110, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
note right of Loading : waits for response`,
|
||||
noteConnectorColor,
|
||||
noteBorderColor,
|
||||
noteTextColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const connectorSpan = spans.find((span) => span.text.includes("═") && span.fg?.equals(noteConnectorColor))
|
||||
const borderSpan = spans.find((span) => span.text.includes("╔") && span.fg?.equals(noteBorderColor))
|
||||
const textSpan = spans.find((span) => span.text.includes("waits for response"))
|
||||
|
||||
expect(connectorSpan).toBeTruthy()
|
||||
expect(borderSpan).toBeTruthy()
|
||||
expect(textSpan?.fg.equals(noteTextColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors active transition paths through choice junctions", async () => {
|
||||
const activeTransitionColor = parseColor("#E6B17E")
|
||||
const testRenderer = await createTestRenderer({ width: 120, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
Validating --> Decision
|
||||
Decision --> Submitted: valid
|
||||
Decision --> Invalid: errors`,
|
||||
activeTransition: [
|
||||
{ from: "Validating", to: "Decision" },
|
||||
{ from: "Decision", to: "Submitted", label: "valid" },
|
||||
],
|
||||
activeTransitionColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const validSpan = spans.find((span) => span.text.includes("valid"))
|
||||
const errorsSpan = spans.find((span) => span.text.includes("errors"))
|
||||
const activeArrowSpan = spans.find((span) => span.text.includes("▶") && span.fg?.equals(activeTransitionColor))
|
||||
|
||||
expect(validSpan?.fg.equals(activeTransitionColor)).toBe(true)
|
||||
expect(errorsSpan?.fg.equals(activeTransitionColor)).toBe(false)
|
||||
expect(activeArrowSpan).toBeTruthy()
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors composite containers separately", async () => {
|
||||
const compositeColor = parseColor("#6F8A7E")
|
||||
const stateColor = parseColor("#E4EFE8")
|
||||
const testRenderer = await createTestRenderer({ width: 100, height: 10 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
direction LR
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
}`,
|
||||
compositeColor,
|
||||
stateColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const compositeSpan = spans.find((span) => span.text.includes("Authenticated"))
|
||||
const stateSpan = spans.find((span) => span.text.includes("Idle"))
|
||||
|
||||
expect(compositeSpan?.fg.equals(compositeColor)).toBe(true)
|
||||
expect(stateSpan?.fg.equals(stateColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores stale active transition selections while highlighting matches", () => {
|
||||
expect(() =>
|
||||
renderStateDiagram(
|
||||
`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B`,
|
||||
{
|
||||
activeTransition: [
|
||||
{ from: "Missing", to: "Gone" },
|
||||
{ from: "A", to: "B" },
|
||||
],
|
||||
},
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { renderStateGridAnsi, renderStateGridText } from "./render-grid.js"
|
||||
import type { StateDiagramAnsiOptions, StateDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderStateDiagram(content: string, options: StateDiagramRenderOptions = {}): string {
|
||||
return renderStateGridText(drawStateDiagramGrid(parseMermaidStateDiagram(content), options))
|
||||
}
|
||||
|
||||
export function renderStateDiagramAnsi(content: string, options: StateDiagramAnsiOptions = {}): string {
|
||||
return renderStateGridAnsi(drawStateDiagramGrid(parseMermaidStateDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { diagramRadialCellColorLevel } from "../core/color/map.js"
|
||||
import { diagramArrowHead, diagramLineGlyph, drawDiagramFrame, mergeDiagramLineGlyph } from "../core/drawing.js"
|
||||
import { activeTransitionIndex, isActiveTransition, normalizeActiveTransitions } from "./active-transition.js"
|
||||
import {
|
||||
createStateDiagramLayout,
|
||||
expandCompositeBoundsForFeedback,
|
||||
expandCompositeBoundsForInternalTransitions,
|
||||
type StateDiagramBoxBounds as BoxBounds,
|
||||
type StateDiagramNoteBounds as StateNoteBounds,
|
||||
} from "./layout.js"
|
||||
import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js"
|
||||
import type { StateCellMetadata, StateGrid } from "./render-grid.js"
|
||||
import {
|
||||
createStateTransitionJunctionPlans,
|
||||
createStateTransitionRenderPlans,
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import {
|
||||
isStateActiveTransitionStyle,
|
||||
isStateTransitionFadeStyle,
|
||||
stateDiagramStateColorKey,
|
||||
stateTransitionFadeStyle,
|
||||
} from "./style.js"
|
||||
import type {
|
||||
FadeSourceStyle,
|
||||
StateCellStyle,
|
||||
StateDiagram,
|
||||
StateDiagramActiveTransition,
|
||||
StateDiagramArrowHeadStyle,
|
||||
StateDiagramRenderOptions,
|
||||
StateDiagramState,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
import { isHiddenCompositeMarker, prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
type StateCell = DiagramCanvasCell<StateCellStyle, StateCellMetadata>
|
||||
|
||||
interface TransitionDrawContext {
|
||||
fadeSource: FadeSourceStyle
|
||||
active: boolean
|
||||
fadeFromSource: boolean
|
||||
sourceStateId: string
|
||||
}
|
||||
|
||||
function translateTransitionPlans(
|
||||
plans: readonly StateTransitionRenderPlan[],
|
||||
dy: number,
|
||||
): StateTransitionRenderPlan[] {
|
||||
return plans.map((plan) => ({
|
||||
...plan,
|
||||
cells: plan.cells.map((cell) => ({ ...cell, y: cell.y + dy })),
|
||||
path: plan.path.map(([x, y]) => [x, y + dy]),
|
||||
label: plan.label ? { ...plan.label, y: plan.label.y + dy } : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function makeGrid(width: number, height: number): StateGrid {
|
||||
return new DiagramCanvas(width, height, {
|
||||
mergeCell: (existing, incoming): StateCell => {
|
||||
const shouldMerge = isTransitionDrawingStyle(existing.style) && isTransitionDrawingStyle(incoming.style)
|
||||
return {
|
||||
...incoming,
|
||||
char: shouldMerge
|
||||
? (mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char)
|
||||
: incoming.char,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function isTransitionDrawingStyle(style: StateCellStyle | undefined): boolean {
|
||||
return (
|
||||
style === "transition" ||
|
||||
style === "activeTransition" ||
|
||||
isStateActiveTransitionStyle(style) ||
|
||||
isStateTransitionFadeStyle(style)
|
||||
)
|
||||
}
|
||||
|
||||
function setCell(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
char: string,
|
||||
style?: StateCellStyle,
|
||||
stateId?: string,
|
||||
bgStateId?: string,
|
||||
): void {
|
||||
grid.setCell(x, y, char, style, { stateId, bgStateId })
|
||||
}
|
||||
|
||||
function setText(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style?: StateCellStyle,
|
||||
stateId?: string,
|
||||
bgStateId?: string,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, { stateId, bgStateId })
|
||||
}
|
||||
|
||||
function setTransitionLabel(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
lines: readonly string[],
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
lines.forEach((line, index) => setText(grid, x, y + index, line, style))
|
||||
}
|
||||
|
||||
function drawBox(
|
||||
grid: StateGrid,
|
||||
state: StateDiagramState,
|
||||
bounds: BoxBounds,
|
||||
lines: string[],
|
||||
active: boolean,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
if (isHiddenCompositeMarker(state)) return
|
||||
|
||||
if (state.kind !== "state") {
|
||||
setCell(grid, bounds.left, bounds.top, state.label, active ? "activeState" : state.kind, state.id)
|
||||
return
|
||||
}
|
||||
const style: StateCellStyle = active ? "activeState" : "state"
|
||||
fillBoxInterior(grid, bounds, style, state.id)
|
||||
drawStateFrame(grid, bounds, BorderChars[borderStyle], style, state.id)
|
||||
lines.forEach((line, index) => {
|
||||
setStateText(grid, bounds, bounds.left + 2, bounds.top + 1 + index, line, style, state.id)
|
||||
})
|
||||
}
|
||||
|
||||
function stateColorKeyForCell(bounds: BoxBounds, stateId: string, x: number, y: number, border = false): string {
|
||||
return stateDiagramStateColorKey(stateId, diagramRadialCellColorLevel(bounds, x, y, border))
|
||||
}
|
||||
|
||||
function fillBoxInterior(grid: StateGrid, bounds: BoxBounds, style: StateCellStyle, stateId: string): void {
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
const colorKey = stateColorKeyForCell(bounds, stateId, x, y)
|
||||
setCell(grid, x, y, " ", style, colorKey, colorKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawStateFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
stateId: string,
|
||||
): void {
|
||||
const setBorderCell = (x: number, y: number, char: string) => {
|
||||
setCell(grid, x, y, char, style, stateColorKeyForCell(bounds, stateId, x, y, true))
|
||||
}
|
||||
|
||||
drawDiagramFrame(bounds, chars, setBorderCell)
|
||||
}
|
||||
|
||||
function setStateText(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style: StateCellStyle,
|
||||
stateId: string,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, (cellX, cellY) => {
|
||||
const colorKey = stateColorKeyForCell(bounds, stateId, cellX, cellY)
|
||||
return { stateId: colorKey, bgStateId: colorKey }
|
||||
})
|
||||
}
|
||||
|
||||
function drawContainerFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
label: string,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
stateId?: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style, stateId))
|
||||
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style, stateId)
|
||||
}
|
||||
|
||||
function drawHorizontalNoteConnector(grid: StateGrid, fromX: number, toX: number, y: number, char: string): void {
|
||||
const step = fromX <= toX ? 1 : -1
|
||||
for (let x = fromX; step === 1 ? x <= toX : x >= toX; x += step) {
|
||||
setCell(grid, x, y, char, "noteConnector")
|
||||
}
|
||||
}
|
||||
|
||||
function drawNote(grid: StateGrid, bounds: StateNoteBounds, target: BoxBounds): void {
|
||||
const chars = BorderChars.double
|
||||
const connectorChars = BorderChars.double
|
||||
const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width
|
||||
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
||||
const targetBottom = target.top + target.height - 1
|
||||
const noteBottom = bounds.top + bounds.height - 1
|
||||
const noteAbove = noteBottom < target.top
|
||||
const noteBelow = bounds.top > targetBottom
|
||||
let connectorY: number
|
||||
|
||||
if (noteAbove || noteBelow) {
|
||||
const targetY = noteAbove ? target.top - 1 : targetBottom + 1
|
||||
connectorY = bounds.centerY
|
||||
const verticalStep = targetY <= connectorY ? 1 : -1
|
||||
|
||||
for (let y = targetY; verticalStep === 1 ? y <= connectorY : y >= connectorY; y += verticalStep) {
|
||||
setCell(grid, targetX, y, connectorChars.vertical, "noteConnector")
|
||||
}
|
||||
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
const connectorTurnsRight = targetX <= noteX
|
||||
const corner = noteAbove
|
||||
? connectorTurnsRight
|
||||
? connectorChars.topLeft
|
||||
: connectorChars.topRight
|
||||
: connectorTurnsRight
|
||||
? connectorChars.bottomLeft
|
||||
: connectorChars.bottomRight
|
||||
setCell(grid, targetX, connectorY, corner, "noteConnector")
|
||||
} else {
|
||||
connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
}
|
||||
|
||||
drawContainerFrame(grid, bounds, "", chars, "noteBorder")
|
||||
setCell(
|
||||
grid,
|
||||
bounds.note.position === "right" ? bounds.left : bounds.left + bounds.width - 1,
|
||||
connectorY,
|
||||
bounds.note.position === "right" ? chars.rightT : chars.leftT,
|
||||
"noteBorder",
|
||||
)
|
||||
bounds.lines.forEach((line, index) => setText(grid, bounds.left + 2, bounds.top + 1 + index, line, "noteText"))
|
||||
}
|
||||
|
||||
function transitionLineStyle(active: boolean): StateCellStyle {
|
||||
return active ? "activeTransition" : "transition"
|
||||
}
|
||||
|
||||
function transitionLabelStyle(active: boolean): StateCellStyle {
|
||||
return active ? "activeTransition" : "label"
|
||||
}
|
||||
|
||||
function transitionFadeCellStyle(context: TransitionDrawContext, distance: number): StateCellStyle {
|
||||
return stateTransitionFadeStyle(context.fadeSource, context.active, distance, context.fadeFromSource)
|
||||
}
|
||||
|
||||
function drawTransitionRenderPlan(
|
||||
grid: StateGrid,
|
||||
plan: StateTransitionRenderPlan,
|
||||
arrowHeadStyle: StateDiagramArrowHeadStyle,
|
||||
context: TransitionDrawContext,
|
||||
): void {
|
||||
const lineStyle = transitionLineStyle(context.active)
|
||||
for (const cell of plan.cells) {
|
||||
const char = cell.arrowDirection ? diagramArrowHead(cell.arrowDirection, arrowHeadStyle) : cell.char
|
||||
const style = cell.fadeDistance === undefined ? lineStyle : transitionFadeCellStyle(context, cell.fadeDistance)
|
||||
setCell(grid, cell.x, cell.y, char, style, cell.fadeDistance === undefined ? undefined : context.sourceStateId)
|
||||
}
|
||||
if (plan.label) {
|
||||
setTransitionLabel(grid, plan.label.x, plan.label.y, plan.label.lines, transitionLabelStyle(context.active))
|
||||
}
|
||||
}
|
||||
|
||||
function drawTransitionJunctionPlans(
|
||||
grid: StateGrid,
|
||||
diagram: StateDiagram,
|
||||
bounds: Map<string, BoxBounds>,
|
||||
renderPlans: readonly StateTransitionRenderPlan[],
|
||||
activeState: string | undefined,
|
||||
activeTransitions: readonly StateDiagramActiveTransition[],
|
||||
): void {
|
||||
for (const plan of createStateTransitionJunctionPlans(diagram, bounds, renderPlans)) {
|
||||
const active = plan.transitions.some((transition) => isActiveTransition(transition, activeTransitions))
|
||||
const style =
|
||||
plan.state.id === activeState
|
||||
? "activeState"
|
||||
: active
|
||||
? "activeTransition"
|
||||
: plan.kind === "choice"
|
||||
? "choice"
|
||||
: "transition"
|
||||
setCell(grid, plan.bounds.left, plan.bounds.top, diagramLineGlyph(plan.connections, "rounded"), style)
|
||||
}
|
||||
}
|
||||
|
||||
function transitionFadeSource(
|
||||
statesById: Map<string, StateDiagramState>,
|
||||
transition: StateDiagramTransition,
|
||||
activeState: string | undefined,
|
||||
): FadeSourceStyle {
|
||||
if (transition.from === activeState) return "activeState"
|
||||
const source = statesById.get(transition.from)
|
||||
if (isHiddenCompositeMarker(source)) return "composite"
|
||||
if (source?.kind === "start") return "start"
|
||||
if (source?.kind === "end") return "end"
|
||||
if (source?.kind === "choice") return "choice"
|
||||
return "state"
|
||||
}
|
||||
|
||||
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
|
||||
const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
|
||||
const diagram = prepareVisibleStateDiagram(directedDiagram)
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
|
||||
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
|
||||
const activeTransitions = normalizeActiveTransitions(options.activeTransition)
|
||||
const { bounds, sizes, compositeBounds, noteBounds } = createStateDiagramLayout(diagram, {
|
||||
minStateGap,
|
||||
})
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
let allBounds = [...bounds.values(), ...noteBounds]
|
||||
let maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
let feedbackLaneY = maxY + 3
|
||||
let feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
|
||||
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
|
||||
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
const transitionTop = Math.min(
|
||||
0,
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (transitionTop < 0) {
|
||||
const dy = -transitionTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
feedbackLaneY += dy
|
||||
feedbackTopY += dy
|
||||
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
}
|
||||
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
|
||||
const contentTop = Math.min(
|
||||
0,
|
||||
...[...bounds.values(), ...noteBounds].map((bound) => bound.top),
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (contentTop < 0) {
|
||||
const dy = -contentTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
transitionPlans = translateTransitionPlans(transitionPlans, dy)
|
||||
}
|
||||
allBounds = [...bounds.values(), ...noteBounds]
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
const transitionLabelSizes = diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label))
|
||||
const maxTransitionLabelWidth = Math.max(0, ...transitionLabelSizes.map((size) => size.width))
|
||||
const maxTransitionLabelLines = Math.max(0, ...transitionLabelSizes.map((size) => size.height))
|
||||
const transitionRight = Math.max(
|
||||
maxX,
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.x + 1),
|
||||
...(plan.label ? [plan.label.x + measureStateTransitionLabel(plan.route.transition.label).width] : []),
|
||||
]),
|
||||
)
|
||||
const transitionBottom = Math.max(
|
||||
maxY,
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.y + 1),
|
||||
...(plan.label ? [plan.label.y + plan.label.lines.length] : []),
|
||||
]),
|
||||
)
|
||||
const grid = makeGrid(
|
||||
Math.max(maxX + Math.max(24, maxTransitionLabelWidth + 4), transitionRight + 2),
|
||||
Math.max(maxY + 8 + maxTransitionLabelLines, transitionBottom + 2),
|
||||
)
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
drawContainerFrame(
|
||||
grid,
|
||||
bound,
|
||||
composite.label,
|
||||
BorderChars[borderStyle],
|
||||
options.activeState === composite.id ? "activeState" : "composite",
|
||||
)
|
||||
}
|
||||
|
||||
for (const state of diagram.states) {
|
||||
const bound = bounds.get(state.id)
|
||||
const size = sizes.get(state.id)
|
||||
if (!bound || !size) continue
|
||||
drawBox(grid, state, bound, size.lines, options.activeState === state.id, borderStyle)
|
||||
}
|
||||
|
||||
for (const plan of transitionPlans) {
|
||||
const transition = plan.route.transition
|
||||
const fadeSource = transitionFadeSource(statesById, transition, options.activeState)
|
||||
const activeIndex = activeTransitionIndex(transition, activeTransitions)
|
||||
const active = activeIndex !== -1
|
||||
const fadeFromSource = activeIndex <= 0
|
||||
const drawContext: TransitionDrawContext = {
|
||||
fadeSource,
|
||||
active,
|
||||
fadeFromSource,
|
||||
sourceStateId: transition.from,
|
||||
}
|
||||
drawTransitionRenderPlan(grid, plan, arrowHeadStyle, drawContext)
|
||||
}
|
||||
|
||||
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans, options.activeState, activeTransitions)
|
||||
|
||||
for (const noteBound of noteBounds) {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
if (target) drawNote(grid, noteBound, target)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export function stateDiagramStartMarkerId(scope?: string): string {
|
||||
return scope ? `${scope}.__start` : "__start"
|
||||
}
|
||||
|
||||
export function stateDiagramEndMarkerId(scope?: string): string {
|
||||
return scope ? `${scope}.__end` : "__end"
|
||||
}
|
||||
|
||||
export function stateDiagramMarkerId(position: "from" | "to", scope?: string): string {
|
||||
return position === "from" ? stateDiagramStartMarkerId(scope) : stateDiagramEndMarkerId(scope)
|
||||
}
|
||||
|
||||
export function normalizeStateDiagramEndpoint(value: string, position: "from" | "to", scope?: string): string {
|
||||
return value === "[*]" ? stateDiagramMarkerId(position, scope) : value
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { renderStateDiagram, renderStateDiagramAnsi } from "./diagram.js"
|
||||
import type { StateDiagramAnsiOptions, StateDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export type {
|
||||
StateDiagram as Diagram,
|
||||
StateDiagramActiveTransition as ActiveTransition,
|
||||
StateDiagramActiveTransitionSelection as ActiveTransitionSelection,
|
||||
StateDiagramAnsiOptions as AnsiRenderOptions,
|
||||
StateDiagramAnsiTheme as Theme,
|
||||
StateDiagramArrowHeadStyle as ArrowHeadStyle,
|
||||
StateDiagramCompositeState as CompositeState,
|
||||
StateDiagramDirection as Direction,
|
||||
StateDiagramNote as Note,
|
||||
StateDiagramOptions as RenderableOptions,
|
||||
StateDiagramRenderOptions as PlainRenderOptions,
|
||||
StateDiagramState as State,
|
||||
StateDiagramStateColors as StateColors,
|
||||
StateDiagramTransition as Transition,
|
||||
} from "./types.js"
|
||||
export { isMermaidStateDiagram as is, parseMermaidStateDiagram as parse } from "./parser.js"
|
||||
export { StateDiagramRenderable as Renderable } from "./renderable.js"
|
||||
export { stateDiagramStateColorKey as stateColorKey } from "./style.js"
|
||||
|
||||
export interface RenderOptions extends StateDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid state diagram string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color ? renderStateDiagramAnsi(content, rest) : renderStateDiagram(content, rest as StateDiagramRenderOptions)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StateDiagram } from "./types.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
|
||||
describe("StateDiagramLayout", () => {
|
||||
test("lays out horizontal main-path states before branch states", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "main" },
|
||||
{ from: "A", to: "C", label: "branch" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
|
||||
expect(a.left).toBeLessThan(b.left)
|
||||
expect(c.top).toBeGreaterThan(a.top)
|
||||
})
|
||||
|
||||
test("aligns a reconverging side branch under the parallel main-path stage", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "Fork", to: "Upper", label: "upper" },
|
||||
{ from: "Fork", to: "Lower", label: "lower" },
|
||||
{ from: "Upper", to: "Join", label: "join" },
|
||||
{ from: "Lower", to: "Join", label: "join" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const upper = layout.bounds.get("Upper")!
|
||||
const lower = layout.bounds.get("Lower")!
|
||||
|
||||
expect(lower.centerX).toBe(upper.centerX)
|
||||
expect(lower.top).toBeGreaterThan(upper.top)
|
||||
})
|
||||
|
||||
test("places note bounds outside their target state", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
],
|
||||
transitions: [{ from: "A", to: "B", label: "next" }],
|
||||
composites: [],
|
||||
notes: [{ target: "A", position: "right", lines: ["note"] }],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const target = layout.bounds.get("A")!
|
||||
const note = layout.noteBounds[0]!
|
||||
|
||||
expect(note.left).toBeGreaterThanOrEqual(target.left + target.width)
|
||||
expect(note.lines).toEqual(["note"])
|
||||
})
|
||||
|
||||
test("widens only the horizontal gap that carries a long label", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "a transition label requiring substantially more room" },
|
||||
{ from: "B", to: "C", label: "ok" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
const longGap = b.left - (a.left + a.width)
|
||||
const shortGap = c.left - (b.left + b.width)
|
||||
|
||||
expect(longGap).toBeGreaterThan(shortGap)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user