mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-19 03:37:02 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bc9f6ea09 | |||
| aa8c1f6dac | |||
| 23c3a1461c |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Compile Effect-authored plugins to the runtime-neutral Promise and Standard Schema plugin contract.
|
||||
@@ -579,7 +579,6 @@
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -590,6 +589,7 @@
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
@@ -597,12 +597,14 @@
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"effect": "catalog:",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opencode-ai/theme",
|
||||
"@opentui/core",
|
||||
"@opentui/solid",
|
||||
"effect",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "./plugin-runtime.promise"
|
||||
import "./plugin-runtime.effect"
|
||||
|
||||
process.stdout.on("error", (error) => {
|
||||
if ("code" in error && error.code === "EPIPE") return
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
Agent,
|
||||
Command,
|
||||
Connection,
|
||||
Credential,
|
||||
Integration,
|
||||
Model,
|
||||
Plugin,
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.effect")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
Agent,
|
||||
Command,
|
||||
Connection,
|
||||
Credential,
|
||||
Integration,
|
||||
Model,
|
||||
Plugin,
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool: { Error: Tool.Error },
|
||||
}
|
||||
@@ -132,20 +132,10 @@ export const Plugin = sdk.Plugin
|
||||
export const Provider = sdk.Provider
|
||||
export const Reference = sdk.Reference
|
||||
export const Skill = sdk.Skill`
|
||||
const effectModule = promiseModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const define = sdk.Plugin.define`
|
||||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promiseToolModule = `export {}`
|
||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Error = sdk.Tool.Error
|
||||
`
|
||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||
import __cjs_mod__ from "node:module"
|
||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||
@@ -160,17 +150,11 @@ const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
|
||||
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
|
||||
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
})}
|
||||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-promise-plugin": promisePluginModule,
|
||||
"opencode:plugin-promise-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
})}
|
||||
__cjs_mod__.registerHooks({
|
||||
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
|
||||
|
||||
@@ -95,7 +95,7 @@ await Effect.runPromise(
|
||||
),
|
||||
write(
|
||||
emitEffectImported(effectContract, {
|
||||
module: "../../contract.js",
|
||||
module: "../../contract",
|
||||
api: "ClientApi",
|
||||
shapeModule: "../api/api.js",
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Stream, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract.js"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
ServerGetOutput,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
import type { Effect } from "effect"
|
||||
|
||||
export * from "./generated/index.js"
|
||||
export * from "./generated/index"
|
||||
export type {
|
||||
AgentApi,
|
||||
AppApi,
|
||||
@@ -47,4 +47,4 @@ export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client.js").make>>
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigAgentPlugin from "./agent.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { SessionCompaction } from "../../session/compaction.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigImagePlugin from "./image.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Image } from "../../image.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigInstructionPlugin from "./instruction.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { dirname, join } from "path"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigMCPPlugin from "./mcp.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigPolicyPlugin from "./policy.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigProviderPlugin from "./provider.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect } from "effect"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigReferencePlugin from "./reference.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigShellPlugin from "./shell.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigSkillPlugin from "./skill.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigSnapshotPlugin from "./snapshot.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigToolOutputPlugin from "./tool-output.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigWebSearchPlugin from "./websearch.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Watcher from "./watcher.js"
|
||||
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/core/plugin/definition"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app.js"
|
||||
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as AgentPlugin from "./agent.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as CommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { Location } from "../location.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Context as PluginContext, Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Scope } from "effect"
|
||||
|
||||
export type Context = PluginContext
|
||||
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
|
||||
|
||||
export function define<R>(plugin: PluginDefinition<R>) {
|
||||
return plugin
|
||||
}
|
||||
|
||||
export namespace Plugin {
|
||||
export type Context = PluginContext
|
||||
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
|
||||
export const define = <R>(plugin: PluginDefinition<R>) => plugin
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginHost from "./host.js"
|
||||
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Plugin } from "@opencode-ai/core/plugin/definition"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginInternal from "./internal.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Plugin } from "@opencode-ai/core/plugin/definition"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as PlanPlugin from "./plan.js"
|
||||
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Stream } from "effect"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type MantleSDK = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const AnthropicPlugin = define({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const CerebrasPlugin = define({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from "os"
|
||||
import { App } from "../../app.js"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from "os"
|
||||
import { App } from "../../app.js"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import { Effect } from "effect"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { App } from "../../app.js"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from "os"
|
||||
import { App } from "../../app.js"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const GitLabPlugin = define({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
function resolveProject(options: Record<string, any>) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const KiloPlugin = define({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const NvidiaPlugin = define({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "opencode.provider.openai.compatible",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
|
||||
export const OpenRouterPlugin = define({
|
||||
id: "opencode.provider.openrouter",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const VercelPlugin = define({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Clock, Effect, Option, Schema } from "effect"
|
||||
import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const ZenmuxPlugin = define({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SdkPlugins from "./sdk.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Plugin } from "@opencode-ai/core/plugin/definition"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
export * as SkillPlugin from "./skill.js"
|
||||
|
||||
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define, type Context } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Skill } from "../skill.js"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, Predicate, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -18,22 +17,13 @@ import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { Service } from "./supervisor-service.js"
|
||||
|
||||
const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
effect: Schema.declare<PluginDefinition["effect"]>(
|
||||
(input): input is PluginDefinition["effect"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
default: Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
@@ -119,8 +109,18 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
: `${entrypoint}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
if (
|
||||
Predicate.isObject(mod) &&
|
||||
Predicate.isObject(mod.default) &&
|
||||
"effect" in mod.default &&
|
||||
!("setup" in mod.default)
|
||||
) {
|
||||
return yield* Effect.fail(
|
||||
new Error("Effect plugins must be exported through Plugin.define from @opencode-ai/plugin/effect"),
|
||||
)
|
||||
}
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
const plugin = PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SystemPromptPlugin from "./system-prompt.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as VariantPlugin from "./variant.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WarmingPlugin from "./warming.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Clock, Duration, Effect, Scope } from "effect"
|
||||
import { Config } from "../config.js"
|
||||
import { SessionSchema } from "../session/schema.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchExa from "./exa.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { WebSearchMcp } from "./mcp.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchFirecrawl from "./firecrawl.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchParallel from "./parallel.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchTavily from "./tavily.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Duration, Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as EditTool from "./edit.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as GlobTool from "./glob.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as GrepTool from "./grep.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PatchTool from "./patch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as QuestionTool from "./question.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ReadTool from "./read.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { basename, dirname, join } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ShellTool from "./shell.js"
|
||||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SkillTool from "./skill.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebFetchTool from "./webfetch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchTool from "./websearch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as WriteTool from "./write.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WellKnownPlugin from "./plugin.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/core/plugin/definition"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
@@ -146,6 +146,7 @@ describe("PluginSupervisor config", () => {
|
||||
"-*",
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/raw-effect-plugin.ts"),
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
@@ -162,12 +163,14 @@ describe("PluginSupervisor config", () => {
|
||||
expect(output).toEqual([
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/raw-effect-plugin.ts"),
|
||||
])
|
||||
expect(
|
||||
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
|
||||
).toEqual([
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/raw-effect-plugin.ts") },
|
||||
])
|
||||
}),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import type { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
|
||||
import { Effect, type Scope } from "effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/core/plugin/definition"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/core/plugin/definition"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default {
|
||||
id: "raw-effect-plugin",
|
||||
effect: () => Effect.void,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { DateTime, Effect, Fiber, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -25,6 +25,7 @@ import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const EffectPlugin = await import("@opencode-ai/plugin/effect")
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("adapts plugin storage methods", () =>
|
||||
@@ -469,11 +470,11 @@ describe("fromPromise", () => {
|
||||
const events: string[] = []
|
||||
const promisePlugin = define({
|
||||
id: "promise-cleanup",
|
||||
setup: async () => {
|
||||
setup: async (context) => {
|
||||
events.push("setup")
|
||||
return async () => {
|
||||
await Promise.resolve()
|
||||
events.push("cleanup")
|
||||
events.push(context.signal.aborted ? "cleanup-aborted" : "cleanup-active")
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -485,7 +486,7 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(events).toEqual(["setup", "cleanup"])
|
||||
expect(events).toEqual(["setup", "cleanup-aborted"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -534,6 +535,77 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs Effect-authored tool codecs through the Promise boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const interrupted: string[] = []
|
||||
const effectPlugin = EffectPlugin.Plugin.define({
|
||||
id: "effect-tool",
|
||||
effect: (context) =>
|
||||
context.tool.transform((tools) =>
|
||||
tools.add({
|
||||
name: "increment",
|
||||
options: { codemode: false },
|
||||
description: "Increment",
|
||||
input: Schema.FiniteFromString,
|
||||
output: Schema.FiniteFromString,
|
||||
execute: (input) => {
|
||||
if (input === 42) {
|
||||
return Effect.fail(
|
||||
new Tool.Error({ message: "cannot increment 42", metadata: { reason: "reserved" } }),
|
||||
)
|
||||
}
|
||||
if (input === 43) {
|
||||
return Effect.sync(() => interrupted.push("started")).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.ensuring(Effect.sync(() => interrupted.push("finalized"))),
|
||||
)
|
||||
}
|
||||
return Effect.succeed({ output: input + 1 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(effectPlugin).effect(host)
|
||||
|
||||
expect(
|
||||
yield* (yield* registry.snapshot()).execute({
|
||||
sessionID: Session.ID.make("ses_effect_tool"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_effect_tool"),
|
||||
progress: () => Effect.void,
|
||||
call: { type: "tool-call", id: "call_effect_tool", name: "increment", input: "41" },
|
||||
}),
|
||||
).toMatchObject({ output: "42" })
|
||||
expect(
|
||||
yield* Effect.flip(
|
||||
(yield* registry.snapshot()).execute({
|
||||
sessionID: Session.ID.make("ses_effect_tool"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_effect_tool_error"),
|
||||
progress: () => Effect.void,
|
||||
call: { type: "tool-call", id: "call_effect_tool_error", name: "increment", input: "42" },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ _tag: "Tool.Error", message: "cannot increment 42", metadata: { reason: "reserved" } })
|
||||
const execution = yield* (yield* registry.snapshot())
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_effect_tool"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_effect_tool_interrupt"),
|
||||
progress: () => Effect.void,
|
||||
call: { type: "tool-call", id: "call_effect_tool_interrupt", name: "increment", input: "43" },
|
||||
})
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
while (interrupted.length === 0) yield* Effect.yieldNow
|
||||
yield* Fiber.interrupt(execution)
|
||||
expect(interrupted).toEqual(["started", "finalized"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns content-only plugin results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"audit:layouts": "bun run script/layout-audit.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import {
|
||||
auditAllFixtures,
|
||||
summarizeAudits,
|
||||
worstAudits,
|
||||
type LayoutAudit,
|
||||
type LayoutMetrics,
|
||||
} from "../src/test/layout-audit/harness.js"
|
||||
|
||||
const outputPath = resolve(import.meta.dir, "../../../tmp/merman-layout-audit.md")
|
||||
const startedAt = performance.now()
|
||||
const audits = auditAllFixtures()
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
const summary = summarizeAudits(audits)
|
||||
|
||||
function label(audit: LayoutAudit): string {
|
||||
return `${audit.fixture.id} @${audit.viewport}`
|
||||
}
|
||||
|
||||
function metricTable(items: readonly LayoutAudit[]): string {
|
||||
return [
|
||||
"| Fixture | Viewport | Size | Area | Route length | Bends | Crossings | Shared cells | Overflow |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
...items.map(
|
||||
(audit) =>
|
||||
`| \`${audit.fixture.id}\` | ${audit.viewport} | ${audit.metrics.width}x${audit.metrics.height} | ${audit.metrics.area} | ${audit.metrics.routeLength} | ${audit.metrics.bends} | ${audit.metrics.crossings} | ${audit.metrics.sharedRouteCells} | ${audit.metrics.overflow} |`,
|
||||
),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function worstSection(metric: keyof LayoutMetrics): string {
|
||||
const worst = worstAudits(audits, metric)
|
||||
return [`### ${metric}`, "", metricTable(worst)].join("\n")
|
||||
}
|
||||
|
||||
function fixtureSection(audit: LayoutAudit): string {
|
||||
return [
|
||||
`<details${audit.fixture.curated || audit.violations.length > 0 ? " open" : ""}>`,
|
||||
`<summary><code>${label(audit)}</code> · ${audit.metrics.width}x${audit.metrics.height} · area ${audit.metrics.area} · bends ${audit.metrics.bends} · crossings ${audit.metrics.crossings} · overflow ${audit.metrics.overflow}</summary>`,
|
||||
"",
|
||||
...(audit.violations.length > 0 ? ["Violations:", "", ...audit.violations.map((item) => `- ${item}`), ""] : []),
|
||||
"Source:",
|
||||
"",
|
||||
"```mermaid",
|
||||
audit.fixture.source,
|
||||
"```",
|
||||
"",
|
||||
"Rendered output:",
|
||||
"",
|
||||
"```text",
|
||||
audit.output,
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const grouped = Map.groupBy(audits, (audit) => `${audit.fixture.kind}/${audit.fixture.family}`)
|
||||
const violations = audits.flatMap((audit) => audit.violations.map((violation) => `${label(audit)}: ${violation}`))
|
||||
const markdown = [
|
||||
"# Merman Layout Audit",
|
||||
"",
|
||||
`Generated from ${new Set(audits.map((audit) => audit.fixture.id)).size} sources and ${audits.length} layout runs.`,
|
||||
"",
|
||||
`Structural violations: **${violations.length}**`,
|
||||
"",
|
||||
"## Aggregate Metrics",
|
||||
"",
|
||||
"```json",
|
||||
JSON.stringify(summary, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"## Worst Offenders",
|
||||
"",
|
||||
...(["area", "bends", "crossings", "sharedRouteCells", "overflow"] as const).flatMap((metric) => [
|
||||
worstSection(metric),
|
||||
"",
|
||||
]),
|
||||
"## Fixtures",
|
||||
"",
|
||||
...[...grouped.entries()].flatMap(([family, items]) => [
|
||||
`### ${family}`,
|
||||
"",
|
||||
metricTable(items),
|
||||
"",
|
||||
...items.flatMap((audit) => [fixtureSection(audit), ""]),
|
||||
]),
|
||||
].join("\n")
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await Bun.write(outputPath, markdown)
|
||||
|
||||
console.log(`Wrote ${audits.length} layout runs to ${outputPath} in ${elapsedMs.toFixed(0)}ms`)
|
||||
if (violations.length > 0) {
|
||||
console.error(violations.join("\n"))
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -43,8 +43,9 @@ function mergeFlowchartCell(
|
||||
if (incoming.style !== "edge") return incoming
|
||||
if (existing.style === "label") return existing
|
||||
if (incoming.char === " ") return existing
|
||||
if ((existing.style !== "edge" && existing.style !== "group") || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
if (existing.style !== "edge" || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char)) return existing
|
||||
if (DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
@@ -75,22 +76,23 @@ function drawNode(
|
||||
): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
const style: FlowchartCellStyle = node.shape === "database" ? "database" : "node"
|
||||
const border: FlowchartCellStyle = node.shape === "database" ? "databaseBorder" : "nodeBorder"
|
||||
|
||||
if (node.shape === "decision") {
|
||||
drawDiagramDiamond(
|
||||
bounds,
|
||||
(x, y, char) => grid.setCell(x, y, char, style),
|
||||
(x, y, char) => grid.setCell(x, y, char, border),
|
||||
diagramDiamondCharactersFromBorder(chars),
|
||||
)
|
||||
} else if (node.shape === "subroutine") {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawSubroutineNode(grid, bounds, chars, style)
|
||||
drawSubroutineNode(grid, bounds, chars, border)
|
||||
} else if (node.shape === "database") {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawDatabaseNode(grid, bounds, chars, style)
|
||||
drawDatabaseNode(grid, bounds, chars, border)
|
||||
} else {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, border))
|
||||
}
|
||||
|
||||
const textTop =
|
||||
@@ -270,7 +272,7 @@ function drawSourceConnectors(
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell && cell.style !== "label") {
|
||||
if (cell && cell.style !== "label" && !DIAGRAM_ARROW_HEADS.has(cell.char)) {
|
||||
grid.replaceCell(
|
||||
sourcePoint.x,
|
||||
sourcePoint.y,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor, TextAttributes } from "@opentui/core"
|
||||
import stringWidth from "string-width"
|
||||
import { diagramArrowHeadBetween } from "../core/drawing.js"
|
||||
import { orthogonalPathPoints } from "../core/geometry.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { deploymentArchitectureSource } from "../test/layout-audit/fixtures.js"
|
||||
import { drawFlowchartDiagramGrid as drawParsedFlowchartDiagramGrid } from "./drawing.js"
|
||||
import {
|
||||
DEFAULT_MIN_RANK_GAP,
|
||||
@@ -35,7 +38,7 @@ function routeRunsAlongHorizontalBorder(
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
if (from.y !== to.y || !borderYs.has(from.y)) continue
|
||||
if (Math.max(from.x, to.x) >= left && Math.min(from.x, to.x) <= right) return true
|
||||
if (Math.min(Math.max(from.x, to.x), right) > Math.max(Math.min(from.x, to.x), left)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -52,7 +55,7 @@ function routeRunsAlongVerticalBorder(
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
if (from.x !== to.x || !borderXs.has(from.x)) continue
|
||||
if (Math.max(from.y, to.y) >= top && Math.min(from.y, to.y) <= bottom) return true
|
||||
if (Math.min(Math.max(from.y, to.y), bottom) > Math.max(Math.min(from.y, to.y), top)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -114,6 +117,111 @@ function boundsIntersect(
|
||||
)
|
||||
}
|
||||
|
||||
function boundsContains(
|
||||
outer: { left: number; top: number; width: number; height: number },
|
||||
inner: { left: number; top: number; width: number; height: number },
|
||||
): boolean {
|
||||
return (
|
||||
inner.left >= outer.left &&
|
||||
inner.top >= outer.top &&
|
||||
inner.left + inner.width <= outer.left + outer.width &&
|
||||
inner.top + inner.height <= outer.top + outer.height
|
||||
)
|
||||
}
|
||||
|
||||
function routesIntersect(
|
||||
left: { points: readonly { x: number; y: number }[] },
|
||||
right: { points: readonly { x: number; y: number }[] },
|
||||
): boolean {
|
||||
const occupied = new Set(orthogonalPathPoints(left.points).map((point) => `${point.x}:${point.y}`))
|
||||
return orthogonalPathPoints(right.points).some((point) => occupied.has(`${point.x}:${point.y}`))
|
||||
}
|
||||
|
||||
function renderedDimensions(output: string): { width: number; height: number } {
|
||||
const lines = output.split("\n")
|
||||
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
|
||||
}
|
||||
|
||||
function expectResponsiveFlowchartValid(content: string, layoutMaxWidth: number) {
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const options = { compact: true, layoutMaxWidth }
|
||||
const layout = layoutParsedFlowchartDiagram(diagram, options)
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram, options)
|
||||
const output = renderFlowchartDiagram(content, options)
|
||||
const nodes = [...layout.bounds.values()]
|
||||
|
||||
expect(layout.diagram.direction).toBe("TD")
|
||||
for (let left = 0; left < nodes.length; left++) {
|
||||
for (let right = left + 1; right < nodes.length; right++) {
|
||||
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
|
||||
}
|
||||
}
|
||||
for (const route of layout.routes) {
|
||||
expect(route.points.length).toBeGreaterThanOrEqual(2)
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
expect(from.x === to.x || from.y === to.y).toBe(true)
|
||||
}
|
||||
expect(terminalPointsTowardBounds(route, layout.bounds.get(route.edge.to)!)).toBe(true)
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
if (route.edge.label) expect(output).toContain(route.edge.label)
|
||||
}
|
||||
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
|
||||
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const frame = layout.subgraphBounds.get(subgraph.id)!
|
||||
for (const nodeId of subgraph.nodeIds) expect(boundsContains(frame, layout.bounds.get(nodeId)!)).toBe(true)
|
||||
expect(output).toContain(subgraph.label)
|
||||
}
|
||||
for (const node of layout.bounds.values()) {
|
||||
for (const line of node.lines) expect(output).toContain(line)
|
||||
}
|
||||
|
||||
const widestContent = Math.max(
|
||||
...nodes.map((node) => node.width),
|
||||
...layout.routes.flatMap((route) =>
|
||||
route.edge.label ? [flowchartRouteLabelLayout(route, visualLength).width] : [],
|
||||
),
|
||||
)
|
||||
const dimensions = renderedDimensions(output)
|
||||
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(
|
||||
layoutMaxWidth + widestContent + 4,
|
||||
)
|
||||
return { dimensions, layout, output }
|
||||
}
|
||||
|
||||
function generatedWideRankFlowchart(count: number): string {
|
||||
const labels = [
|
||||
"地域 gateway Ω",
|
||||
"界面 worker λ",
|
||||
"Long-running synchronization service",
|
||||
"Cache café 🚀",
|
||||
"Audit and observability pipeline",
|
||||
"Provider μ endpoint",
|
||||
"Fallback Ж service",
|
||||
"Archive 数据 lake",
|
||||
"Terminal résumé queue",
|
||||
]
|
||||
const branches = labels
|
||||
.slice(0, count)
|
||||
.flatMap((label, index) => [
|
||||
` Hub ${index === 0 ? "-->|dispatch across regions and providers|" : "-->"} N${index}[${label}]`,
|
||||
` N${index} --> Join`,
|
||||
])
|
||||
return [
|
||||
"flowchart LR",
|
||||
" Start[Client α] --> Hub",
|
||||
" subgraph Services [地域 services Ω]",
|
||||
" Hub[Dispatch hub]",
|
||||
...branches,
|
||||
" Join[Join results]",
|
||||
" end",
|
||||
" Join --> Done[Complete ✓]",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function expectFlowchartRoutesAvoidUnrelatedNodes(layout: ReturnType<typeof layoutFlowchartDiagram>): void {
|
||||
for (const route of layout.routes) {
|
||||
for (const [id, bounds] of layout.bounds) {
|
||||
@@ -327,6 +435,52 @@ describe("FlowchartDiagram", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
test.each(
|
||||
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
|
||||
[false, true].map((compact) => ({ direction, compact })),
|
||||
),
|
||||
)(
|
||||
"preserves every target arrowhead after painting $direction routes with compact=$compact",
|
||||
({ direction, compact }) => {
|
||||
const content = `flowchart ${direction}
|
||||
A[A]
|
||||
B[B]
|
||||
C[C]
|
||||
D[D]
|
||||
A --> A
|
||||
A --> C
|
||||
C --> B`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram, { compact })
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram, { compact })
|
||||
|
||||
for (const route of layout.routes) {
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(
|
||||
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
|
||||
[false, true].map((compact) => ({ direction, compact })),
|
||||
),
|
||||
)("keeps crossed endpoint-disjoint $direction routes separate with compact=$compact", ({ direction, compact }) => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart ${direction}
|
||||
A[A]
|
||||
B[B]
|
||||
C[C]
|
||||
D[D]
|
||||
A --> C
|
||||
D --> B`,
|
||||
{ compact },
|
||||
)
|
||||
|
||||
expect(layout.routes).toHaveLength(2)
|
||||
expect(routesIntersect(layout.routes[0]!, layout.routes[1]!)).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps vertical feedback labels clear of unrelated nodes", () => {
|
||||
const content = `flowchart TD
|
||||
S[Source] --> A[Alpha]
|
||||
@@ -613,6 +767,7 @@ describe("FlowchartDiagram", () => {
|
||||
const loops = layout.routes.filter((route) => route.edge.from === "B" && route.edge.to === "B")
|
||||
|
||||
expect(loops).toHaveLength(3)
|
||||
expect(new Set(loops.map((route) => JSON.stringify(route.points))).size).toBe(3)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -811,6 +966,119 @@ describe("FlowchartDiagram", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
test("wraps the real deployment chart responsively without losing content or geometry", () => {
|
||||
const expected = new Map([
|
||||
[60, { width: 82, height: 108 }],
|
||||
[80, { width: 97, height: 85 }],
|
||||
[120, { width: 143, height: 77 }],
|
||||
[160, { width: 163, height: 69 }],
|
||||
])
|
||||
const results = [...expected].map(([budget, dimensions]) => {
|
||||
const result = expectResponsiveFlowchartValid(deploymentArchitectureSource, budget)
|
||||
expect(result.dimensions).toEqual(dimensions)
|
||||
for (const frame of result.layout.subgraphBounds.values()) {
|
||||
for (const other of result.layout.subgraphBounds.values()) {
|
||||
if (frame !== other) expect(boundsIntersect(frame, other)).toBe(false)
|
||||
}
|
||||
}
|
||||
return result.dimensions
|
||||
})
|
||||
|
||||
for (let index = 1; index < results.length; index++) {
|
||||
expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
|
||||
}
|
||||
})
|
||||
|
||||
test.each([7, 9])("wraps generated %s-node Unicode subgraph ranks across width targets", (count) => {
|
||||
const results = [60, 80, 120].map(
|
||||
(budget) => expectResponsiveFlowchartValid(generatedWideRankFlowchart(count), budget).dimensions,
|
||||
)
|
||||
|
||||
for (let index = 1; index < results.length; index++) {
|
||||
expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
|
||||
expect(results[index - 1]!.height).toBeGreaterThanOrEqual(results[index]!.height)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps responsive local-direction subgraphs clear of sibling nodes", () => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart BT
|
||||
N0[Outside zero]
|
||||
subgraph Outer
|
||||
N2[Two]
|
||||
subgraph Inner
|
||||
direction LR
|
||||
N3[Three]
|
||||
N4[Four]
|
||||
end
|
||||
N5[X]
|
||||
end
|
||||
N7[Outside seven]
|
||||
N7 --> N2
|
||||
N2 -->|label 6| N5
|
||||
N5 --> N4
|
||||
N0 --> N7`,
|
||||
{ compact: true, layoutMaxWidth: 35 },
|
||||
)
|
||||
const nodes = [...layout.bounds.values()]
|
||||
|
||||
for (let left = 0; left < nodes.length; left++) {
|
||||
for (let right = left + 1; right < nodes.length; right++) {
|
||||
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps responsive sibling subgraph frames and long titles disjoint", () => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart TD
|
||||
subgraph Parent
|
||||
direction LR
|
||||
subgraph Left [A deliberately long left group title]
|
||||
direction LR
|
||||
A1[One] --> A2[Two]
|
||||
end
|
||||
subgraph Right [A deliberately long right group title]
|
||||
direction LR
|
||||
B1[Three] --> B2[Four]
|
||||
end
|
||||
A2 --> B1
|
||||
end`,
|
||||
{ compact: true, layoutMaxWidth: 35 },
|
||||
)
|
||||
|
||||
expect(boundsIntersect(layout.subgraphBounds.get("Left")!, layout.subgraphBounds.get("Right")!)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not change parallel routes for a non-binding width target", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
|
||||
A[A] -->|one| B[B]
|
||||
A -->|two| B`)
|
||||
const unconstrained = layoutParsedFlowchartDiagram(diagram, { compact: true })
|
||||
const nonBinding = layoutParsedFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: 1_000 })
|
||||
|
||||
expect(nonBinding.routes.map((route) => route.points)).toEqual(unconstrained.routes.map((route) => route.points))
|
||||
})
|
||||
|
||||
test("keeps responsive fan-out labels inside the width target", () => {
|
||||
const content = `flowchart TD
|
||||
subgraph Group
|
||||
S[Source]
|
||||
S -->|route 0 detail| N0[Node 0]
|
||||
S -->|route 1 detail| N1[Node 1]
|
||||
S -->|route 2 detail| N2[Node 2]
|
||||
S -->|route 3 detail| N3[Node 3]
|
||||
end`
|
||||
const layout = layoutFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
|
||||
const output = renderFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
|
||||
|
||||
for (const route of layout.routes) {
|
||||
const label = flowchartRouteLabelLayout(route, visualLength)
|
||||
expect(label.point.x + label.width).toBeLessThanOrEqual(30)
|
||||
}
|
||||
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(34)
|
||||
})
|
||||
|
||||
test("parses Mermaid flowchart nodes and standard arrows", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`
|
||||
flowchart TD
|
||||
@@ -1349,6 +1617,22 @@ flowchart LR
|
||||
expect(output).not.toContain("<br")
|
||||
})
|
||||
|
||||
test.each(
|
||||
(["TD", "BT"] as const).flatMap((direction) =>
|
||||
[false, true].flatMap((compact) => [2, 4].map((lines) => ({ direction, compact, lines }))),
|
||||
),
|
||||
)(
|
||||
"keeps $lines-line $direction labels off both terminal rows with compact=$compact",
|
||||
({ direction, compact, lines }) => {
|
||||
const label = Array.from({ length: lines }, (_, index) => `line ${index + 1}`).join("<br/>")
|
||||
const route = layoutFlowchartDiagram(`flowchart ${direction}\n A[A] -->|${label}| B[B]`, { compact }).routes[0]!
|
||||
const layout = flowchartRouteLabelLayout(route, visualLength)
|
||||
const terminals = new Set([route.points[0]!.y, route.points.at(-1)!.y])
|
||||
|
||||
for (let y = layout.point.y; y < layout.point.y + layout.height; y++) expect(terminals.has(y)).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
test("expands canvas for multiline back-edge labels", () => {
|
||||
const output = renderFlowchartDiagram(`flowchart TD
|
||||
A --> B
|
||||
@@ -1393,10 +1677,10 @@ graph LR
|
||||
expect(output).toContain("API")
|
||||
expect(output).toContain("DB")
|
||||
expect(output).toContain("╭─ Web App ")
|
||||
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).toContain("┼")
|
||||
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).not.toContain("┼")
|
||||
})
|
||||
|
||||
test("merges horizontal routes through vertical subgraph borders", () => {
|
||||
test("breaks vertical subgraph borders where horizontal routes pass through", () => {
|
||||
const content = `flowchart LR
|
||||
Outside[Outside] --> Inside
|
||||
subgraph Group
|
||||
@@ -1408,13 +1692,14 @@ graph LR
|
||||
const group = layout.subgraphBounds.get("Group")!
|
||||
const crossing = { x: group.left, y: layout.routes[0]!.points.at(-1)!.y }
|
||||
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
|
||||
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
|
||||
expect(grid.getCell(crossing.x, crossing.y + 1)?.char).toBe("│")
|
||||
})
|
||||
|
||||
test("merges vertical routes through horizontal subgraph borders", () => {
|
||||
test("breaks horizontal subgraph borders where vertical routes pass through", () => {
|
||||
const content = `flowchart TD
|
||||
Outside[Outside] --> Inside
|
||||
subgraph Outer [O]
|
||||
@@ -1428,7 +1713,8 @@ graph LR
|
||||
const outer = layout.subgraphBounds.get("Outer")!
|
||||
const crossing = { x: layout.routes[0]!.points[0]!.x, y: outer.top }
|
||||
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("│")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
|
||||
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x + 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
|
||||
@@ -1466,21 +1752,20 @@ graph LR
|
||||
expect(output).not.toContain("<br")
|
||||
})
|
||||
|
||||
test("merges transition lines through subgraph frame borders", () => {
|
||||
const output = renderFlowchartDiagram(`
|
||||
flowchart TD
|
||||
subgraph Verse [verse]
|
||||
direction LR
|
||||
A[A] --> B[B]
|
||||
C[C] --> D[D]
|
||||
test("keeps long Unicode subgraph titles from replacing entering arrowheads", () => {
|
||||
const content = `flowchart TD
|
||||
U[Up] --> A
|
||||
subgraph G [界界界界界界]
|
||||
A[A]
|
||||
end
|
||||
B --> Join
|
||||
D --> Join
|
||||
`)
|
||||
const crossingLines = output.split("\n").filter((line) => line.includes("Join") || line.includes("├"))
|
||||
A --> D[Down]`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const entry = layout.routes.find((route) => route.edge.from === "U")!
|
||||
const end = entry.points.at(-1)!
|
||||
|
||||
expect(output).toContain(" verse ")
|
||||
expect(crossingLines.join("\n").match(/┼/g)).toHaveLength(2)
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(entry.points.at(-2)!, end))
|
||||
})
|
||||
|
||||
test("lays out subgraph-local directions independently from the outer flow", () => {
|
||||
@@ -1612,6 +1897,65 @@ flowchart TD
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps nested local-direction layouts rigid across direction and compact matrices", () => {
|
||||
const directions = ["LR", "RL", "TD", "BT"] as const
|
||||
for (const global of directions) {
|
||||
for (const outer of directions) {
|
||||
for (const inner of directions) {
|
||||
for (const compact of [false, true]) {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart ${global}
|
||||
X[X] --> A
|
||||
subgraph Outer [Outer]
|
||||
direction ${outer}
|
||||
subgraph Inner [Inner]
|
||||
direction ${inner}
|
||||
A[A] --> B[B]
|
||||
end
|
||||
B --> C[C]
|
||||
end
|
||||
C --> Y[Y]`,
|
||||
{ compact },
|
||||
)
|
||||
const nodes = [...layout.bounds.values()]
|
||||
const innerFrame = layout.subgraphBounds.get("Inner")!
|
||||
const outerFrame = layout.subgraphBounds.get("Outer")!
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
|
||||
for (let left = 0; left < nodes.length; left++) {
|
||||
for (let right = left + 1; right < nodes.length; right++) {
|
||||
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(boundsContains(innerFrame, a)).toBe(true)
|
||||
expect(boundsContains(innerFrame, b)).toBe(true)
|
||||
expect(boundsContains(outerFrame, innerFrame)).toBe(true)
|
||||
expect(boundsContains(outerFrame, c)).toBe(true)
|
||||
expect(layout.routes.every((route) => route.points.length >= 2)).toBe(true)
|
||||
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
|
||||
for (const frame of layout.subgraphBounds.values()) {
|
||||
for (const route of layout.routes) {
|
||||
expect(routeRunsAlongHorizontalBorder(route, frame)).toBe(false)
|
||||
expect(routeRunsAlongVerticalBorder(route, frame)).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (inner === "LR") expect(b.left).toBeGreaterThan(a.left)
|
||||
if (inner === "RL") expect(b.left).toBeLessThan(a.left)
|
||||
if (inner === "TD") expect(b.top).toBeGreaterThan(a.top)
|
||||
if (inner === "BT") expect(b.top).toBeLessThan(a.top)
|
||||
if (outer === "LR") expect(c.centerX).toBeGreaterThan(b.centerX)
|
||||
if (outer === "RL") expect(c.centerX).toBeLessThan(b.centerX)
|
||||
if (outer === "TD") expect(c.centerY).toBeGreaterThan(b.centerY)
|
||||
if (outer === "BT") expect(c.centerY).toBeLessThan(b.centerY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("compacts stacked subgraph-local direction rows", () => {
|
||||
const layout = layoutFlowchartDiagram(`
|
||||
flowchart TD
|
||||
@@ -2037,8 +2381,10 @@ flowchart LR
|
||||
test("applies the global flowchart StyledText theme", () => {
|
||||
const grid = drawFlowchartDiagramGrid("flowchart LR\n A[Alpha] --> B[Beta]")
|
||||
const node = parseColor("#ff0000")
|
||||
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node }))
|
||||
const nodeBorder = parseColor("#0000ff")
|
||||
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node, nodeBorder }))
|
||||
|
||||
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && chunk.fg?.equals(node))).toBe(true)
|
||||
expect(styled.chunks.some((chunk) => chunk.text.includes("╭") && chunk.fg?.equals(nodeBorder))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
const LABEL_LINE_CLEARANCE = 2
|
||||
const LABEL_PADDING = 1
|
||||
const LABEL_TERMINAL_CLEARANCE = 1
|
||||
|
||||
export interface FlowchartEdgeLabelLayout {
|
||||
lines: string[]
|
||||
@@ -63,6 +64,11 @@ function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHei
|
||||
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
|
||||
}
|
||||
|
||||
const slot = insetSpan(segmentSpan(segment), LABEL_TERMINAL_CLEARANCE)
|
||||
if (spanCapacity(slot) >= labelHeight) {
|
||||
return clampPoint(point(segment.from.x + 1, centeredSpanStart(slot, labelHeight)))
|
||||
}
|
||||
|
||||
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
|
||||
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartNodeSize,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
@@ -364,7 +365,8 @@ function layoutRankedNodes(
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): Map<string, FlowchartNodeBounds> {
|
||||
targetWidth?: number,
|
||||
): { bounds: Map<string, FlowchartNodeBounds>; wrapped: boolean } {
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const ranks = rankNodes(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
@@ -406,6 +408,7 @@ function layoutRankedNodes(
|
||||
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
||||
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
||||
const bounds = new Map<string, FlowchartNodeBounds>()
|
||||
let wrapped = false
|
||||
|
||||
if (horizontal) {
|
||||
const columnWidths = rankKeys.map((rank) =>
|
||||
@@ -442,68 +445,141 @@ function layoutRankedNodes(
|
||||
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 rankBands = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
const roomyNodeGap = verticalNodeGap(rank)
|
||||
const naturalWidth =
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
||||
Math.max(0, nodes.length - 1) * verticalNodeGap(rank)
|
||||
Math.max(0, nodes.length - 1) * roomyNodeGap
|
||||
const labeledEdges = diagram.edges.filter(
|
||||
(edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
|
||||
)
|
||||
})
|
||||
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]!
|
||||
const nodeGap = verticalNodeGap(rank)
|
||||
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
|
||||
const needsLabelLanes =
|
||||
labeledEdges.length > 1 &&
|
||||
labeledEdges.some((edge) => {
|
||||
const targets = new Set(
|
||||
labeledEdges.filter((candidate) => candidate.from === edge.from).map((candidate) => candidate.to),
|
||||
)
|
||||
const sources = new Set(
|
||||
labeledEdges.filter((candidate) => candidate.to === edge.to).map((candidate) => candidate.from),
|
||||
)
|
||||
const grouped = (ids: readonly string[]) =>
|
||||
!diagram.subgraphs?.length ||
|
||||
diagram.subgraphs.some((subgraph) => ids.every((id) => subgraph.nodeIds.includes(id)))
|
||||
return (
|
||||
(targets.size > 1 && grouped([edge.from, ...targets])) ||
|
||||
(sources.size > 1 && grouped([edge.to, ...sources]))
|
||||
)
|
||||
})
|
||||
const nodeGap =
|
||||
targetWidth !== undefined && naturalWidth > targetWidth && !needsLabelLanes ? minNodeGap : roomyNodeGap
|
||||
const bands: { nodes: FlowchartNode[]; width: number; height: number }[] = []
|
||||
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 + nodeGap
|
||||
const current = bands.at(-1)
|
||||
const width = current ? current.width + nodeGap + size.width : size.width
|
||||
if (current && targetWidth !== undefined && width > targetWidth) {
|
||||
wrapped = true
|
||||
bands.push({ nodes: [node], width: size.width, height: size.height })
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
bands.push({ nodes: [node], width: size.width, height: size.height })
|
||||
continue
|
||||
}
|
||||
current.nodes.push(node)
|
||||
current.width = width
|
||||
current.height = Math.max(current.height, size.height)
|
||||
}
|
||||
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
|
||||
return { bands, nodeGap }
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rankBands.flatMap((rank) => rank.bands.map((band) => band.width)))
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankBands[rankIndex]!
|
||||
for (const [bandIndex, band] of rank.bands.entries()) {
|
||||
let x = Math.floor((canvasWidth - band.width) / 2)
|
||||
for (const node of band.nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((band.height - 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 + rank.nodeGap
|
||||
}
|
||||
y += band.height + (bandIndex < rank.bands.length - 1 ? minNodeGap : 0)
|
||||
}
|
||||
y += verticalGaps[rankIndex] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return bounds
|
||||
return { bounds, wrapped }
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): void {
|
||||
targetWidth?: number,
|
||||
): boolean {
|
||||
let wrapped = false
|
||||
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 childSubgraphs = (diagram.subgraphs ?? []).filter((child) => child.parentId === subgraph.id)
|
||||
const coveredNodeIds = new Set(childSubgraphs.flatMap((child) => [...collectSubgraphNodeIds(diagram, child.id)]))
|
||||
const items = [
|
||||
...childSubgraphs.flatMap((child) => {
|
||||
const nodeIds = [...collectSubgraphNodeIds(diagram, child.id)]
|
||||
const content = boundsFromChildren(nodeIds.flatMap((id) => nodeBounds.get(id) ?? []))
|
||||
const bounds = content ? subgraphBoundFromChildren(child.id, child.label, [content]) : undefined
|
||||
return bounds ? [{ id: `subgraph:${child.id}`, nodeIds, bounds, childId: child.id }] : []
|
||||
}),
|
||||
...subgraph.nodeIds.flatMap((id) => {
|
||||
if (coveredNodeIds.has(id)) return []
|
||||
const bounds = nodeBounds.get(id)
|
||||
return bounds ? [{ id, nodeIds: [id], bounds, childId: undefined }] : []
|
||||
}),
|
||||
]
|
||||
if (items.length === 0) continue
|
||||
|
||||
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
|
||||
const currentBounds = boundsFromChildren(items.map((item) => item.bounds))
|
||||
if (!currentBounds) continue
|
||||
|
||||
const itemByEndpoint = new Map<string, string>()
|
||||
for (const item of items) {
|
||||
for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id)
|
||||
if (item.childId) itemByEndpoint.set(item.childId, item.id)
|
||||
}
|
||||
const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" }))
|
||||
const localDiagram: FlowchartDiagram = {
|
||||
direction: subgraph.direction,
|
||||
nodes,
|
||||
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
|
||||
edges: diagram.edges.flatMap((edge) => {
|
||||
const from = itemByEndpoint.get(edge.from)
|
||||
const to = itemByEndpoint.get(edge.to)
|
||||
return from && to && from !== to ? [{ ...edge, from, to }] : []
|
||||
}),
|
||||
subgraphs: [],
|
||||
}
|
||||
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
|
||||
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
|
||||
const itemSizes = new Map(
|
||||
items.map((item) => [item.id, { width: item.bounds.width, height: item.bounds.height, lines: [item.id] }]),
|
||||
)
|
||||
const localLayout = layoutRankedNodes(
|
||||
localDiagram,
|
||||
subgraph.direction,
|
||||
itemSizes,
|
||||
Math.max(minNodeGap, SUBGRAPH_PADDING_X * 2 + 1),
|
||||
requestedMinRankGap,
|
||||
targetWidth,
|
||||
)
|
||||
const localBounds = localLayout.bounds
|
||||
wrapped ||= localLayout.wrapped
|
||||
const localExtent = boundsFromChildren([...localBounds.values()])
|
||||
if (!localExtent) continue
|
||||
|
||||
@@ -512,11 +588,66 @@ function layoutLocalSubgraphDirections(
|
||||
const dx = targetLeft - localExtent.left
|
||||
const dy = targetTop - localExtent.top
|
||||
|
||||
for (const [nodeId, bound] of localBounds) {
|
||||
translateBounds(bound, dx, dy)
|
||||
nodeBounds.set(nodeId, bound)
|
||||
const translations = new Map<string, { dx: number; dy: number }>()
|
||||
for (const item of items) {
|
||||
const bound = localBounds.get(item.id)!
|
||||
const itemDx = bound.left + dx - item.bounds.left
|
||||
const itemDy = bound.top + dy - item.bounds.top
|
||||
for (const nodeId of item.nodeIds) translations.set(nodeId, { dx: itemDx, dy: itemDy })
|
||||
}
|
||||
|
||||
let groupOffset = { x: 0, y: 0 }
|
||||
if (targetWidth !== undefined) {
|
||||
const localNodeIds = new Set(translations.keys())
|
||||
const external = [...nodeBounds.entries()].filter(([id]) => !localNodeIds.has(id)).map(([, bound]) => bound)
|
||||
const overlaps = (offset: FlowchartPoint) =>
|
||||
[...translations].some(([id, translation]) => {
|
||||
const bound = nodeBounds.get(id)!
|
||||
const left = bound.left + translation.dx + offset.x
|
||||
const top = bound.top + translation.dy + offset.y
|
||||
return external.some(
|
||||
(other) =>
|
||||
left < other.left + other.width + minNodeGap &&
|
||||
left + bound.width + minNodeGap > other.left &&
|
||||
top < other.top + other.height + minNodeGap &&
|
||||
top + bound.height + minNodeGap > other.top,
|
||||
)
|
||||
})
|
||||
if (overlaps(groupOffset)) {
|
||||
const vertical = !isHorizontalDirection(diagram.direction)
|
||||
const sign = diagram.direction === "RL" || diagram.direction === "BT" ? -1 : 1
|
||||
let found = false
|
||||
search: for (let distance = 1; distance < 1_000; distance++) {
|
||||
const candidates = vertical
|
||||
? [
|
||||
{ x: 0, y: sign * distance },
|
||||
{ x: 0, y: -sign * distance },
|
||||
{ x: distance, y: 0 },
|
||||
{ x: -distance, y: 0 },
|
||||
]
|
||||
: [
|
||||
{ x: sign * distance, y: 0 },
|
||||
{ x: -sign * distance, y: 0 },
|
||||
{ x: 0, y: distance },
|
||||
{ x: 0, y: -distance },
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (overlaps(candidate)) continue
|
||||
groupOffset = candidate
|
||||
found = true
|
||||
break search
|
||||
}
|
||||
}
|
||||
if (!found) throw new Error(`Subgraph ${subgraph.id} has no collision-free responsive position`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [nodeId, translation] of translations) {
|
||||
const nodeBound = nodeBounds.get(nodeId)
|
||||
if (nodeBound) translateBounds(nodeBound, translation.dx + groupOffset.x, translation.dy + groupOffset.y)
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
||||
@@ -601,6 +732,7 @@ function separateTopLevelItems(
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
gap: number,
|
||||
targetWidth?: number,
|
||||
): boolean {
|
||||
const hasLocalDirection = (diagram.subgraphs ?? []).some(
|
||||
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
|
||||
@@ -734,6 +866,35 @@ function separateTopLevelItems(
|
||||
crossCursor = start + shift + size + gap
|
||||
}
|
||||
}
|
||||
if (targetWidth !== undefined && !horizontal) {
|
||||
const intersects = (left: (typeof items)[number], right: (typeof items)[number]): boolean =>
|
||||
[...left.nodeIds].some((leftId) => {
|
||||
const leftBounds = nodeBounds.get(leftId)!
|
||||
return [...right.nodeIds].some((rightId) => {
|
||||
const rightBounds = nodeBounds.get(rightId)!
|
||||
return (
|
||||
leftBounds.left <= rightBounds.left + rightBounds.width - 1 &&
|
||||
leftBounds.left + leftBounds.width - 1 >= rightBounds.left &&
|
||||
leftBounds.top <= rightBounds.top + rightBounds.height - 1 &&
|
||||
leftBounds.top + leftBounds.height - 1 >= rightBounds.top
|
||||
)
|
||||
})
|
||||
})
|
||||
for (let rightIndex = 1; rightIndex < items.length; rightIndex++) {
|
||||
const right = items[rightIndex]!
|
||||
for (let leftIndex = 0; leftIndex < rightIndex; leftIndex++) {
|
||||
const left = items[leftIndex]!
|
||||
if (!intersects(left, right)) continue
|
||||
const leftBounds = boundsFromChildren([...left.nodeIds].map((id) => nodeBounds.get(id)!))!
|
||||
const rightBounds = boundsFromChildren([...right.nodeIds].map((id) => nodeBounds.get(id)!))!
|
||||
const shift = reversed
|
||||
? leftBounds.top - gap - (rightBounds.top + rightBounds.height)
|
||||
: leftBounds.top + leftBounds.height + gap - rightBounds.top
|
||||
moved ||= shift !== 0
|
||||
moveItem(right, 0, shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
@@ -771,6 +932,7 @@ function layoutFlowchartWithDirection(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions,
|
||||
direction: FlowchartDirection,
|
||||
responsiveFallback = false,
|
||||
): FlowchartLayout {
|
||||
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
@@ -786,29 +948,65 @@ function layoutFlowchartWithDirection(
|
||||
: 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)
|
||||
const targetWidth =
|
||||
!horizontal && options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
|
||||
? Math.max(1, Math.trunc(options.layoutMaxWidth))
|
||||
: undefined
|
||||
const ranked = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap, targetWidth)
|
||||
const bounds = ranked.bounds
|
||||
const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
|
||||
const directionAligned = responsiveFallback || responsive || ranked.wrapped
|
||||
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
let routes: FlowchartEdgeRoute[]
|
||||
if (subgraphs.length === 0) {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
undefined,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
} else {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
undefined,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
const moved = separateTopLevelItems(
|
||||
diagram,
|
||||
bounds,
|
||||
subgraphBounds,
|
||||
Math.max(1, Math.floor(requestedMinRankGap / 2)),
|
||||
targetWidth,
|
||||
)
|
||||
if (moved) {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
undefined,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
}
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
subgraphBounds,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
|
||||
}
|
||||
freezeRouteLabelPoints(routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
@@ -834,5 +1032,5 @@ export function layoutFlowchartDiagram(
|
||||
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")
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD", true)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ export interface FlowchartDiagramRenderOptions {
|
||||
borderStyle?: BorderStyle
|
||||
minNodeGap?: number
|
||||
minRankGap?: number
|
||||
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
|
||||
/** Target rendered width. Oversized horizontal layouts fold vertically and broad vertical ranks wrap. */
|
||||
layoutMaxWidth?: number
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
lane,
|
||||
oppositeSide,
|
||||
orthogonalPath,
|
||||
orthogonalPathPoints,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
shiftPoint,
|
||||
@@ -138,11 +140,11 @@ function horizontalEdgePath(
|
||||
})
|
||||
}
|
||||
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds, laneOffset = 0): 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
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE + laneOffset
|
||||
const bottomLaneY = bounds.top + bounds.height + 1 + laneOffset
|
||||
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
|
||||
}
|
||||
|
||||
@@ -522,6 +524,8 @@ function routeVerticalFanIn(
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
directionAligned: boolean,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
@@ -530,8 +534,18 @@ function routeParallelEdges(
|
||||
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 parallelAxis = parallelLaneAxis(from, to)
|
||||
if (!from || !to) continue
|
||||
if (from.id === to.id) {
|
||||
let laneOffset = 0
|
||||
for (const edge of edges) {
|
||||
routes.push({ edge, points: selfEdgePath(from, laneOffset) })
|
||||
handled.add(edge)
|
||||
laneOffset++
|
||||
}
|
||||
continue
|
||||
}
|
||||
const parallelAxis =
|
||||
directionAligned && isVerticalDirection(directionForEdge(edges[0]!)) ? "x" : parallelLaneAxis(from, to)
|
||||
let previousRoute: FlowchartEdgeRoute | undefined
|
||||
for (const edge of edges) {
|
||||
const height = labelHeight(edge)
|
||||
@@ -539,7 +553,7 @@ function routeParallelEdges(
|
||||
parallelAxis === "x"
|
||||
? previousRoute
|
||||
? rightRenderExtent(previousRoute) + NODE_CLEARANCE
|
||||
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x)
|
||||
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + (directionAligned ? 1 : 0)
|
||||
: previousRoute
|
||||
? Math.max(...previousRoute.points.map((point) => point.y)) + (height > 1 ? height + 1 : 1)
|
||||
: Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + (height > 1 ? height : 0)
|
||||
@@ -795,6 +809,69 @@ function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly Flowc
|
||||
)
|
||||
}
|
||||
|
||||
function pathsIntersect(left: readonly FlowchartPoint[], right: readonly FlowchartPoint[]): boolean {
|
||||
const occupied = new Set(orthogonalPathPoints(left).map((point) => `${point.x}:${point.y}`))
|
||||
return orthogonalPathPoints(right).some((point) => occupied.has(`${point.x}:${point.y}`))
|
||||
}
|
||||
|
||||
function endpointDisjoint(left: FlowchartEdge, right: FlowchartEdge): boolean {
|
||||
return left.from !== right.from && left.from !== right.to && left.to !== right.from && left.to !== right.to
|
||||
}
|
||||
|
||||
function endpointConflictsWithRoutes(route: FlowchartEdgeRoute, otherRoutes: readonly FlowchartEdgeRoute[]): boolean {
|
||||
const source = route.points[0]
|
||||
const target = route.points.at(-1)
|
||||
if (!source || !target) return false
|
||||
return otherRoutes.some((other) => {
|
||||
const otherSource = other.points[0]
|
||||
const otherTarget = other.points.at(-1)
|
||||
return (
|
||||
(otherSource && target.x === otherSource.x && target.y === otherSource.y) ||
|
||||
(otherTarget && source.x === otherTarget.x && source.y === otherTarget.y)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function pathRunsAlongFrame(points: readonly FlowchartPoint[], bounds: FlowchartSubgraphBounds): boolean {
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = segmentBetween(points[index - 1]!, points[index]!)
|
||||
if (!segment) continue
|
||||
const span = segmentSpan(segment)
|
||||
if (
|
||||
segment.axis === "x" &&
|
||||
(segment.from.y === bounds.top || segment.from.y === bottom) &&
|
||||
Math.min(span.end, right) > Math.max(span.start, bounds.left)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
segment.axis === "y" &&
|
||||
(segment.from.x === bounds.left || segment.from.x === right) &&
|
||||
Math.min(span.end, bottom) > Math.max(span.start, bounds.top)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function subgraphTitleBounds(bounds: FlowchartSubgraphBounds): {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
return {
|
||||
left: bounds.left + 2,
|
||||
top: bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length,
|
||||
width: Math.max(...lines.map((line) => diagramTextWidth(` ${line} `))),
|
||||
height: lines.length,
|
||||
}
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
@@ -815,10 +892,23 @@ function avoidNodeObstacles(
|
||||
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
|
||||
return pathIntersectsBounds(candidate.points, bound, allowedContact)
|
||||
})
|
||||
const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
intersectsNode(candidate) ||
|
||||
allSubgraphBounds.some(
|
||||
(bound) =>
|
||||
pathRunsAlongFrame(candidate.points, bound) ||
|
||||
(bound.label.length > 0 && pathIntersectsBounds(candidate.points, subgraphTitleBounds(bound))),
|
||||
)
|
||||
const intersectsRoutingObstacle = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
intersectsStructuralObstacle(candidate) ||
|
||||
endpointConflictsWithRoutes(candidate, otherRoutes) ||
|
||||
otherRoutes.some(
|
||||
(other) => endpointDisjoint(candidate.edge, other.edge) && pathsIntersect(candidate.points, other.points),
|
||||
)
|
||||
const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
|
||||
const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined
|
||||
return (
|
||||
intersectsNode(candidate) ||
|
||||
intersectsRoutingObstacle(candidate) ||
|
||||
allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
|
||||
allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
@@ -839,19 +929,19 @@ function avoidNodeObstacles(
|
||||
const rightBusXs = [
|
||||
...new Set([
|
||||
rightBusX,
|
||||
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + BUS_CLEARANCE)),
|
||||
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + NODE_CLEARANCE)),
|
||||
]),
|
||||
].sort((left, right) => left - right)
|
||||
const leftBusXs = [
|
||||
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - BUS_CLEARANCE))]),
|
||||
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - NODE_CLEARANCE))]),
|
||||
].sort((left, right) => right - left)
|
||||
const topBusYs = [
|
||||
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - BUS_CLEARANCE))]),
|
||||
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - NODE_CLEARANCE))]),
|
||||
].sort((left, right) => right - left)
|
||||
const bottomBusYs = [
|
||||
...new Set([
|
||||
bottomBusY,
|
||||
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + BUS_CLEARANCE)),
|
||||
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + NODE_CLEARANCE)),
|
||||
]),
|
||||
].sort((left, right) => left - right)
|
||||
const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4)))
|
||||
@@ -953,7 +1043,7 @@ function avoidNodeObstacles(
|
||||
if (from.id === to.id)
|
||||
return (
|
||||
shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(selfLoops, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(selfLoops, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
route
|
||||
)
|
||||
const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!)
|
||||
@@ -1002,8 +1092,8 @@ function avoidNodeObstacles(
|
||||
shortest(sameSides, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
route
|
||||
)
|
||||
}
|
||||
@@ -1012,8 +1102,8 @@ function avoidNodeObstacles(
|
||||
shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
attachments.find((candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
route
|
||||
)
|
||||
}
|
||||
@@ -1023,6 +1113,8 @@ function avoidLabelOverlap(
|
||||
otherRoutes: readonly FlowchartEdgeRoute[],
|
||||
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
targetWidth?: number,
|
||||
includeLabelWidth = true,
|
||||
): FlowchartEdgeRoute {
|
||||
if (!route.edge.label) return route
|
||||
const nodeBounds = [...bounds.values()]
|
||||
@@ -1040,7 +1132,14 @@ function avoidLabelOverlap(
|
||||
{ left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 },
|
||||
]
|
||||
})
|
||||
const hasParallelRoute = otherRoutes.some(
|
||||
(other) => other.edge.from === route.edge.from && other.edge.to === route.edge.to,
|
||||
)
|
||||
const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean =>
|
||||
(targetWidth !== undefined &&
|
||||
(hasParallelRoute || !includeLabelWidth
|
||||
? label.point.x > targetWidth
|
||||
: label.point.x + label.width > targetWidth)) ||
|
||||
nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
|
||||
frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
@@ -1094,6 +1193,15 @@ function avoidLabelOverlap(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetWidth !== undefined && includeLabelWidth && current.point.x + current.width > targetWidth) {
|
||||
const x = Math.max(0, targetWidth - current.width)
|
||||
for (let distance = 0; distance < 100; distance++) {
|
||||
for (const y of distance === 0 ? [current.point.y] : [current.point.y - distance, current.point.y + distance]) {
|
||||
if (y < 0 || intersectsObstacle({ ...current, point: { x, y } })) continue
|
||||
return { ...route, labelPoint: { x, y } }
|
||||
}
|
||||
}
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
@@ -1102,6 +1210,8 @@ export function routeFlowchartEdges(
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
|
||||
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
targetWidth?: number,
|
||||
directionAligned = false,
|
||||
): FlowchartEdgeRoute[] {
|
||||
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
|
||||
const handled = new Set<FlowchartEdge>()
|
||||
@@ -1110,7 +1220,7 @@ export function routeFlowchartEdges(
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(routedDiagram, bounds, handled, routes)
|
||||
routeParallelEdges(routedDiagram, bounds, directionForEdge, directionAligned, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = routedDiagram.edges.filter(
|
||||
@@ -1145,11 +1255,109 @@ export function routeFlowchartEdges(
|
||||
for (let index = routes.length - 1; index >= 0; index--) {
|
||||
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
|
||||
}
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
const containers = (id: string) => {
|
||||
const ids = new Set<string>()
|
||||
let current = subgraphs.find((subgraph) => subgraph.nodeIds.includes(id))
|
||||
while (current) {
|
||||
ids.add(current.id)
|
||||
current = current.parentId ? subgraphById.get(current.parentId) : undefined
|
||||
}
|
||||
return ids
|
||||
}
|
||||
const groupedLabelEdge = (edge: FlowchartEdge) => {
|
||||
const fromContainers = containers(edge.from)
|
||||
if (![...containers(edge.to)].some((id) => fromContainers.has(id))) return false
|
||||
const targets = new Set(
|
||||
routedDiagram.edges
|
||||
.filter((candidate) => candidate.label && candidate.from === edge.from)
|
||||
.map((candidate) => candidate.to),
|
||||
)
|
||||
const sources = new Set(
|
||||
routedDiagram.edges
|
||||
.filter((candidate) => candidate.label && candidate.to === edge.to)
|
||||
.map((candidate) => candidate.from),
|
||||
)
|
||||
return targets.size > 1 || sources.size > 1
|
||||
}
|
||||
return routes.reduce<FlowchartEdgeRoute[]>((resolved, route, index) => {
|
||||
return [...resolved, avoidLabelOverlap(route, [...resolved, ...routes.slice(index + 1)], bounds, subgraphBounds)]
|
||||
const grouped = groupedLabelEdge(route.edge)
|
||||
return [
|
||||
...resolved,
|
||||
avoidLabelOverlap(
|
||||
route,
|
||||
[...resolved, ...routes.slice(index + 1)],
|
||||
bounds,
|
||||
subgraphBounds,
|
||||
targetWidth !== undefined && subgraphs.length > 0 && grouped ? Math.max(1, targetWidth - 5) : targetWidth,
|
||||
subgraphs.length === 0 || grouped,
|
||||
),
|
||||
]
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function avoidFlowchartFrameBorders(
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): void {
|
||||
const inside = (node: FlowchartNodeBounds, frame: FlowchartSubgraphBounds) =>
|
||||
node.left >= frame.left &&
|
||||
node.top >= frame.top &&
|
||||
node.left + node.width <= frame.left + frame.width &&
|
||||
node.top + node.height <= frame.top + frame.height
|
||||
|
||||
for (const route of routes) {
|
||||
const source = bounds.get(route.edge.from)
|
||||
const target = bounds.get(route.edge.to)
|
||||
for (const frame of subgraphBounds.values()) {
|
||||
const inward = Boolean(source && target && inside(source, frame) && inside(target, frame))
|
||||
const right = frame.left + frame.width - 1
|
||||
const bottom = frame.top + frame.height - 1
|
||||
const points: FlowchartPoint[] = [route.points[0]!]
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
const segment = segmentBetween(from, to)
|
||||
if (!segment) continue
|
||||
const span = segmentSpan(segment)
|
||||
const horizontalSide =
|
||||
segment.axis === "x" && Math.min(span.end, right) > Math.max(span.start, frame.left)
|
||||
? segment.from.y === frame.top
|
||||
? "top"
|
||||
: segment.from.y === bottom
|
||||
? "bottom"
|
||||
: undefined
|
||||
: undefined
|
||||
const verticalSide =
|
||||
segment.axis === "y" && Math.min(span.end, bottom) > Math.max(span.start, frame.top)
|
||||
? segment.from.x === frame.left
|
||||
? "left"
|
||||
: segment.from.x === right
|
||||
? "right"
|
||||
: undefined
|
||||
: undefined
|
||||
if (!horizontalSide && !verticalSide) {
|
||||
points.push(to)
|
||||
continue
|
||||
}
|
||||
|
||||
const offset = horizontalSide
|
||||
? horizontalSide === "top"
|
||||
? frame.top + (inward ? 1 : -1)
|
||||
: bottom + (inward ? -1 : 1)
|
||||
: verticalSide === "left"
|
||||
? frame.left + (inward ? 1 : -1)
|
||||
: right + (inward ? -1 : 1)
|
||||
if (horizontalSide) points.push({ x: from.x, y: offset }, { x: to.x, y: offset }, to)
|
||||
else points.push({ x: offset, y: from.y }, { x: offset, y: to.y }, to)
|
||||
}
|
||||
route.points = pathThrough(points)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
if (sourcePoint.x < bounds.left) return "left"
|
||||
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
|
||||
export type FlowchartBaseCellStyle = "node" | "database" | "edge" | "label" | "group"
|
||||
export type FlowchartBaseCellStyle = "node" | "nodeBorder" | "database" | "databaseBorder" | "edge" | "label" | "group"
|
||||
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
||||
@@ -22,7 +22,9 @@ export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetad
|
||||
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
||||
export const DEFAULT_THEME_RGB = {
|
||||
node: [228, 239, 232],
|
||||
nodeBorder: [141, 163, 151],
|
||||
database: [228, 239, 232],
|
||||
databaseBorder: [141, 163, 151],
|
||||
edge: [134, 225, 200],
|
||||
label: [134, 225, 200],
|
||||
group: [76, 99, 89],
|
||||
@@ -35,16 +37,20 @@ export function resolveFlowchartStyleColors(
|
||||
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
||||
): FlowchartStyleColors {
|
||||
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
||||
const nodeBorder = colors.nodeBorder ?? rgba(DEFAULT_THEME_RGB.nodeBorder)
|
||||
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
||||
const databaseBorder = colors.databaseBorder ?? rgba(DEFAULT_THEME_RGB.databaseBorder)
|
||||
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
||||
return {
|
||||
node,
|
||||
nodeBorder,
|
||||
database,
|
||||
databaseBorder,
|
||||
edge,
|
||||
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),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, nodeBorder, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, databaseBorder, edge),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { auditAllFixtures, auditFixture, summarizeAudits, worstAudits } from "./test/layout-audit/harness.js"
|
||||
import { layoutFixtures } from "./test/layout-audit/fixtures.js"
|
||||
|
||||
test("audits deterministic flowchart and state layout families", () => {
|
||||
const fixtures = layoutFixtures()
|
||||
const flowcharts = fixtures.filter((fixture) => fixture.kind === "flowchart")
|
||||
const states = fixtures.filter((fixture) => fixture.kind === "state")
|
||||
expect(flowcharts.length).toBeGreaterThanOrEqual(100)
|
||||
expect(states.length).toBeGreaterThanOrEqual(100)
|
||||
expect(new Set(fixtures.map((fixture) => fixture.id)).size).toBe(fixtures.length)
|
||||
|
||||
const startedAt = performance.now()
|
||||
const audits = auditAllFixtures()
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
const violations = audits.flatMap((audit) =>
|
||||
audit.violations.map((violation) => `${audit.fixture.id} @${audit.viewport}: ${violation}`),
|
||||
)
|
||||
const summary = summarizeAudits(audits)
|
||||
|
||||
expect(audits.length).toBeGreaterThanOrEqual(fixtures.length)
|
||||
expect(violations).toEqual([])
|
||||
expect(elapsedMs).toBeLessThan(35_000)
|
||||
for (const audit of audits.filter((audit) => audit.fixture.kind === "state")) {
|
||||
expect(audit.viewport).toBe(audit.fixture.profile === "short" ? 60 : audit.fixture.profile === "unicode" ? 80 : 120)
|
||||
}
|
||||
for (const id of ["state/chain/lr-long", "state/chain/rl-long"]) {
|
||||
const audit = audits.find((candidate) => candidate.fixture.id === id)!
|
||||
expect(audit.viewport).toBe(120)
|
||||
expect([audit.metrics.width, audit.metrics.height, audit.metrics.overflow]).toEqual([84, 41, 0])
|
||||
}
|
||||
expect(
|
||||
audits
|
||||
.filter((audit) => audit.fixture.id === "flowchart/deployment-architecture/curated")
|
||||
.map((audit) => audit.viewport),
|
||||
).toEqual([60, 80, 120])
|
||||
expect(summary.total.area.max).toBeLessThanOrEqual(11_011)
|
||||
expect(summary.total.area.p95).toBeLessThanOrEqual(5_313)
|
||||
expect(summary.total.bends.max).toBeLessThanOrEqual(30)
|
||||
expect(summary.total.bends.p95).toBeLessThanOrEqual(11)
|
||||
expect(summary.total.crossings.total).toBeLessThanOrEqual(40)
|
||||
expect(summary.total.crossings.max).toBeLessThanOrEqual(3)
|
||||
expect(summary.total.routeLength.max).toBeLessThanOrEqual(930)
|
||||
expect(summary.total.routeLength.p95).toBeLessThanOrEqual(364)
|
||||
expect(summary.total.sharedRouteCells.max).toBeLessThanOrEqual(547)
|
||||
expect(summary.total.sharedRouteCells.p95).toBeLessThanOrEqual(122)
|
||||
expect(summary.total.overflow.max).toBeLessThanOrEqual(170)
|
||||
expect(summary.total.overflow.p95).toBeLessThanOrEqual(99)
|
||||
expect(summary.state.crossings.total).toBe(0)
|
||||
|
||||
for (const fixture of [...Map.groupBy(fixtures, (candidate) => `${candidate.kind}/${candidate.family}`).values()].map(
|
||||
(family) => family[0]!,
|
||||
)) {
|
||||
const first = auditFixture(fixture, 80)
|
||||
const second = auditFixture(fixture, 80)
|
||||
expect(second.output).toBe(first.output)
|
||||
expect(second.metrics).toEqual(first.metrics)
|
||||
expect(second.violations).toEqual(first.violations)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[layout-audit] ${fixtures.length} sources, ${audits.length} runs, ${elapsedMs.toFixed(0)}ms`,
|
||||
JSON.stringify({
|
||||
summary,
|
||||
worst: {
|
||||
area: worstAudits(audits, "area", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.area]),
|
||||
bends: worstAudits(audits, "bends", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.bends]),
|
||||
crossings: worstAudits(audits, "crossings", 3).map((audit) => [
|
||||
audit.fixture.id,
|
||||
audit.viewport,
|
||||
audit.metrics.crossings,
|
||||
]),
|
||||
overflow: worstAudits(audits, "overflow", 3).map((audit) => [
|
||||
audit.fixture.id,
|
||||
audit.viewport,
|
||||
audit.metrics.overflow,
|
||||
]),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 40_000)
|
||||
@@ -51,7 +51,7 @@ interface PreparedDiagram {
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
/** Use terminal-optimized diagram spacing. Defaults to true. */
|
||||
compact?: boolean
|
||||
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
|
||||
/** Fold responsive horizontal diagrams that exceed this width. Defaults to 120 columns. */
|
||||
layoutMaxWidth?: number
|
||||
/** Gantt-specific terminal rendering options. */
|
||||
gantt?: Omit<GanttDiagramRenderOptions, "layoutMaxWidth">
|
||||
@@ -141,7 +141,9 @@ function prepareDiagram(
|
||||
grid,
|
||||
resolveFlowchartStyleColors({
|
||||
node: color(colors.primary),
|
||||
nodeBorder: color(colors.muted),
|
||||
database: color(colors.primary),
|
||||
databaseBorder: color(colors.muted),
|
||||
edge: color(colors.secondary),
|
||||
label: color(colors.text),
|
||||
group: color(colors.muted),
|
||||
@@ -215,8 +217,8 @@ function prepareDiagram(
|
||||
}
|
||||
}
|
||||
case "state": {
|
||||
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source))
|
||||
const size = grid.getTextSize({ trimBottom: true })
|
||||
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source), { layoutMaxWidth })
|
||||
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user