mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 17:49:53 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e41d6deb9f | |||
| 22c15eb4d6 | |||
| a07b523dae | |||
| e5ef00b8b8 | |||
| 917d6449e3 | |||
| db31c42e39 | |||
| c79ced174e | |||
| 8ba8af1dd9 | |||
| 6e82f5d3b9 | |||
| 48d1a6e5b9 | |||
| bc47030d4d | |||
| e6d20440f9 | |||
| c9cbd2b1f4 | |||
| 292dfa3036 | |||
| 3bb0d7fda0 | |||
| 8977881e09 | |||
| e6c9b6bef7 |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -395,6 +395,7 @@
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
@@ -570,6 +571,20 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.8",
|
||||
@@ -880,6 +895,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
@@ -2063,6 +2079,8 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
@@ -134,22 +134,6 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
|
||||
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("accumulates rapid page key presses", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: history(80),
|
||||
viewport: { width: 1400, height: 700 },
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await scroller.focus()
|
||||
const before = await scroller.evaluate((element) => ({ top: element.scrollTop, height: element.clientHeight }))
|
||||
|
||||
for (let index = 0; index < 3; index++) await scroller.press("PageUp")
|
||||
|
||||
await page.waitForTimeout(150)
|
||||
expect(before.top - (await scroller.evaluate((element) => element.scrollTop))).toBeGreaterThan(before.height * 2.2)
|
||||
})
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
const timeline = await setupTimeline(page, {
|
||||
|
||||
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.input.cancelled",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ missing: "msg_user" })
|
||||
})
|
||||
|
||||
test("keeps steered input available to the promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.input.steered",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.input.queued",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
|
||||
@@ -688,6 +688,8 @@ export default function Page() {
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
enabled,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
@@ -701,6 +703,16 @@ export default function Page() {
|
||||
}
|
||||
})
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
createEffect(
|
||||
on(
|
||||
() => desktopReviewOpen() || mobileChanges(),
|
||||
(open, previous) => {
|
||||
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const reviewDiffs = () => {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||
// avoids suspense
|
||||
@@ -947,19 +959,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
const details = evt.details as { type: string; properties?: unknown }
|
||||
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof details.properties === "object" && details.properties
|
||||
? (details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk().directory,
|
||||
|
||||
@@ -263,38 +263,52 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionPendingCancelOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = {
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_25Output = void
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_25Input,
|
||||
) => Effect.Effect<Endpoint5_25Output, E>
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_26Output = void
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_26Input,
|
||||
) => Effect.Effect<Endpoint5_26Output, E>
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_27Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
export type Endpoint5_31Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_28Output =
|
||||
export type Endpoint5_31Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -404,6 +418,33 @@ export type Endpoint5_28Output =
|
||||
readonly input: SessionPending.Message
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.cancelled"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.steered"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.queued"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -862,19 +903,19 @@ export type Endpoint5_28Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_31Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -902,7 +943,12 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||
readonly pending: {
|
||||
readonly list: SessionPendingListOperation<E>
|
||||
readonly cancel: SessionPendingCancelOperation<E>
|
||||
readonly steer: SessionPendingSteerOperation<E>
|
||||
readonly queue: SessionPendingQueueOperation<E>
|
||||
}
|
||||
readonly instructions: {
|
||||
readonly entry: {
|
||||
readonly list: SessionInstructionsEntryListOperation<E>
|
||||
|
||||
@@ -80,6 +80,12 @@ import type {
|
||||
Endpoint5_30Output,
|
||||
Endpoint5_31Input,
|
||||
Endpoint5_31Output,
|
||||
Endpoint5_32Input,
|
||||
Endpoint5_32Output,
|
||||
Endpoint5_33Input,
|
||||
Endpoint5_33Output,
|
||||
Endpoint5_34Input,
|
||||
Endpoint5_34Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -523,37 +529,58 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveStream<Endpoint5_28Output>()(
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -565,18 +592,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -605,13 +632,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
pending: { list: Endpoint5_23(raw) },
|
||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
||||
generate: Endpoint5_27(raw),
|
||||
log: Endpoint5_28(raw),
|
||||
interrupt: Endpoint5_29(raw),
|
||||
background: Endpoint5_30(raw),
|
||||
message: Endpoint5_31(raw),
|
||||
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -58,6 +58,12 @@ import type {
|
||||
SessionContextOutput,
|
||||
SessionPendingListInput,
|
||||
SessionPendingListOutput,
|
||||
SessionPendingCancelInput,
|
||||
SessionPendingCancelOutput,
|
||||
SessionPendingSteerInput,
|
||||
SessionPendingSteerOutput,
|
||||
SessionPendingQueueInput,
|
||||
SessionPendingQueueOutput,
|
||||
SessionInstructionsEntryListInput,
|
||||
SessionInstructionsEntryListOutput,
|
||||
SessionInstructionsEntryPutInput,
|
||||
@@ -766,6 +772,39 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingCancelOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingSteerOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingQueueOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
instructions: {
|
||||
entry: {
|
||||
|
||||
@@ -502,6 +502,36 @@ export type SessionInputPromoted = {
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.cancelled"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputSteered = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.steered"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputQueued = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.queued"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1970,6 +2000,9 @@ export type SessionEventDurable =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -2024,6 +2057,9 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -3689,6 +3725,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
|
||||
|
||||
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
||||
|
||||
export type SessionPendingCancelInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingCancelOutput = void
|
||||
|
||||
export type SessionPendingSteerInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingSteerOutput = void
|
||||
|
||||
export type SessionPendingQueueInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingQueueOutput = void
|
||||
|
||||
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
||||
|
||||
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
@@ -356,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
||||
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||
})
|
||||
|
||||
test("session.pending mutations use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
|
||||
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
|
||||
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
|
||||
])
|
||||
})
|
||||
|
||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./environment": "./src/environment/index.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./instructions": "./src/instructions/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
@@ -117,6 +118,7 @@
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -13,12 +13,14 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
const configuredIntegrations = new Set(
|
||||
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
)
|
||||
for (const [id, provider] of configuredProviders(loaded.entries)) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
if (!integrations.get(integrationID)) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "key", label: "Manually enter API Key" },
|
||||
})
|
||||
}
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = provider.name ?? integration.name
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { FilesImpl } from "./files"
|
||||
|
||||
export interface Driver {
|
||||
readonly spawner: ChildProcessSpawner["Service"]
|
||||
readonly overrides?: Partial<FilesImpl>
|
||||
}
|
||||
|
||||
export * as EnvironmentDriver from "./driver"
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Files } from "./files"
|
||||
import { makeFiles } from "./index"
|
||||
import { makeLocalDriver } from "./local"
|
||||
|
||||
export interface Interface {
|
||||
readonly files: Files
|
||||
readonly spawner: ChildProcessSpawner["Service"]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
|
||||
|
||||
export * as EnvironmentService from "./environment"
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { collectStream } from "@opencode-ai/util/process"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
|
||||
|
||||
/**
|
||||
* Files derived from spawning processes: one process per intent, "$1" is
|
||||
* always the target path. Scripts report classification through an exit-code
|
||||
* protocol (44/45/46) so failures never require parsing localized error text;
|
||||
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
|
||||
* findutils in the target image — BSD and busybox userlands will not work.
|
||||
* Malformed output from these scripts is our own bug and dies as a defect.
|
||||
*/
|
||||
|
||||
const MAX_DATA_BYTES = 64 * 1024 * 1024
|
||||
const MAX_ERROR_BYTES = 64 * 1024
|
||||
const NOT_FOUND = 44
|
||||
const WRONG_KIND = 45
|
||||
const FAILED = 46
|
||||
const TAB = "\t"
|
||||
|
||||
const loadMetadata = (flags = "") => `
|
||||
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
|
||||
case "$metadata" in
|
||||
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
|
||||
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
|
||||
esac
|
||||
}
|
||||
`
|
||||
|
||||
const statScript = `
|
||||
${loadMetadata()}
|
||||
printf '%s\n' "$metadata"
|
||||
`
|
||||
|
||||
const readScript = `
|
||||
${loadMetadata("-L")}
|
||||
kind=\${metadata%%${TAB}*}
|
||||
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
printf '%s\n' "$metadata"
|
||||
if [ "$2" = range ]; then
|
||||
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
|
||||
else
|
||||
cat -- "$1"
|
||||
fi
|
||||
`
|
||||
|
||||
const listScript = `
|
||||
${loadMetadata("-L")}
|
||||
kind=\${metadata%%${TAB}*}
|
||||
if [ "$kind" != directory ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||
`
|
||||
|
||||
const moveScript = `
|
||||
${loadMetadata()}
|
||||
mv -- "$1" "$2"
|
||||
`
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly stdout: Uint8Array
|
||||
readonly stderr: Uint8Array
|
||||
}
|
||||
|
||||
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
|
||||
const run = (
|
||||
path: string,
|
||||
script: string,
|
||||
args: ReadonlyArray<string> = [],
|
||||
stdin?: Uint8Array,
|
||||
): Effect.Effect<Result, Failed> =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
|
||||
env: { LC_ALL: "C" },
|
||||
extendEnv: true,
|
||||
stdin: stdin === undefined ? undefined : Stream.make(stdin),
|
||||
})
|
||||
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, MAX_DATA_BYTES),
|
||||
collectStream(handle.stderr, MAX_ERROR_BYTES),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
if (stdout.truncated || stderr.truncated) {
|
||||
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
|
||||
}
|
||||
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
|
||||
}),
|
||||
)
|
||||
|
||||
const classify = <A>(
|
||||
path: string,
|
||||
result: Result,
|
||||
success: (stdout: Uint8Array) => A,
|
||||
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
if (result.exitCode === WRONG_KIND) {
|
||||
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
|
||||
}
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const complete = (path: string, result: Result) =>
|
||||
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
|
||||
|
||||
return {
|
||||
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
|
||||
read: (path, range) =>
|
||||
run(
|
||||
path,
|
||||
readScript,
|
||||
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
|
||||
).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
classify(path, result, (stdout) => {
|
||||
const newline = stdout.indexOf(10)
|
||||
if (newline < 0) throw new Error("Missing read metadata header")
|
||||
return {
|
||||
info: parseInfo(stdout.slice(0, newline)),
|
||||
bytes: stdout.slice(newline + 1),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
write: (path, bytes) =>
|
||||
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
|
||||
Effect.flatMap((result) => complete(path, result)),
|
||||
),
|
||||
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
|
||||
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
move: (from, to) =>
|
||||
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
|
||||
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
}
|
||||
}
|
||||
|
||||
/** `classify` for scripts whose protocol never reports WrongKind. */
|
||||
const classifyPlain = <A>(
|
||||
path: string,
|
||||
result: Result,
|
||||
success: (stdout: Uint8Array) => A,
|
||||
): Effect.Effect<A, NotFound | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const processFailure = (path: string, result: Result) =>
|
||||
new Failed({
|
||||
path,
|
||||
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
|
||||
})
|
||||
|
||||
const parseInfo = (bytes: Uint8Array): FileInfo => {
|
||||
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
|
||||
const size = Number(rawSize)
|
||||
const mtimeMs = Number(rawMtime) * 1_000
|
||||
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
|
||||
return { type: parseType(rawType), size, mtimeMs }
|
||||
}
|
||||
|
||||
const parseType = (value: string): FileType => {
|
||||
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
|
||||
if (value === "directory" || value === "d") return "directory"
|
||||
if (value === "symbolic link" || value === "l") return "symlink"
|
||||
return "other"
|
||||
}
|
||||
|
||||
const parseList = (bytes: Uint8Array) => {
|
||||
const fields = new TextDecoder().decode(bytes).split("\0")
|
||||
fields.pop()
|
||||
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
|
||||
return Array.from({ length: fields.length / 2 }, (_, index) => ({
|
||||
name: fields[index * 2 + 1],
|
||||
type: parseType(fields[index * 2]),
|
||||
}))
|
||||
}
|
||||
|
||||
export * as EnvironmentExecDefaults from "./exec-defaults"
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
|
||||
export type FileType = typeof FileType.Type
|
||||
|
||||
export interface FileInfo {
|
||||
readonly type: FileType
|
||||
readonly size: number
|
||||
readonly mtimeMs: number
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: FileType
|
||||
}
|
||||
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
|
||||
path: Schema.String,
|
||||
actual: FileType,
|
||||
}) {}
|
||||
|
||||
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
|
||||
path: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export interface FilesImpl {
|
||||
/**
|
||||
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
|
||||
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
|
||||
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||
* `Failed`, so callers must use ranges for larger files.
|
||||
*/
|
||||
readonly read: (
|
||||
path: string,
|
||||
range?: { readonly offset: number; readonly length: number },
|
||||
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
|
||||
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
|
||||
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export interface Files extends FilesImpl {}
|
||||
|
||||
/**
|
||||
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
|
||||
* symlink fails with `NotFound`.
|
||||
*/
|
||||
export const typeFollowing = (files: Files, path: string) =>
|
||||
files.stat(path).pipe(
|
||||
Effect.flatMap((info) =>
|
||||
info.type === "symlink"
|
||||
? files.read(path, { offset: 0, length: 0 }).pipe(
|
||||
Effect.map((result) => result.info.type),
|
||||
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
|
||||
)
|
||||
: Effect.succeed(info.type),
|
||||
),
|
||||
)
|
||||
|
||||
export * as EnvironmentFiles from "./files"
|
||||
@@ -0,0 +1,27 @@
|
||||
export * as Environment from "./index"
|
||||
|
||||
export { type Driver } from "./driver"
|
||||
export {
|
||||
type DirEntry,
|
||||
Failed,
|
||||
type FileInfo,
|
||||
type Files,
|
||||
type FilesImpl,
|
||||
type FileType,
|
||||
NotFound,
|
||||
typeFollowing,
|
||||
WrongKind,
|
||||
} from "./files"
|
||||
export { execDefaults } from "./exec-defaults"
|
||||
export { makeLocalDriver } from "./local"
|
||||
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||
export { type Interface, node, Service } from "./environment"
|
||||
|
||||
import type { Driver } from "./driver"
|
||||
import { execDefaults } from "./exec-defaults"
|
||||
import type { Files } from "./files"
|
||||
|
||||
export const makeFiles = (driver: Driver): Files => ({
|
||||
...execDefaults(driver.spawner),
|
||||
...driver.overrides,
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "./driver"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||
|
||||
/**
|
||||
* The host filesystem binding. Deliberately raw node:fs rather than effect's
|
||||
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
|
||||
* reports "symlink") and typed directory entries, and effect's node
|
||||
* FileSystem provides neither — its stat always follows symlinks and
|
||||
* readDirectory returns names only. FSUtil hits the same gap and its
|
||||
* readDirectoryEntries already bypasses to raw node readdir internally.
|
||||
* Nothing above the environment seam touches node:fs.
|
||||
*/
|
||||
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
|
||||
const overrides: FilesImpl = {
|
||||
read: (value, range) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* stat(value, true)
|
||||
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
|
||||
if (range === undefined) {
|
||||
const bytes = yield* attempt(value, () => fs.readFile(value), true)
|
||||
return { info, bytes }
|
||||
}
|
||||
const bytes = yield* attempt(
|
||||
value,
|
||||
async () => {
|
||||
const handle = await fs.open(value, "r")
|
||||
try {
|
||||
const buffer = new Uint8Array(range.length)
|
||||
const result = await handle.read(buffer, 0, range.length, range.offset)
|
||||
return buffer.subarray(0, result.bytesRead)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
return { info, bytes }
|
||||
}),
|
||||
stat: (value) => stat(value, false),
|
||||
list: (value) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* stat(value, true)
|
||||
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
|
||||
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
|
||||
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
|
||||
}),
|
||||
write: (value, bytes) =>
|
||||
attempt(value, async () => {
|
||||
await fs.mkdir(path.dirname(value), { recursive: true })
|
||||
await fs.writeFile(value, bytes)
|
||||
}),
|
||||
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
|
||||
move: (from, to) =>
|
||||
Effect.gen(function* () {
|
||||
yield* stat(from, false)
|
||||
const destination = yield* stat(to, false).pipe(
|
||||
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof NotFound,
|
||||
() => Effect.succeed(to),
|
||||
),
|
||||
)
|
||||
yield* attempt(from, () => fs.rename(from, destination))
|
||||
}),
|
||||
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
|
||||
}
|
||||
|
||||
return { spawner, overrides }
|
||||
}
|
||||
|
||||
const stat = (value: string, follow: boolean) =>
|
||||
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
|
||||
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
|
||||
)
|
||||
|
||||
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
|
||||
if (entry.isFile()) return "file"
|
||||
if (entry.isDirectory()) return "directory"
|
||||
if (entry.isSymbolicLink()) return "symlink"
|
||||
return "other"
|
||||
}
|
||||
|
||||
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
|
||||
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
|
||||
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
|
||||
return Effect.tryPromise({
|
||||
try: run,
|
||||
catch: (cause) =>
|
||||
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
|
||||
})
|
||||
}
|
||||
|
||||
const isMissing = (cause: unknown) =>
|
||||
cause !== null &&
|
||||
typeof cause === "object" &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
|
||||
export * as EnvironmentLocal from "./local"
|
||||
@@ -0,0 +1,168 @@
|
||||
import path from "node:path"
|
||||
import { Effect, PlatformError } from "effect"
|
||||
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "./driver"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||
|
||||
type Node =
|
||||
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
|
||||
| { readonly type: "directory"; readonly mtimeMs: number }
|
||||
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
|
||||
|
||||
export interface MemoryDriver extends Driver {
|
||||
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export const makeMemoryDriver = (): MemoryDriver => {
|
||||
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
|
||||
const key = (value: string) => path.posix.resolve("/", value)
|
||||
const info = (node: Node): FileInfo => ({
|
||||
type: node.type,
|
||||
size:
|
||||
node.type === "file"
|
||||
? node.bytes.length
|
||||
: node.type === "symlink"
|
||||
? new TextEncoder().encode(node.target).length
|
||||
: 0,
|
||||
mtimeMs: node.mtimeMs,
|
||||
})
|
||||
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
|
||||
const normalized = key(value)
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
const base = "/"
|
||||
const walk = (current: string, index: number): string | undefined => {
|
||||
if (index === parts.length) return current
|
||||
const part = parts[index]
|
||||
const candidate = path.posix.join(current, part)
|
||||
const node = nodes.get(candidate)
|
||||
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
|
||||
if (seen.has(candidate)) return undefined
|
||||
seen.add(candidate)
|
||||
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
|
||||
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
|
||||
}
|
||||
return walk(base, 0)
|
||||
}
|
||||
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
|
||||
const requireParent = (value: string) => {
|
||||
const parentPath = path.posix.dirname(key(value))
|
||||
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
|
||||
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
|
||||
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
|
||||
}
|
||||
const mkdirSync = (value: string) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const existing = nodes.get(target)
|
||||
if (existing?.type === "directory") return
|
||||
if (existing) throw new Error(`Path is not a directory: ${value}`)
|
||||
const parent = path.posix.dirname(target)
|
||||
if (parent !== target) mkdirSync(parent)
|
||||
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
mkdirSync(path.posix.dirname(key(value)))
|
||||
const existing = lookup(value)
|
||||
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
|
||||
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
|
||||
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
|
||||
requireParent(target)
|
||||
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
for (const entry of nodes.keys()) {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
const spawner = make((command) =>
|
||||
Effect.suspend(() => {
|
||||
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
|
||||
return Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "Unknown",
|
||||
module: "EnvironmentMemory",
|
||||
method: "spawn",
|
||||
pathOrDescriptor: description,
|
||||
cause: failed(description, new Error("The memory driver cannot spawn processes")),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
spawner,
|
||||
overrides,
|
||||
symlink: (target, value) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
requireParent(value)
|
||||
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export * as EnvironmentMemory from "./memory"
|
||||
@@ -5,6 +5,8 @@ import { Context, Effect, Layer } from "effect"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "./environment"
|
||||
import type { Files } from "./environment"
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: string
|
||||
@@ -29,13 +31,36 @@ export interface WriteResult {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */
|
||||
readonly withLock: (
|
||||
targets: ReadonlyArray<string>,
|
||||
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
readonly writeTextPreservingBom: (
|
||||
input: TextWriteInput,
|
||||
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||
|
||||
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
|
||||
return Bom.decodeBytes((yield* files.read(target)).bytes)
|
||||
})
|
||||
|
||||
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
||||
files: Files,
|
||||
target: string,
|
||||
bom: boolean,
|
||||
) {
|
||||
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
|
||||
if (synced.bytes) yield* files.write(target, synced.bytes)
|
||||
return synced.text
|
||||
})
|
||||
|
||||
/** Share transaction locks across Location graphs that address the same file. */
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
@@ -44,8 +69,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
||||
[...new Set(targets.map(FSUtil.resolve))]
|
||||
.sort()
|
||||
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
||||
const withTargetLock =
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -61,8 +90,14 @@ const layer = Layer.effect(
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const existed = yield* fs.exists(input.target.absolute)
|
||||
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||
const existed = yield* environment.files.stat(input.target.absolute).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
|
||||
)
|
||||
yield* environment.files.write(
|
||||
input.target.absolute,
|
||||
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
|
||||
)
|
||||
return writeResult(input.target, existed)
|
||||
}),
|
||||
),
|
||||
@@ -72,23 +107,24 @@ const layer = Layer.effect(
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const current = yield* fs
|
||||
.readFile(input.target.absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
|
||||
Effect.map((result) => result.bytes),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
yield* environment.files.write(
|
||||
input.target.absolute,
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ write, writeTextPreservingBom })
|
||||
return Service.of({ withLock, write, writeTextPreservingBom })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding integrations exist.
|
||||
|
||||
@@ -11,15 +11,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { Watcher } from "./watcher"
|
||||
import { Ignore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const relative = path.relative(dir, item)
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
})
|
||||
}
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
@@ -44,19 +35,6 @@ const layer = Layer.effect(
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = Protected.isHome(location.directory)
|
||||
|
||||
if (!home && location.vcs) {
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: location.directory,
|
||||
type: "directory",
|
||||
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
|
||||
})
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
if (home) {
|
||||
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
|
||||
}
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
@@ -64,10 +42,7 @@ const layer = Layer.effect(
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||
)
|
||||
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Environment } from "./environment"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
@@ -53,6 +54,7 @@ export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
Command.node,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { Form } from "../form"
|
||||
@@ -70,6 +71,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const credential = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
@@ -102,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Credential.Service, credential),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(Environment.Service, environment),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
@@ -282,7 +283,9 @@ const layer = Layer.effect(
|
||||
})
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
@@ -320,6 +323,7 @@ export const node = makeLocationNode({
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export * as WebSearchFirecrawl from "./firecrawl"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
limit: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
success: Schema.Boolean,
|
||||
data: Schema.Struct({
|
||||
web: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.firecrawl",
|
||||
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("firecrawl")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"firecrawl_search",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{ query: input.query, limit: 8 },
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
)
|
||||
const content = result?.content.find((item) => item.text)
|
||||
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
|
||||
return (
|
||||
response?.data.web.map((item) => ({
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
...(item.description ? { content: item.description } : {}),
|
||||
time: {},
|
||||
})) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchFirecrawl } from "./firecrawl"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||
|
||||
@@ -3,8 +3,9 @@ export * as Ripgrep from "./ripgrep"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||
import { Environment } from "./environment"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { RipgrepBinary } from "./ripgrep/binary"
|
||||
|
||||
@@ -93,7 +94,7 @@ const isInvalidPattern = (stderr: string) =>
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const process = yield* AppProcess.Service
|
||||
const environment = yield* Environment.Service
|
||||
const binary = yield* RipgrepBinary.Service
|
||||
|
||||
const run = <A>(input: {
|
||||
@@ -107,7 +108,8 @@ const layer = Layer.effect(
|
||||
}) => {
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* process.spawn(
|
||||
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
|
||||
const handle = yield* environment.spawner.spawn(
|
||||
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
||||
@@ -275,4 +277,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
|
||||
|
||||
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
|
||||
"Session.PendingInputConflictError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
}) {}
|
||||
@@ -181,6 +189,9 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
/**
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
@@ -318,6 +329,31 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new PendingInputConflictError(input)
|
||||
})
|
||||
const mutatePending = (
|
||||
input: PendingInputRef,
|
||||
mutation: (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) => Effect.Effect<unknown>,
|
||||
wake = false,
|
||||
) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? pendingConflict(input)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (wake) yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
@@ -507,6 +543,9 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionPending.list(db, sessionID)
|
||||
}),
|
||||
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
|
||||
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
|
||||
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
|
||||
@@ -2,9 +2,14 @@ export * as SessionRestart from "./restart"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { SessionStore } from "../store"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Marks every execution active in this process for resumption by the next server start.
|
||||
@@ -26,6 +31,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
return Service.of({
|
||||
suspendActiveSessions: Effect.gen(function* () {
|
||||
yield* store.suspend(yield* execution.active)
|
||||
@@ -37,6 +43,11 @@ export const layer = Layer.effect(
|
||||
(sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* store.consumeSuspended(sessionID))) return
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
@@ -47,4 +58,8 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.forked": () => Effect.void,
|
||||
"session.input.promoted": () => Effect.void,
|
||||
"session.input.admitted": () => Effect.void,
|
||||
"session.input.cancelled": () => Effect.void,
|
||||
"session.input.steered": () => Effect.void,
|
||||
"session.input.queued": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
|
||||
@@ -37,6 +37,7 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||
|
||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||
"SessionPending.LifecycleConflict",
|
||||
@@ -294,10 +295,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
|
||||
*/
|
||||
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
input: PendingRef,
|
||||
) {
|
||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const deleted = yield* db
|
||||
@@ -312,6 +310,55 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
return stored
|
||||
})
|
||||
|
||||
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
|
||||
db: DatabaseService,
|
||||
input: PendingRef,
|
||||
) {
|
||||
const deleted = yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
|
||||
db: DatabaseService,
|
||||
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionPendingTable)
|
||||
.set({ delivery: input.to })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
eq(SessionPendingTable.delivery, input.from),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
|
||||
(db: DatabaseService, input: PendingRef) =>
|
||||
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
|
||||
)
|
||||
|
||||
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
|
||||
(db: DatabaseService, input: PendingRef) =>
|
||||
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
|
||||
)
|
||||
|
||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||
db: DatabaseService,
|
||||
input: { readonly sessionID: SessionSchema.ID },
|
||||
@@ -389,6 +436,39 @@ export const equivalent = (
|
||||
return false
|
||||
}
|
||||
|
||||
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
|
||||
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
|
||||
|
||||
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InputCancelled, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InputSteered, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InputQueued, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
|
||||
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputCancelled, (event) =>
|
||||
SessionPending.projectCancelled(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputSteered, (event) =>
|
||||
SessionPending.projectSteered(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputQueued, (event) =>
|
||||
SessionPending.projectQueued(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
|
||||
+257
-258
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { produce } from "immer"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Bus } from "./bus"
|
||||
import { Environment } from "./environment"
|
||||
import { Location } from "./location"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
@@ -65,285 +65,284 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||
|
||||
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
export const layer = (options?: ShellSelect.Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const environment = yield* Environment.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
||||
}
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||
return (yield* require(id)).info
|
||||
})
|
||||
|
||||
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
||||
return yield* Deferred.await((yield* require(id)).done)
|
||||
})
|
||||
|
||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
let offset = 0
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
bytes.copy(buffer, offset)
|
||||
offset += bytes.length
|
||||
})
|
||||
stream.on("end", () => resolve(offset))
|
||||
stream.on("error", () => resolve(0))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const invocation: ShellCreateBefore = {
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("shell", "create.before", invocation)
|
||||
if (before) yield* before(invocation)
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
const id = Shell.ID.ascending()
|
||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
const info: Info = {
|
||||
id,
|
||||
status: "running",
|
||||
command: invocation.command,
|
||||
cwd: invocation.cwd,
|
||||
shell: invocation.shell,
|
||||
file,
|
||||
metadata: input.metadata ?? {},
|
||||
time: { started: Date.now() },
|
||||
}
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||
return (yield* require(id)).info
|
||||
})
|
||||
|
||||
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
||||
return yield* Deferred.await((yield* require(id)).done)
|
||||
})
|
||||
|
||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
let offset = 0
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
bytes.copy(buffer, offset)
|
||||
offset += bytes.length
|
||||
})
|
||||
stream.on("end", () => resolve(offset))
|
||||
stream.on("error", () => resolve(0))
|
||||
}),
|
||||
)
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const invocation: ShellCreateBefore = {
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("shell", "create.before", invocation)
|
||||
if (before) yield* before(invocation)
|
||||
|
||||
const id = Shell.ID.ascending()
|
||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
status: "running",
|
||||
command: invocation.command,
|
||||
cwd: invocation.cwd,
|
||||
shell: invocation.shell,
|
||||
file,
|
||||
metadata: input.metadata ?? {},
|
||||
time: { started: Date.now() },
|
||||
}
|
||||
|
||||
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
|
||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
const ready = Deferred.makeUnsafe<Active>()
|
||||
runFork(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* appProcess.spawn(
|
||||
ChildProcess.make(invocation.shell, args, {
|
||||
cwd: invocation.cwd,
|
||||
env: invocation.env,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
}),
|
||||
)
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
file,
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Deferred.makeUnsafe<void>()
|
||||
const pump = handle.all.pipe(
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
session.size += chunk.length
|
||||
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
|
||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
const ready = Deferred.makeUnsafe<Active>()
|
||||
runFork(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* environment.spawner.spawn(
|
||||
ChildProcess.make(invocation.shell, args, {
|
||||
cwd: invocation.cwd,
|
||||
env: invocation.env,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
}),
|
||||
),
|
||||
)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* pump.pipe(Effect.catch(() => Effect.void))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve())
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(outputDone, undefined)
|
||||
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.once("open", () => resolve())
|
||||
stream.once("error", () => resolve())
|
||||
)
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
)
|
||||
file,
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
sessions.set(id, session)
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
draft.status = status
|
||||
if (exit !== undefined) draft.exit = exit
|
||||
draft.time.completed = Date.now()
|
||||
})
|
||||
yield* beforeWait
|
||||
yield* Deferred.await(outputDone)
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||
yield* Deferred.succeed(session.done, session.info)
|
||||
yield* bus.publish(Shell.Event.Exited, {
|
||||
id,
|
||||
...(exit !== undefined ? { exit } : {}),
|
||||
status,
|
||||
})
|
||||
exitOrder.push(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(Shell.ID.make(oldest))
|
||||
}
|
||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||
// aborting finish when finish itself runs on the timeout fiber.
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Deferred.makeUnsafe<void>()
|
||||
const pump = handle.all.pipe(
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
session.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* pump.pipe(Effect.catch(() => Effect.void))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve())
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(outputDone, undefined)
|
||||
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
stream.once("open", () => resolve())
|
||||
stream.once("error", () => resolve())
|
||||
}),
|
||||
)
|
||||
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
Effect.sleep(Duration.millis(duration)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
draft.status = status
|
||||
if (exit !== undefined) draft.exit = exit
|
||||
draft.time.completed = Date.now()
|
||||
})
|
||||
yield* beforeWait
|
||||
yield* Deferred.await(outputDone)
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||
yield* Deferred.succeed(session.done, session.info)
|
||||
yield* bus.publish(Shell.Event.Exited, {
|
||||
id,
|
||||
...(exit !== undefined ? { exit } : {}),
|
||||
status,
|
||||
})
|
||||
exitOrder.push(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(Shell.ID.make(oldest))
|
||||
}
|
||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||
// aborting finish when finish itself runs on the timeout fiber.
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
Effect.sleep(Duration.millis(duration)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
yield* session.timeout(invocation.timeout)
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
Effect.flatMap((code) => finish("exited", code)),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
Effect.flatMap((code) => finish("exited", code)),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||
// release (kill) the process before its exit is observed.
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||
// release (kill) the process before its exit is observed.
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+105
-34
@@ -2,8 +2,7 @@ export * as Skill from "./skill"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
@@ -13,6 +12,7 @@ import { Permission } from "./permission"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SkillDiscovery } from "./skill/discovery"
|
||||
import { State } from "./state"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
|
||||
export const DirectorySource = Skill.DirectorySource
|
||||
export type DirectorySource = Skill.DirectorySource
|
||||
@@ -81,6 +81,82 @@ const layer = Layer.effect(
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const changes = yield* PubSub.unbounded<string>()
|
||||
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const changed = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return false
|
||||
cache.clear()
|
||||
yield* FiberMap.clear(watches)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!changed) return
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe(
|
||||
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
|
||||
)
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
|
||||
directory: string,
|
||||
) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) {
|
||||
yield* watch(target, "file")
|
||||
}
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "skill",
|
||||
@@ -92,7 +168,10 @@ const layer = Layer.effect(
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () =>
|
||||
lock
|
||||
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
|
||||
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
|
||||
})
|
||||
|
||||
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
||||
@@ -104,14 +183,22 @@ const layer = Layer.effect(
|
||||
directories: [],
|
||||
skills: [source.skill.id],
|
||||
})
|
||||
return { skills: [source.skill], directories: [] }
|
||||
return { skills: [source.skill], paths: [] }
|
||||
}
|
||||
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const paths = [...roots]
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
|
||||
const external = path.dirname(resolved)
|
||||
paths.push(external)
|
||||
yield* watch(external, "directory")
|
||||
}
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
@@ -139,38 +226,22 @@ const layer = Layer.effect(
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return { skills, directories }
|
||||
return { skills, paths }
|
||||
})
|
||||
|
||||
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return
|
||||
for (const [key] of invalidated) cache.delete(key)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => invalidate(event.data.file)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
const list = Effect.fn("Skill.list")(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -187,5 +258,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
|
||||
})
|
||||
|
||||
@@ -11,9 +11,11 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
@@ -109,9 +111,10 @@ export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -152,17 +155,16 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
)
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.absolute)
|
||||
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
Effect.catchTag("Environment.WrongKind", (error) =>
|
||||
error.actual === "directory"
|
||||
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
|
||||
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
|
||||
),
|
||||
)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
@@ -204,19 +206,20 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
const result = yield* fileMutation.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
: (yield* FileMutation.readText(environment.files, target.absolute)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
fileMutation.withLock([path.resolve(location.directory, input.path)]),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Ripgrep } from "../../ripgrep"
|
||||
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
@@ -82,22 +82,20 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (type !== "directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const root = target.absolute
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd: target.absolute,
|
||||
cwd: root,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
|
||||
export const name = "grep"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GrepInput.fields.pattern.check(
|
||||
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
|
||||
).annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
}),
|
||||
pattern: FileSystem.GrepInput.fields.pattern
|
||||
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
|
||||
.annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
}),
|
||||
path: Schema.optionalKey(RelativePath).annotate({
|
||||
description: "File or directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
@@ -66,104 +66,100 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const root = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs
|
||||
.stat(root)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const cwd = info?.type === "Directory" ? root : path.dirname(root)
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* ripgrep
|
||||
.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
file: info?.type === "File" ? path.basename(root) : undefined,
|
||||
include: input.include,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(
|
||||
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
||||
),
|
||||
}),
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const root = target.absolute
|
||||
const type = yield* Environment.typeFollowing(environment.files, root).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const cwd = type === "directory" ? root : path.dirname(root)
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* ripgrep
|
||||
.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
file: type === "file" ? path.basename(root) : undefined,
|
||||
include: input.include,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(
|
||||
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.matches,
|
||||
content: toModelContent(
|
||||
result.matches.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: error instanceof Ripgrep.InvalidPatternError
|
||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||
)
|
||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.matches,
|
||||
content: toModelContent(
|
||||
result.matches.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: error instanceof Ripgrep.InvalidPatternError
|
||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "../../environment"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -44,7 +45,13 @@ export const toModelOutput = (output: Output) =>
|
||||
].join("\n")
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
readonly target: Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
@@ -69,7 +76,8 @@ interface Target {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -84,6 +92,13 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
const lockTargets = Result.isSuccess(parsed)
|
||||
? parsed.success.flatMap((hunk) => [
|
||||
path.resolve(location.directory, hunk.path),
|
||||
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
|
||||
])
|
||||
: []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
@@ -97,7 +112,7 @@ export const Plugin = {
|
||||
id: context.id,
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
const hunks = yield* Effect.fromResult(parsed).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
@@ -125,18 +140,19 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
const content =
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||
).text,
|
||||
after: Bom.split(content).text,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -151,20 +167,7 @@ export const Plugin = {
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const stats = yield* fs.stat(target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -233,13 +236,8 @@ export const Plugin = {
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.absolute,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
yield* environment.files
|
||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
@@ -249,7 +247,7 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
yield* environment.files
|
||||
.remove(change.target.absolute)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
@@ -261,10 +259,10 @@ export const Plugin = {
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.absolute, change.content)
|
||||
yield* environment.files
|
||||
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs
|
||||
yield* environment.files
|
||||
.remove(change.target.absolute)
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
@@ -278,8 +276,8 @@ export const Plugin = {
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.absolute, change.content)
|
||||
yield* environment.files
|
||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
@@ -294,13 +292,13 @@ export const Plugin = {
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
const current = yield* FileMutation.readText(environment.files, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
@@ -315,6 +313,7 @@ export const Plugin = {
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
mutation.withLock(lockTargets),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
@@ -345,10 +344,10 @@ export const Plugin = {
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof PlatformError) {
|
||||
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||
return error.reason.description ?? error.reason.message
|
||||
}
|
||||
if (error instanceof Environment.NotFound) return "file does not exist"
|
||||
if (error instanceof Environment.WrongKind)
|
||||
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
|
||||
if (error instanceof Environment.Failed) return errorMessage(error.cause)
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Permission } from "../../permission"
|
||||
import { SessionInstructions } from "../../session/instructions"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { ReadToolFileSystem } from "../read-filesystem"
|
||||
import { Environment } from "../../environment"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
@@ -72,16 +73,12 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader
|
||||
.inspect(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof Environment.NotFound,
|
||||
() => missing(input.path, target.absolute),
|
||||
),
|
||||
)
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
@@ -95,7 +92,7 @@ export const Plugin = {
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
start: content.type === "list-page" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
|
||||
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Config } from "../../config"
|
||||
import { Environment } from "../../environment"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
@@ -83,7 +83,7 @@ export const Plugin = {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const fsUtil = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -179,14 +179,12 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* fsUtil
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
),
|
||||
)
|
||||
if (workdir.type !== "Directory")
|
||||
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
),
|
||||
)
|
||||
if (workdir !== "directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "../../environment"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
@@ -47,9 +47,9 @@ export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -77,8 +77,8 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||
@@ -91,9 +91,11 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) {
|
||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
}
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
|
||||
@@ -2,17 +2,22 @@ export * as ReadToolFileSystem from "./read-filesystem"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { lookup } from "mime-types"
|
||||
import { Environment } from "../environment"
|
||||
import type { Files } from "../environment"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Mime } from "../mime"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
|
||||
export const MAX_READ_LINES = 2_000
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
||||
const FIRST_CHUNK = 256 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
||||
|
||||
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
|
||||
resource: Schema.String,
|
||||
@@ -52,8 +57,13 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
||||
}
|
||||
}
|
||||
|
||||
export type InspectError = FSUtil.Error | PathKindError
|
||||
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||
export type ReadError =
|
||||
| Environment.NotFound
|
||||
| Environment.Failed
|
||||
| BinaryFileError
|
||||
| MediaIngestLimitError
|
||||
| OffsetOutOfRangeError
|
||||
| PathKindError
|
||||
|
||||
export const PageInput = Schema.Struct({
|
||||
offset: Schema.optionalKey(NonNegativeInt),
|
||||
@@ -90,202 +100,113 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
|
||||
readonly read: (
|
||||
path: AbsolutePath,
|
||||
resource: string,
|
||||
page?: PageInput,
|
||||
) => Effect.Effect<FileContent | TextPage, ReadError>
|
||||
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
|
||||
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||
|
||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||
const mediaMime = (bytes: Uint8Array) => {
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
||||
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||
return "image/webp"
|
||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||
}
|
||||
const binary = (bytes: Uint8Array) => {
|
||||
if (bytes.length === 0) return false
|
||||
let nonPrintable = 0
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) return true
|
||||
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
|
||||
}
|
||||
return nonPrintable / bytes.length > 0.3
|
||||
}
|
||||
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||
const info = yield* fs.stat(input)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
||||
return type
|
||||
})
|
||||
const mimeType = (value: string) => lookup(value) || "application/octet-stream"
|
||||
|
||||
export const read = Effect.fn("ReadTool.read")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
input: string,
|
||||
files: Files,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
page: PageInput = {},
|
||||
) {
|
||||
const real = yield* fs.realPath(input)
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(real, { flag: "r" })
|
||||
const info = yield* file.stat
|
||||
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
|
||||
const first = Option.getOrElse(
|
||||
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
|
||||
() => new Uint8Array(),
|
||||
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
|
||||
Effect.catchTag("Environment.WrongKind", (error) => {
|
||||
if (error.actual !== "directory")
|
||||
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
|
||||
return files.list(input).pipe(
|
||||
Effect.map((entries) => list(entries, page)),
|
||||
Effect.catchTag("Environment.WrongKind", () =>
|
||||
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
|
||||
),
|
||||
)
|
||||
const mime = mediaMime(first)
|
||||
if (mime) {
|
||||
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||
const chunks = [first]
|
||||
let total = first.length
|
||||
while (total <= MAX_MEDIA_INGEST_BYTES) {
|
||||
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
||||
if (Option.isNone(chunk)) break
|
||||
chunks.push(chunk.value)
|
||||
total += chunk.value.length
|
||||
}
|
||||
if (total > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
content: Buffer.concat(
|
||||
chunks.map((chunk) => Buffer.from(chunk)),
|
||||
total,
|
||||
).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime,
|
||||
}
|
||||
}
|
||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||
if (!paged) {
|
||||
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder()
|
||||
const text = [decodeUtf8(decoder, first)]
|
||||
while (true) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||
}
|
||||
text.push(decodeUtf8(decoder))
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
content: text.join(""),
|
||||
encoding: "utf8" as const,
|
||||
mime: FSUtil.mimeType(real),
|
||||
}
|
||||
}
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const lines: string[] = []
|
||||
const decoder = new TextDecoder()
|
||||
let pending = ""
|
||||
let discard = false
|
||||
let line = 1
|
||||
let bytes = 0
|
||||
let next: number | undefined
|
||||
const append = (input: string) => {
|
||||
if (line < offset) {
|
||||
line++
|
||||
return true
|
||||
}
|
||||
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_READ_BYTES) {
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
lines.push(text)
|
||||
bytes += size
|
||||
line++
|
||||
return true
|
||||
}
|
||||
const consume = (input: string) => {
|
||||
let text = input
|
||||
while (true) {
|
||||
const index = text.indexOf("\n")
|
||||
if (index === -1) {
|
||||
if (!discard) {
|
||||
pending += text
|
||||
if (pending.length > MAX_LINE_LENGTH) {
|
||||
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
|
||||
discard = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
const current = pending + (discard ? "" : text.slice(0, index))
|
||||
pending = ""
|
||||
discard = false
|
||||
text = text.slice(index + 1)
|
||||
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
|
||||
let start = 0
|
||||
while (start < chunk.length) {
|
||||
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
const newline = chunk.indexOf(10, start)
|
||||
const end = newline === -1 ? chunk.length : newline + 1
|
||||
const segment = chunk.subarray(start, end)
|
||||
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||
start = end
|
||||
}
|
||||
return true
|
||||
})
|
||||
let done = !(yield* consumeChunk(first))
|
||||
while (!done) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
done = !(yield* consumeChunk(chunk.value))
|
||||
}
|
||||
if (!done) {
|
||||
const tail = decodeUtf8(decoder)
|
||||
if (!discard) pending += tail
|
||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||
}
|
||||
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: lines.join("\n"),
|
||||
mime: FSUtil.mimeType(real),
|
||||
offset,
|
||||
truncated: next !== undefined,
|
||||
...(next === undefined ? {} : { next }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (first instanceof ListPage) return first
|
||||
|
||||
const media = Mime.detect(first.bytes)
|
||||
if (MEDIA_MIMES.has(media)) {
|
||||
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
|
||||
const whole = yield* readFile(files, input, resource)
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(input).href,
|
||||
name: path.basename(input),
|
||||
content: Buffer.from(whole.bytes).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime: media,
|
||||
}
|
||||
}
|
||||
|
||||
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||
if (!paged) {
|
||||
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(input).href,
|
||||
name: path.basename(input),
|
||||
content: new TextDecoder().decode(first.bytes),
|
||||
encoding: "utf8" as const,
|
||||
mime: mimeType(input),
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = [first.bytes]
|
||||
while (true) {
|
||||
const bytes = Buffer.concat(chunks)
|
||||
const eof = bytes.length >= first.info.size
|
||||
const result = textPage(bytes, eof, page)
|
||||
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
|
||||
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
|
||||
if (next.bytes.length === 0) {
|
||||
const result = textPage(bytes, true, page)
|
||||
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
|
||||
return yield* makeTextPage(bytes, input, resource, result)
|
||||
}
|
||||
chunks.push(next.bytes)
|
||||
}
|
||||
})
|
||||
|
||||
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||
const real = yield* fs.realPath(input)
|
||||
const items = yield* fs.readDirectoryEntries(real)
|
||||
const readFile = (
|
||||
files: Files,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
range?: { readonly offset: number; readonly length: number },
|
||||
) =>
|
||||
files
|
||||
.read(input, range)
|
||||
.pipe(
|
||||
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
|
||||
)
|
||||
|
||||
const makeTextPage = Effect.fnUntraced(function* (
|
||||
bytes: Uint8Array,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
result: NonNullable<ReturnType<typeof textPage>>,
|
||||
) {
|
||||
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
|
||||
if (result.entries.length === 0 && result.offset !== 1)
|
||||
return yield* new OffsetOutOfRangeError({ offset: result.offset })
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: result.entries.join("\n"),
|
||||
mime: mimeType(input),
|
||||
offset: result.offset,
|
||||
truncated: result.next !== undefined,
|
||||
...(result.next === undefined ? {} : { next: result.next }),
|
||||
})
|
||||
})
|
||||
|
||||
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const visible = items
|
||||
@@ -316,18 +237,58 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
truncated,
|
||||
...(truncated ? { next: offset + selected.length } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const decoded = new TextDecoder().decode(bytes)
|
||||
const split = decoded.split("\n")
|
||||
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
|
||||
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
||||
|
||||
const entries: string[] = []
|
||||
let size = 0
|
||||
let next: number | undefined
|
||||
for (const [index, value] of available.slice(offset - 1).entries()) {
|
||||
const line = offset + index
|
||||
if (entries.length >= limit || size >= MAX_READ_BYTES) {
|
||||
next = line
|
||||
break
|
||||
}
|
||||
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
|
||||
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
|
||||
if (size + lineSize > MAX_READ_BYTES) {
|
||||
next = line
|
||||
break
|
||||
}
|
||||
entries.push(text)
|
||||
size += lineSize
|
||||
}
|
||||
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
|
||||
next = offset + entries.length
|
||||
if (!eof && next === undefined) return
|
||||
|
||||
const consumedLines = next === undefined ? available.length : next - 1
|
||||
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
|
||||
return { entries, offset, next, consumed }
|
||||
}
|
||||
|
||||
const nthNewline = (bytes: Uint8Array, count: number) => {
|
||||
let found = 0
|
||||
for (const [index, byte] of bytes.entries()) {
|
||||
if (byte !== 10) continue
|
||||
found++
|
||||
if (found === count) return index + 1
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return Service.of({
|
||||
inspect: (path) => inspect(fs, path),
|
||||
read: (path, resource, page) => read(fs, path, resource, page),
|
||||
list: (path, page) => list(fs, path, page),
|
||||
})
|
||||
const environment = yield* Environment.Service
|
||||
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
|
||||
|
||||
@@ -50,6 +50,33 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
litellm: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]
|
||||
|
||||
yield* addPlugin(entries)
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
|
||||
id: "litellm",
|
||||
name: "litellm",
|
||||
methods: [{ type: "key", label: "Manually enter API Key" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import {
|
||||
execDefaults,
|
||||
Failed,
|
||||
makeFiles,
|
||||
makeLocalDriver,
|
||||
makeMemoryDriver,
|
||||
NotFound,
|
||||
typeFollowing,
|
||||
} from "../src/environment/index"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { environmentConformance } from "./lib/environment-conformance"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("typeFollowing", () => {
|
||||
it.effect("follows symlinks without changing stat semantics", () =>
|
||||
Effect.gen(function* () {
|
||||
const driver = makeMemoryDriver()
|
||||
const files = makeFiles(driver)
|
||||
yield* files.mkdir("/directory")
|
||||
yield* files.write("/file", new Uint8Array())
|
||||
yield* driver.symlink("/directory", "/directory-link")
|
||||
yield* driver.symlink("/file", "/file-link")
|
||||
yield* driver.symlink("/missing", "/dangling-link")
|
||||
|
||||
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
|
||||
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
|
||||
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
environmentConformance("memory environment", () =>
|
||||
Effect.sync(() => {
|
||||
const driver = makeMemoryDriver()
|
||||
return {
|
||||
files: makeFiles(driver),
|
||||
root: `/workspace-${crypto.randomUUID()}`,
|
||||
symlink: driver.symlink,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
environmentConformance("local environment", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
|
||||
return {
|
||||
files: makeFiles(makeLocalDriver(spawner)),
|
||||
root: tmp.path,
|
||||
...(process.platform === "win32"
|
||||
? {}
|
||||
: {
|
||||
symlink: (target: string, link: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.symlink(target, link),
|
||||
catch: (cause) => new Failed({ path: link, cause }),
|
||||
}),
|
||||
}),
|
||||
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
}
|
||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||
)
|
||||
|
||||
environmentConformance(
|
||||
"GNU exec environment",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
|
||||
return {
|
||||
files: execDefaults(spawner),
|
||||
root: tmp.path,
|
||||
symlink: (target: string, link: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.symlink(target, link),
|
||||
catch: (cause) => new Failed({ path: link, cause }),
|
||||
}),
|
||||
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
}
|
||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||
process.platform !== "linux",
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
|
||||
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
@@ -21,7 +21,7 @@ function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.n
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[FSUtil.node, filesystemLayer],
|
||||
[Environment.node, environmentLayer],
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -152,6 +152,57 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("shares transaction locks across Location service instances", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const target = path.join(directory, "shared.txt")
|
||||
const first = yield* Effect.gen(function* () {
|
||||
const files = yield* FileMutation.Service
|
||||
yield* files.withLock([target])(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
}).pipe(provide(directory), Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* Effect.gen(function* () {
|
||||
const files = yield* FileMutation.Service
|
||||
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
|
||||
}).pipe(provide(directory), Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
const files = yield* FileMutation.Service
|
||||
const first = yield* files
|
||||
.withLock([path.join(directory, "first.txt")])(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
|
||||
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -191,16 +242,16 @@ describe("FileMutation", () => {
|
||||
|
||||
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
||||
return Layer.effect(
|
||||
FSUtil.Service,
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...filesystem,
|
||||
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
|
||||
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
|
||||
writeFileString: (target, content, options) =>
|
||||
run(filesystem.writeFileString(target, content, options), target),
|
||||
const environment = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...environment,
|
||||
files: {
|
||||
...environment.files,
|
||||
write: (target, content) => run(environment.files.write(target, content), target),
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
}
|
||||
|
||||
@@ -17,9 +17,8 @@ import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||
|
||||
@@ -75,10 +74,9 @@ describe("Watcher lifecycle", () => {
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
|
||||
Effect.flatMap(Stream.runDrain),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const consumer = yield* watcher
|
||||
.subscribe({ path: "/pending", type: "directory" })
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(consumer)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(true)
|
||||
@@ -99,10 +97,9 @@ describe("Watcher lifecycle", () => {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consume = () =>
|
||||
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
|
||||
Effect.flatMap(Stream.runDrain),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
watcher
|
||||
.subscribe({ path: "/shared", type: "directory" })
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
const first = yield* consume()
|
||||
const second = yield* consume()
|
||||
yield* Effect.yieldNow
|
||||
@@ -138,22 +135,26 @@ describe("Watcher lifecycle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
const built = AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
return Effect.provide(built)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(
|
||||
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
|
||||
options?: {
|
||||
vcs?: "git" | "hg"
|
||||
init?: (directory: string) => Promise<void>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
@@ -173,9 +174,57 @@ function withTmp<A, E, R>(
|
||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||
}),
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
|
||||
}
|
||||
|
||||
describe("LocationWatcher subscriptions", () => {
|
||||
it.live("watches only exact Git branch metadata", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
||||
}),
|
||||
)
|
||||
return withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
|
||||
}),
|
||||
{ vcs: "git", watcher },
|
||||
)
|
||||
})
|
||||
|
||||
it.live("watches only exact Hg branch metadata", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
||||
}),
|
||||
)
|
||||
return withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
|
||||
}),
|
||||
{ vcs: "hg", watcher },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function wait(check: (event: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -226,31 +275,18 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
|
||||
)
|
||||
}
|
||||
|
||||
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(check),
|
||||
({ deferred }) =>
|
||||
trigger.pipe(
|
||||
Effect.andThen(Deferred.await(deferred)),
|
||||
Effect.timeoutOption(`${timeout} millis`),
|
||||
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
|
||||
),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}
|
||||
|
||||
function ready(directory: string) {
|
||||
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||
function ready(file: string, eventFile = file) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
|
||||
yield* eventuallyUpdate(
|
||||
(event) => event.file === file,
|
||||
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||
(event) => event.file === eventFile,
|
||||
() => fs.writeFileString(file, content),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
describeWatcher("LocationWatcher", () => {
|
||||
describeNative("LocationWatcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -276,94 +312,25 @@ describeWatcher("LocationWatcher", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes root create, update, and delete events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const file = path.join(directory, "watch.txt")
|
||||
yield* ready(directory)
|
||||
for (const item of [
|
||||
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
||||
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
||||
{ event: "unlink" as const, trigger: fs.remove(file) },
|
||||
]) {
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
|
||||
).toEqual({
|
||||
file,
|
||||
event: item.event,
|
||||
})
|
||||
}
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips non-git roots", () =>
|
||||
it.live("detects creation of a missing directory target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const file = path.join(directory, "plain.txt")
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
const watcher = yield* Watcher.Service
|
||||
const target = path.join(directory, "generated")
|
||||
const updates = yield* watcher.subscribe({ path: target, type: "file" })
|
||||
const update = yield* updates.pipe(
|
||||
Stream.take(1),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const creates = yield* Effect.suspend(() =>
|
||||
fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
|
||||
).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
|
||||
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
|
||||
|
||||
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleanup stops publishing events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* ready(tmp.path).pipe(
|
||||
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
|
||||
Effect.scoped,
|
||||
)
|
||||
const file = path.join(tmp.path, "after-dispose.txt")
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.live("ignores .git/index changes", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const index = path.join(directory, ".git", "index")
|
||||
yield* ready(directory)
|
||||
yield* noUpdate(
|
||||
(event) => event.file === index,
|
||||
fs
|
||||
.writeFileString(path.join(directory, "tracked.txt"), "a")
|
||||
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
||||
)
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
expect(event.valueOrUndefined?.path).toBe(target)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -374,11 +341,11 @@ describeWatcher("LocationWatcher", () => {
|
||||
const fs = yield* FSUtil.Service
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* ready(directory)
|
||||
yield* ready(head)
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toMatchObject({ file: head })
|
||||
).toEqual({ file: head, event: "change" })
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
@@ -393,8 +360,8 @@ describeWatcher("LocationWatcher", () => {
|
||||
const afs = yield* FSUtil.Service
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
||||
yield* ready(directory)
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
yield* ready(head, path.join(actual, "HEAD"))
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
@@ -422,7 +389,7 @@ describeWatcher("LocationWatcher", () => {
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(directory)
|
||||
yield* ready(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
|
||||
import { it } from "./effect"
|
||||
|
||||
export interface EnvironmentHarness {
|
||||
readonly files: Files
|
||||
readonly root: string
|
||||
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
readonly dispose?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const environmentConformance = <E>(
|
||||
name: string,
|
||||
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
|
||||
skip = false,
|
||||
) => {
|
||||
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
|
||||
it.live(title, () =>
|
||||
Effect.gen(function* () {
|
||||
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.ignore(harness.files.remove(harness.root))
|
||||
if (harness.dispose) yield* harness.dispose
|
||||
}),
|
||||
)
|
||||
yield* harness.files.mkdir(harness.root)
|
||||
return yield* body(harness)
|
||||
}),
|
||||
)
|
||||
|
||||
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||
const text = (value: Uint8Array) => new TextDecoder().decode(value)
|
||||
const suite = skip ? describe.skip : describe
|
||||
|
||||
suite(name, () => {
|
||||
check("writes, stats, and reads a file with its info", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/hello.txt`
|
||||
yield* harness.files.write(target, bytes("hello"))
|
||||
const result = yield* harness.files.read(target)
|
||||
expect(text(result.bytes)).toBe("hello")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(5)
|
||||
expect(yield* harness.files.stat(target)).toEqual(result.info)
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports missing paths", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/missing`
|
||||
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports the actual kind", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = `${harness.root}/directory`
|
||||
const file = `${harness.root}/file`
|
||||
yield* harness.files.mkdir(directory)
|
||||
yield* harness.files.write(file, bytes("data"))
|
||||
const readError = yield* Effect.flip(harness.files.read(directory))
|
||||
const listError = yield* Effect.flip(harness.files.list(file))
|
||||
expect(readError).toBeInstanceOf(WrongKind)
|
||||
expect((readError as WrongKind).actual).toBe("directory")
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("file")
|
||||
}),
|
||||
)
|
||||
|
||||
check("write creates parent directories", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/one/two/file`
|
||||
yield* harness.files.write(target, bytes("nested"))
|
||||
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
|
||||
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
|
||||
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
|
||||
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
|
||||
}),
|
||||
)
|
||||
|
||||
check("reads byte ranges", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/range`
|
||||
yield* harness.files.write(target, bytes("0123456789"))
|
||||
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
|
||||
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
|
||||
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
check("lists immediate entries with their kinds", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
|
||||
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
|
||||
const entries = yield* harness.files.list(harness.root)
|
||||
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
|
||||
{ name: "directory", type: "directory" },
|
||||
{ name: "file name", type: "file" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
check("preserves symlink metadata while following symlinks for content", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
|
||||
yield* harness.symlink("target", `${harness.root}/link`)
|
||||
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||
expect(
|
||||
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
).toEqual([
|
||||
{ name: "entry-link", type: "symlink" },
|
||||
{ name: "file", type: "file" },
|
||||
])
|
||||
|
||||
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
|
||||
expect(fileError).toBeInstanceOf(WrongKind)
|
||||
expect((fileError as WrongKind).actual).toBe("file")
|
||||
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("follows symlinks when reading", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
|
||||
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||
yield* harness.symlink("target", `${harness.root}/file-link`)
|
||||
yield* harness.symlink("directory", `${harness.root}/directory-link`)
|
||||
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||
|
||||
const result = yield* harness.files.read(`${harness.root}/file-link`)
|
||||
expect(text(result.bytes)).toBe("target content")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(bytes("target content").length)
|
||||
|
||||
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
|
||||
expect(directoryError).toBeInstanceOf(WrongKind)
|
||||
expect((directoryError as WrongKind).actual).toBe("directory")
|
||||
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("moves files and removes trees idempotently", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const source = `${harness.root}/source/file`
|
||||
const destination = `${harness.root}/destination`
|
||||
yield* harness.files.write(source, bytes("moved"))
|
||||
yield* harness.files.move(source, destination)
|
||||
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
|
||||
yield* harness.files.remove(`${harness.root}/source`)
|
||||
yield* harness.files.remove(`${harness.root}/source`)
|
||||
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
@@ -127,23 +128,34 @@ describe("SessionExecution lifecycle", () => {
|
||||
it.effect("resumes each suspended Session at most once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.make("ses_resume_first")
|
||||
const second = Session.ID.make("ses_resume_second")
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
|
||||
const drained: string[] = []
|
||||
const continued: SessionEvent.Synthetic[] = []
|
||||
const scope = yield* Scope.make()
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([first, second])
|
||||
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
[first, second].map((sessionID) => ({
|
||||
sessionID,
|
||||
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
|
||||
description: "Continuing after restart",
|
||||
})),
|
||||
)
|
||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained.length).toBe(2)
|
||||
expect(continued.length).toBe(2)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cancels pending input and allows its ID to be admitted again", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
|
||||
yield* session.prompt({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
text: "Queue this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.cancelPending({ sessionID, inputID })
|
||||
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
expect(
|
||||
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
|
||||
const retried = yield* session.prompt({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
text: "Queue this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves pending input between steer and queue delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const queued = yield* session.synthetic({
|
||||
sessionID,
|
||||
text: "Steer this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.steerPending({ sessionID, inputID: queued.id })
|
||||
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||
{ id: queued.id, delivery: "steer" },
|
||||
{ id: alreadySteered.id, delivery: "steer" },
|
||||
])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||
|
||||
wakeCalls.length = 0
|
||||
yield* session.queuePending({ sessionID, inputID: queued.id })
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||
{ id: queued.id, delivery: "queue" },
|
||||
{ id: alreadySteered.id, delivery: "steer" },
|
||||
])
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
|
||||
|
||||
expect(
|
||||
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
|
||||
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -25,8 +24,15 @@ const discovery = Layer.succeed(
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
|
||||
[SkillDiscovery.node, discovery],
|
||||
[Watcher.node, watcherLayer],
|
||||
]),
|
||||
watcherLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
@@ -53,6 +59,24 @@ function waitForSkillUpdate() {
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe("Skill", () => {
|
||||
it.live("publishes updates when skill sources change", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -89,6 +113,7 @@ describe("Skill", () => {
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => {
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
@@ -119,6 +144,21 @@ describe("Skill", () => {
|
||||
content: "# review",
|
||||
},
|
||||
])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => write(second, "review", "Updated Second"))
|
||||
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -198,7 +238,7 @@ metadata:
|
||||
),
|
||||
)
|
||||
|
||||
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
|
||||
it.live("clears cached skills when sources reload", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -210,26 +250,187 @@ metadata:
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
|
||||
|
||||
let refreshed: Skill.Info[] = []
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (event.type !== Skill.Event.Updated.type) return Effect.void
|
||||
return skill.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* skill.reload().pipe(Effect.timeout("1 second"))
|
||||
yield* unsubscribe
|
||||
|
||||
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: tmp.path, type: "directory" },
|
||||
{ path: tmp.path, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads project sources created after their missing parent", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const file = path.join(source, "deploy", "SKILL.md")
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||
await write(source, "deploy", "Deploy production")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: source, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches directory sources for added and changed skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
|
||||
|
||||
const file = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
bus
|
||||
.publish(FileSystem.Event.Changed, { file, event: "change" })
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review changes")
|
||||
})
|
||||
const review = path.join(tmp.path, "review", "SKILL.md")
|
||||
yield* emitAndWait({ type: "create", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"))
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("invalidates symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source)
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -23,7 +23,15 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
Location.node,
|
||||
Permission.node,
|
||||
],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||
@@ -72,29 +80,28 @@ const reset = () => {
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readFile: (target) =>
|
||||
fs
|
||||
.readFile(target)
|
||||
.pipe(
|
||||
Effect.tap((content) =>
|
||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
read: (target, range) =>
|
||||
current.files
|
||||
.read(target, range)
|
||||
.pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
writeWithDirs: (target, content, mode) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||
writeFile: (target, content, options) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
|
||||
writeFileString: (target, content, options) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -106,15 +113,9 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Tool.node,
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
editToolNode,
|
||||
]),
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Environment.node, environment],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
@@ -471,10 +472,7 @@ describe("EditTool", () => {
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call({ path: "missing.ts", oldString: "before", newString: "after" }),
|
||||
),
|
||||
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "File not found: missing.ts" },
|
||||
@@ -645,6 +643,43 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent edit transactions", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "concurrent.txt")
|
||||
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
|
||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.all(
|
||||
[
|
||||
executeTool(
|
||||
registry,
|
||||
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
||||
),
|
||||
executeTool(
|
||||
registry,
|
||||
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.andThen((results) =>
|
||||
Effect.gen(function* () {
|
||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies the edit when content changes after matching", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -2,11 +2,12 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Schema } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -81,48 +82,33 @@ const reset = () => {
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readFile: (target) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).pipe(Effect.andThen(fs.readFile(target))),
|
||||
remove: (target, options) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "remove",
|
||||
description: "forced remove failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.remove(target, options)
|
||||
},
|
||||
writeWithDirs: (target, content, mode) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "writeWithDirs",
|
||||
description: "forced write failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.writeWithDirs(target, content, mode)
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
read: (target, range) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).pipe(Effect.andThen(current.files.read(target, range))),
|
||||
remove: (target) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
|
||||
return current.files.remove(target)
|
||||
},
|
||||
write: (target, content) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
|
||||
return current.files.write(target, content)
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
|
||||
const withTool = <A, E, R>(
|
||||
directory: string,
|
||||
@@ -139,8 +125,8 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
||||
[Environment.node, environment],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
@@ -262,6 +248,43 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent patch transactions", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "concurrent.txt")
|
||||
afterEditApproval = () =>
|
||||
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
|
||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||
Effect.andThen(
|
||||
Effect.all(
|
||||
[
|
||||
executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
|
||||
"call-patch-one",
|
||||
),
|
||||
),
|
||||
executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
|
||||
"call-patch-two",
|
||||
),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
Effect.andThen((results) =>
|
||||
Effect.gen(function* () {
|
||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns file diffs for final formatted content", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "formatted.txt")
|
||||
|
||||
@@ -1,66 +1,65 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem])))
|
||||
const fixture = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const directory = yield* files.makeTempDirectoryScoped()
|
||||
return { fs, files, directory }
|
||||
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
|
||||
})
|
||||
const absolute = (value: string) => AbsolutePath.make(value)
|
||||
|
||||
describe("ReadToolFileSystem", () => {
|
||||
it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
|
||||
it.effect("preserves the environment not-found error", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, directory } = yield* fixture
|
||||
const { environment, directory } = yield* fixture
|
||||
const file = path.join(directory, "missing.txt")
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "PlatformError" })
|
||||
expect(error).toBeInstanceOf(Environment.NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when a file becomes the wrong path kind", () =>
|
||||
it.effect("returns a listing when read reports a directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed filesystem error when directory listing fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "file.txt")
|
||||
yield* files.writeFileString(file, "hello")
|
||||
|
||||
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
|
||||
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
|
||||
expect(result).toMatchObject({
|
||||
type: "list-page",
|
||||
entries: [
|
||||
{ path: `folder${path.sep}`, type: "directory" },
|
||||
{ path: "file.txt", type: "file" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const binary = path.join(directory, "archive.dat")
|
||||
const malformed = path.join(directory, "malformed.txt")
|
||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||
|
||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
||||
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
|
||||
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt")
|
||||
|
||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||
@@ -70,11 +69,11 @@ describe("ReadToolFileSystem", () => {
|
||||
|
||||
it.effect("reads text despite a binary-associated extension", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "notes.docx")
|
||||
yield* files.writeFileString(file, "plain text")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
|
||||
|
||||
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
||||
}),
|
||||
@@ -83,15 +82,17 @@ describe("ReadToolFileSystem", () => {
|
||||
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const { fs: service, files, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const outside = yield* files.makeTempDirectoryScoped()
|
||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
||||
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
||||
|
||||
const result = yield* ReadToolFileSystem.list(service, directory)
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
|
||||
|
||||
expect(result.type).toBe("list-page")
|
||||
if (result.type !== "list-page") return
|
||||
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
||||
{ path: `folder${path.sep}`, type: "directory" },
|
||||
{ path: "broken", type: "symlink" },
|
||||
@@ -101,45 +102,154 @@ describe("ReadToolFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads a symlinked directory as a listing", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const target = path.join(directory, "target")
|
||||
const link = path.join(directory, "link")
|
||||
yield* files.makeDirectory(target)
|
||||
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
|
||||
yield* Effect.promise(() => fs.symlink(target, link))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "list-page",
|
||||
entries: [{ path: "file.txt", type: "file" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports out-of-range pagination as a typed error", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "short.txt")
|
||||
yield* files.writeFileString(file, "one\n")
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
|
||||
expect(error.message).toBe("Offset 2 is out of range")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops reading after the requested page is complete", () =>
|
||||
it.effect("pages text with one-based offsets", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const prefix = new TextEncoder().encode("one\n")
|
||||
for (const [name, trailing] of [
|
||||
["malformed.txt", 0x80],
|
||||
["nul.txt", 0],
|
||||
] as const) {
|
||||
const file = path.join(directory, name)
|
||||
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "lines.txt")
|
||||
yield* files.writeFileString(file, "one\r\ntwo\nthree")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
|
||||
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("truncates long lines", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "long.txt")
|
||||
yield* files.writeFileString(file, "a".repeat(2_001))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "text-page",
|
||||
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enforces line and byte budgets with continuation offsets", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const linesFile = path.join(directory, "many-lines.txt")
|
||||
const bytesFile = path.join(directory, "many-bytes.txt")
|
||||
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
|
||||
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
|
||||
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
|
||||
const tracked = {
|
||||
...environment,
|
||||
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
|
||||
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
|
||||
}
|
||||
|
||||
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
|
||||
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
|
||||
|
||||
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
|
||||
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
|
||||
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
|
||||
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
|
||||
ReadToolFileSystem.MAX_READ_BYTES,
|
||||
)
|
||||
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sorts and pages directory entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
yield* files.makeDirectory(path.join(directory, "z"))
|
||||
yield* files.makeDirectory(path.join(directory, "a"))
|
||||
yield* files.writeFileString(path.join(directory, "b.txt"), "")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "list-page",
|
||||
entries: [{ path: `z${path.sep}`, type: "directory" }],
|
||||
truncated: true,
|
||||
next: 3,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops checking for null bytes after the requested page", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "nul.txt")
|
||||
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
|
||||
|
||||
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads page two after fetching more than the first 256KB range", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "large.txt")
|
||||
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the media ingestion limit message", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "oversized.png")
|
||||
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
||||
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
|
||||
expect(error.message).toBe(
|
||||
@@ -150,11 +260,11 @@ describe("ReadToolFileSystem", () => {
|
||||
|
||||
it.effect("reads PDFs as bounded media", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "document.pdf")
|
||||
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
|
||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: "file",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
@@ -21,6 +21,7 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -42,24 +43,13 @@ const readToolNode = makeLocationNode({
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const missingPath = "__missing_read_target__.txt"
|
||||
const missingAbsolutePath = path.join(process.cwd(), missingPath)
|
||||
const notFound = (target: string) =>
|
||||
PlatformError.systemError({
|
||||
_tag: "NotFound",
|
||||
module: "FileSystem",
|
||||
method: "stat",
|
||||
pathOrDescriptor: target,
|
||||
})
|
||||
const readCalls: {
|
||||
input: AbsolutePath
|
||||
page: ReadToolFileSystem.PageInput
|
||||
}[] = []
|
||||
const listCalls: ReadToolFileSystem.PageInput[] = []
|
||||
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
|
||||
let resolvedType: "file" | "directory" = "file"
|
||||
let resolveFailure: unknown
|
||||
let inspectFailure: ReadToolFileSystem.InspectError | undefined
|
||||
let directoryEntries: string[] = []
|
||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
|
||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
|
||||
type: "file",
|
||||
uri: "file:///README.md",
|
||||
name: "README.md",
|
||||
@@ -71,22 +61,12 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
|
||||
const reader = Layer.succeed(
|
||||
ReadToolFileSystem.Service,
|
||||
ReadToolFileSystem.Service.of({
|
||||
inspect: () =>
|
||||
resolveFailure !== undefined
|
||||
? Effect.die(resolveFailure)
|
||||
: inspectFailure !== undefined
|
||||
? Effect.fail(inspectFailure)
|
||||
: Effect.succeed(resolvedType),
|
||||
read: (input, _resource, page = {}) => {
|
||||
readCalls.push({ input, page })
|
||||
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
|
||||
if (readFailure !== undefined) return Effect.fail(readFailure)
|
||||
return Effect.succeed(readResult)
|
||||
},
|
||||
list: (_path, input = {}) =>
|
||||
Effect.sync(() => {
|
||||
listCalls.push(input)
|
||||
return listResult
|
||||
}),
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
@@ -125,17 +105,6 @@ const testFileSystem = Layer.effect(
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
readDirectory: () => Effect.succeed(directoryEntries),
|
||||
realPath: (path) =>
|
||||
path === missingAbsolutePath
|
||||
? Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "NotFound",
|
||||
module: "FileSystem",
|
||||
method: "realPath",
|
||||
pathOrDescriptor: path,
|
||||
}),
|
||||
)
|
||||
: Effect.succeed(path),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -195,11 +164,8 @@ describe("ReadTool", () => {
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
readCalls.length = 0
|
||||
listCalls.length = 0
|
||||
allow = true
|
||||
resolvedType = "file"
|
||||
resolveFailure = undefined
|
||||
inspectFailure = undefined
|
||||
directoryEntries = []
|
||||
readResult = {
|
||||
type: "file",
|
||||
@@ -210,7 +176,6 @@ describe("ReadTool", () => {
|
||||
mime: "text/plain",
|
||||
}
|
||||
readFailure = undefined
|
||||
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
|
||||
})
|
||||
|
||||
it.effect("registers, authorizes, and reads through the location filesystem", () =>
|
||||
@@ -672,7 +637,7 @@ describe("ReadTool", () => {
|
||||
|
||||
it.effect("returns missing paths as model-visible tool failures", () =>
|
||||
Effect.gen(function* () {
|
||||
inspectFailure = notFound(missingAbsolutePath)
|
||||
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
|
||||
directoryEntries = [
|
||||
"__missing_read_target__.txt.bak",
|
||||
"copy___missing_read_target__.txt",
|
||||
@@ -696,14 +661,18 @@ describe("ReadTool", () => {
|
||||
},
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
|
||||
expect(readCalls).toEqual([])
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(missingAbsolutePath),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists a bounded directory page through read", () =>
|
||||
Effect.gen(function* () {
|
||||
resolvedType = "directory"
|
||||
listResult = new ReadToolFileSystem.ListPage({
|
||||
readResult = new ReadToolFileSystem.ListPage({
|
||||
type: "list-page",
|
||||
entries: [
|
||||
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
|
||||
@@ -726,7 +695,7 @@ describe("ReadTool", () => {
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||
output: { entries: readResult.entries, truncated: true, next: 4 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
@@ -737,14 +706,15 @@ describe("ReadTool", () => {
|
||||
},
|
||||
])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
||||
expect(readCalls).toEqual([
|
||||
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not list a directory when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
allow = false
|
||||
resolvedType = "directory"
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -754,7 +724,7 @@ describe("ReadTool", () => {
|
||||
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
||||
}),
|
||||
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
|
||||
expect(listCalls).toEqual([])
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -773,7 +743,12 @@ describe("ReadTool", () => {
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(readCalls).toEqual([])
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -24,19 +24,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
FSUtil.node,
|
||||
Ripgrep.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
Permission.node,
|
||||
],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||
|
||||
@@ -186,9 +179,7 @@ describe("search tools", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
|
||||
),
|
||||
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result).toMatchObject({
|
||||
@@ -297,9 +288,7 @@ describe("search tools", () => {
|
||||
(tmp) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) =>
|
||||
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
|
||||
),
|
||||
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
|
||||
),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
@@ -331,9 +320,7 @@ describe("search tools", () => {
|
||||
Effect.sync(() => {
|
||||
expect(result.status).toBe("completed")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
|
||||
expect(assertions[0]?.resources).toEqual([
|
||||
path.join(outside.path, "*").replaceAll("\\", "/"),
|
||||
])
|
||||
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -524,7 +524,8 @@ describe("ShellTool", () => {
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text).not.toContain("one")
|
||||
expect(content.text).toStartWith("two\nthree")
|
||||
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
|
||||
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
|
||||
expect(content.text).toContain("output truncated; full output saved to:")
|
||||
})
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
@@ -68,17 +68,20 @@ const reset = () => {
|
||||
denyAction = undefined
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
writeWithDirs: (target, content, mode) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -92,7 +95,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Environment.node, environment],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
TextBufferRenderable,
|
||||
type ColorInput,
|
||||
type RenderContext,
|
||||
type RGBA,
|
||||
type StyledText,
|
||||
type TextBufferOptions,
|
||||
} from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasTextOptions } from "../canvas.js"
|
||||
import { setDiagramRenderableColor } from "./renderable-color.js"
|
||||
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
|
||||
|
||||
interface DiagramRenderableOptions<Diagram, Grid extends DiagramCanvas<any, any>> {
|
||||
parse: () => Diagram
|
||||
draw: (diagram: Diagram) => Grid
|
||||
publish: (grid: Grid) => StyledText
|
||||
measure?: DiagramCanvasTextOptions
|
||||
}
|
||||
|
||||
export abstract class DiagramRenderable<Diagram, Grid extends DiagramCanvas<any, any>> extends TextBufferRenderable {
|
||||
private _content: string
|
||||
private _renderedWidth = 0
|
||||
private _renderedHeight = 0
|
||||
private _pipeline?: DiagramRenderablePipeline<Diagram, Grid>
|
||||
|
||||
protected constructor(ctx: RenderContext, options: TextBufferOptions & { content?: string }) {
|
||||
super(ctx, { ...options, wrapMode: options.wrapMode ?? "none" })
|
||||
this._content = options.content ?? ""
|
||||
}
|
||||
|
||||
protected initializeDiagram(options: DiagramRenderableOptions<Diagram, Grid>): void {
|
||||
this._pipeline = new DiagramRenderablePipeline({
|
||||
parse: options.parse,
|
||||
draw: options.draw,
|
||||
didDraw: (grid) => {
|
||||
const size = grid.getTextSize(options.measure)
|
||||
this._renderedWidth = size.width
|
||||
this._renderedHeight = size.height
|
||||
},
|
||||
publish: (grid) => {
|
||||
this.textBuffer.setStyledText(options.publish(grid))
|
||||
this.updateTextInfo()
|
||||
},
|
||||
})
|
||||
this._pipeline.invalidateParsedDiagram()
|
||||
}
|
||||
|
||||
get content(): string {
|
||||
return this._content
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
if (this._content === value) return
|
||||
this._content = value
|
||||
this.contentChanged()
|
||||
this.pipeline.invalidateParsedDiagram()
|
||||
}
|
||||
|
||||
get renderedWidth(): number {
|
||||
return this._renderedWidth
|
||||
}
|
||||
|
||||
get renderedHeight(): number {
|
||||
return this._renderedHeight
|
||||
}
|
||||
|
||||
batchUpdate(update: () => void): void {
|
||||
this.pipeline.batchUpdate(update)
|
||||
}
|
||||
|
||||
protected contentChanged(): void {}
|
||||
|
||||
protected parsedDiagram(): Diagram {
|
||||
return this.pipeline.diagram()
|
||||
}
|
||||
|
||||
protected invalidateGrid(): void {
|
||||
this.pipeline.invalidateGrid()
|
||||
}
|
||||
|
||||
protected invalidateStyle(): void {
|
||||
this.pipeline.invalidateStyle()
|
||||
}
|
||||
|
||||
protected setColor(
|
||||
current: RGBA | undefined,
|
||||
value: ColorInput | undefined,
|
||||
assign: (color: RGBA | undefined) => void,
|
||||
): void {
|
||||
setDiagramRenderableColor(current, value, assign, () => this.invalidateStyle())
|
||||
}
|
||||
|
||||
private get pipeline(): DiagramRenderablePipeline<Diagram, Grid> {
|
||||
if (!this._pipeline) throw new Error("Diagram renderable was not initialized")
|
||||
return this._pipeline
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { parseColor, type ColorInput, type RGBA } from "@opentui/core"
|
||||
import { colorsEqual } from "../color/style.js"
|
||||
|
||||
export function parseDiagramRenderableColor(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value ? parseColor(value) : undefined
|
||||
}
|
||||
|
||||
export function setDiagramRenderableColor(
|
||||
current: RGBA | undefined,
|
||||
value: ColorInput | undefined,
|
||||
assign: (color: RGBA | undefined) => void,
|
||||
invalidate: () => void,
|
||||
): void {
|
||||
const next = parseDiagramRenderableColor(value)
|
||||
if (colorsEqual(current, next)) return
|
||||
assign(next)
|
||||
invalidate()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
|
||||
|
||||
describe("DiagramRenderablePipeline", () => {
|
||||
test("caches the parsed diagram and grid across style repaints", () => {
|
||||
let parseCount = 0
|
||||
let drawCount = 0
|
||||
let publishCount = 0
|
||||
const pipeline = new DiagramRenderablePipeline({
|
||||
parse: () => ({ version: ++parseCount }),
|
||||
draw: (diagram) => ({ version: diagram.version, draw: ++drawCount }),
|
||||
publish: () => {
|
||||
publishCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
pipeline.invalidateParsedDiagram()
|
||||
pipeline.invalidateStyle()
|
||||
pipeline.invalidateGrid()
|
||||
|
||||
expect({ parseCount, drawCount, publishCount }).toEqual({ parseCount: 1, drawCount: 2, publishCount: 3 })
|
||||
})
|
||||
|
||||
test("reparses source changes and coalesces nested invalidations", () => {
|
||||
const events: string[] = []
|
||||
const pipeline = new DiagramRenderablePipeline({
|
||||
parse: () => {
|
||||
events.push("parse")
|
||||
return {}
|
||||
},
|
||||
draw: (diagram) => {
|
||||
events.push("draw")
|
||||
return diagram
|
||||
},
|
||||
publish: () => events.push("publish"),
|
||||
})
|
||||
|
||||
pipeline.invalidateParsedDiagram()
|
||||
events.length = 0
|
||||
pipeline.batchUpdate(() => {
|
||||
pipeline.invalidateStyle()
|
||||
pipeline.batchUpdate(() => pipeline.invalidateGrid())
|
||||
pipeline.invalidateParsedDiagram()
|
||||
})
|
||||
|
||||
expect(events).toEqual(["parse", "draw", "publish"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
export interface DiagramRenderablePipelineOptions<Diagram, Grid> {
|
||||
parse: () => Diagram
|
||||
draw: (diagram: Diagram) => Grid
|
||||
publish: (grid: Grid) => void
|
||||
didDraw?: (grid: Grid) => void
|
||||
}
|
||||
|
||||
type DiagramRenderableInvalidation = "grid" | "style"
|
||||
|
||||
export class DiagramRenderablePipeline<Diagram, Grid> {
|
||||
private _diagram?: Diagram
|
||||
private _grid?: Grid
|
||||
private _batchDepth = 0
|
||||
private _pending?: DiagramRenderableInvalidation
|
||||
|
||||
constructor(private readonly options: DiagramRenderablePipelineOptions<Diagram, Grid>) {}
|
||||
|
||||
diagram(): Diagram {
|
||||
this._diagram ??= this.options.parse()
|
||||
return this._diagram
|
||||
}
|
||||
|
||||
batchUpdate(update: () => void): void {
|
||||
this._batchDepth += 1
|
||||
try {
|
||||
update()
|
||||
} finally {
|
||||
this._batchDepth -= 1
|
||||
if (this._batchDepth === 0 && this._pending) {
|
||||
const pending = this._pending
|
||||
this._pending = undefined
|
||||
this.render(pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateParsedDiagram(): void {
|
||||
this._diagram = undefined
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
invalidateGrid(): void {
|
||||
this._grid = undefined
|
||||
this.invalidate("grid")
|
||||
}
|
||||
|
||||
invalidateStyle(): void {
|
||||
this.invalidate("style")
|
||||
}
|
||||
|
||||
private invalidate(level: DiagramRenderableInvalidation): void {
|
||||
if (this._batchDepth > 0) {
|
||||
if (level === "grid" || !this._pending) this._pending = level
|
||||
return
|
||||
}
|
||||
this.render(level)
|
||||
}
|
||||
|
||||
private render(level: DiagramRenderableInvalidation): void {
|
||||
let grid = this._grid
|
||||
if (level === "grid" || !grid) {
|
||||
grid = this.options.draw(this.diagram())
|
||||
this._grid = grid
|
||||
this.options.didDraw?.(grid)
|
||||
}
|
||||
this.options.publish(grid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stringWidth from "string-width"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "./canvas.js"
|
||||
|
||||
describe("DiagramCanvas", () => {
|
||||
test("writes cells and text while clipping out-of-bounds positions", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 2)
|
||||
|
||||
canvas.setCell(0, 0, "A", "label")
|
||||
canvas.setCell(9, 0, "X", "label")
|
||||
canvas.setText(2, 1, "hey", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("A\n hey")
|
||||
})
|
||||
|
||||
test("uses measured character widths for text placement", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 1)
|
||||
|
||||
canvas.setText(0, 0, "a界b", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("a界b")
|
||||
expect(stringWidth(canvas.toString())).toBe(4)
|
||||
})
|
||||
|
||||
test("preserves combined graphemes while placing later text", () => {
|
||||
const canvas = new DiagramCanvas<"label">(4, 1)
|
||||
|
||||
canvas.setText(0, 0, "e\u0301x", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("e\u0301x")
|
||||
expect(stringWidth(canvas.toString())).toBe(2)
|
||||
})
|
||||
|
||||
test("merges cells through the adapter-provided merge function", () => {
|
||||
type Style = "line"
|
||||
const canvas = new DiagramCanvas<Style>(3, 1, {
|
||||
mergeCell: (existing, incoming): DiagramCanvasCell<Style> => ({
|
||||
...incoming,
|
||||
char: existing.char === "─" && incoming.char === "│" ? "┼" : incoming.char,
|
||||
}),
|
||||
})
|
||||
|
||||
canvas.setCell(1, 0, "─", "line")
|
||||
canvas.setCell(1, 0, "│", "line")
|
||||
|
||||
expect(canvas.toString()).toBe(" ┼")
|
||||
})
|
||||
|
||||
test("iterates style and metadata runs", () => {
|
||||
interface Metadata {
|
||||
stateId?: string
|
||||
}
|
||||
const canvas = new DiagramCanvas<"state", Metadata>(4, 1)
|
||||
const runs: string[] = []
|
||||
|
||||
canvas.setText(0, 0, "AB", "state", { stateId: "A" })
|
||||
canvas.setText(2, 0, "CD", "state", { stateId: "B" })
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(`${run.text}:${run.style}:${run.cell.stateId}`),
|
||||
() => runs.push("newline"),
|
||||
{ key: (cell) => [cell.style, cell.stateId] },
|
||||
)
|
||||
|
||||
expect(runs).toEqual(["AB:state:A", "CD:state:B"])
|
||||
})
|
||||
|
||||
test("can trim bottom whitespace for renderers with dynamic height", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
const runs: string[] = []
|
||||
canvas.setText(0, 0, "top", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("top\n\n")
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("top")
|
||||
expect(canvas.getTextSize()).toEqual({ width: 3, height: 3 })
|
||||
expect(canvas.getTextSize({ trimBottom: true })).toEqual({ width: 3, height: 1 })
|
||||
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(run.text),
|
||||
() => runs.push("newline"),
|
||||
{ trimBottom: true },
|
||||
)
|
||||
expect(runs).toEqual(["top"])
|
||||
})
|
||||
|
||||
test("can trim unused leading whitespace reserved by layout", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
canvas.setText(0, 2, "end", "label")
|
||||
|
||||
expect(canvas.toString({ trimTop: true })).toBe("end")
|
||||
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import stringWidth from "string-width"
|
||||
import { diagramTextGraphemes } from "./text.js"
|
||||
|
||||
export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
|
||||
char: string
|
||||
style?: Style
|
||||
} & Partial<Metadata>
|
||||
|
||||
export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
|
||||
text: string
|
||||
style: Style | undefined
|
||||
cell: DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
|
||||
measure?: (text: string) => number
|
||||
mergeCell?: (
|
||||
existing: DiagramCanvasCell<Style, Metadata>,
|
||||
incoming: DiagramCanvasCell<Style, Metadata>,
|
||||
) => DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextOptions {
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type DiagramCanvasTextMetadata<Metadata extends object> =
|
||||
| Partial<Metadata>
|
||||
| ((x: number, y: number) => Partial<Metadata>)
|
||||
|
||||
export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
|
||||
key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
|
||||
return { char: " " } as DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
|
||||
return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
|
||||
}
|
||||
|
||||
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
|
||||
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
|
||||
|
||||
private readonly measure: (text: string) => number
|
||||
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
|
||||
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
options: DiagramCanvasOptions<Style, Metadata> = {},
|
||||
) {
|
||||
this.measure = options.measure ?? stringWidth
|
||||
this.mergeCell = options.mergeCell
|
||||
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
|
||||
}
|
||||
|
||||
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
|
||||
let rowEnd = row.length
|
||||
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
|
||||
return rowEnd
|
||||
}
|
||||
|
||||
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
|
||||
return row
|
||||
.slice(0, rowEnd)
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
}
|
||||
|
||||
private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
|
||||
let start = 0
|
||||
let end = this.rows.length
|
||||
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
|
||||
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
|
||||
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[y]!.length) return
|
||||
|
||||
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
|
||||
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
|
||||
}
|
||||
|
||||
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
|
||||
return this.rows[y]?.[x]
|
||||
}
|
||||
|
||||
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
|
||||
let offset = 0
|
||||
for (const grapheme of diagramTextGraphemes(text)) {
|
||||
const width = Math.max(1, this.measure(grapheme))
|
||||
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
|
||||
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
|
||||
for (let continuation = 1; continuation < width; continuation++) {
|
||||
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
|
||||
}
|
||||
offset += width
|
||||
}
|
||||
}
|
||||
|
||||
toString(options: DiagramCanvasTextOptions = {}): string {
|
||||
const lines: string[] = []
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
lines.push(this.rowText(this.rows[rowIndex]!))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
let width = 0
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
|
||||
}
|
||||
return { width, height: rows.end - rows.start }
|
||||
}
|
||||
|
||||
forEachRun(
|
||||
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
|
||||
onLineEnd: () => void,
|
||||
options: DiagramCanvasRunOptions<Style, Metadata> = {},
|
||||
): void {
|
||||
const key = options.key
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
|
||||
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
|
||||
let currentKey: readonly unknown[] | undefined
|
||||
let currentText = ""
|
||||
const flush = () => {
|
||||
if (!currentText || !currentCell) return
|
||||
onRun({ text: currentText, style: currentCell.style, cell: currentCell })
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
for (let x = 0; x < rowEnd; x++) {
|
||||
const cell = row[x]!
|
||||
const nextKey = key?.(cell)
|
||||
const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
|
||||
if (!sameRun) {
|
||||
flush()
|
||||
currentCell = cell
|
||||
currentKey = nextKey
|
||||
}
|
||||
currentText += cell.char
|
||||
}
|
||||
|
||||
flush()
|
||||
if (rowIndex < rows.end - 1) onLineEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
|
||||
import { colorsEqual } from "./style.js"
|
||||
|
||||
export type DiagramColorMapInput = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
|
||||
|
||||
export const DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR = "::cell:"
|
||||
export const DIAGRAM_CELL_COLOR_LEVEL_COUNT = 6
|
||||
|
||||
export function diagramCellColorKey(id: string, level: number): string {
|
||||
const normalizedLevel = Math.max(0, Math.min(DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1, Math.round(level)))
|
||||
return `${id}${DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR}${normalizedLevel}`
|
||||
}
|
||||
|
||||
export function diagramRadialCellColorLevel(
|
||||
bounds: { width: number; height: number; centerX: number; centerY: number },
|
||||
x: number,
|
||||
y: number,
|
||||
border = false,
|
||||
): number {
|
||||
const halfWidth = Math.max(1, (bounds.width - 1) / 2)
|
||||
const halfHeight = Math.max(1, (bounds.height - 1) / 2)
|
||||
const dx = (x - bounds.centerX) / halfWidth
|
||||
const dy = (y - bounds.centerY) / halfHeight
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const level = Math.round((1 - Math.min(1, distance)) * (DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1))
|
||||
return border ? Math.min(1, level) : level
|
||||
}
|
||||
|
||||
export function baseDiagramCellColorKey(id: string): string {
|
||||
const index = id.lastIndexOf(DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR)
|
||||
return index === -1 ? id : id.slice(0, index)
|
||||
}
|
||||
|
||||
export function mappedDiagramColor(
|
||||
colors: ReadonlyMap<string, RGBA> | undefined,
|
||||
id: string | undefined,
|
||||
): RGBA | undefined {
|
||||
return id ? (colors?.get(id) ?? colors?.get(baseDiagramCellColorKey(id))) : undefined
|
||||
}
|
||||
|
||||
export function normalizeDiagramColorMap(value: DiagramColorMapInput | undefined): Map<string, RGBA> {
|
||||
const colors = new Map<string, RGBA>()
|
||||
if (!value) return colors
|
||||
|
||||
const entries = value instanceof Map ? value.entries() : Object.entries(value)
|
||||
for (const [id, color] of entries) {
|
||||
if (color !== undefined) colors.set(id, parseColor(color))
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
export function diagramColorMapsEqual(left: ReadonlyMap<string, RGBA>, right: ReadonlyMap<string, RGBA>): boolean {
|
||||
if (left.size !== right.size) return false
|
||||
for (const [id, color] of left) {
|
||||
if (!colorsEqual(color, right.get(id))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
baseDiagramCellColorKey,
|
||||
diagramCellColorKey,
|
||||
diagramColorMapsEqual,
|
||||
diagramRadialCellColorLevel,
|
||||
mappedDiagramColor,
|
||||
normalizeDiagramColorMap,
|
||||
} from "./map.js"
|
||||
import {
|
||||
ansiBg,
|
||||
ansiFg,
|
||||
blendColor,
|
||||
brightenColor,
|
||||
createAnsiRampTheme,
|
||||
mixRgb,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
} from "./style.js"
|
||||
|
||||
describe("diagram style helpers", () => {
|
||||
test("mixes rgb values and emits truecolor ANSI", () => {
|
||||
expect(mixRgb([0, 10, 20], [10, 30, 60], 0.5)).toEqual([5, 20, 40])
|
||||
expect(ansiFg([1, 2, 3])).toBe("\x1b[38;2;1;2;3m")
|
||||
expect(ansiBg([4, 5, 6])).toBe("\x1b[48;2;4;5;6m")
|
||||
})
|
||||
|
||||
test("converts rgb tuples and blends optional RGBA values", () => {
|
||||
const black = RGBA.fromInts(0, 0, 0, 255)
|
||||
const white = RGBA.fromInts(10, 20, 30, 255)
|
||||
|
||||
expect(rgba([1, 2, 3]).equals(RGBA.fromInts(1, 2, 3, 255))).toBe(true)
|
||||
expect(blendColor(black, white, 0.5).equals(RGBA.fromInts(5, 10, 15, 255))).toBe(true)
|
||||
expect(blendColor(undefined, white, 0.5)?.equals(white)).toBe(true)
|
||||
expect(blendColor(undefined, undefined, 0.5)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("brightens colors toward white", () => {
|
||||
const color = brightenColor(RGBA.fromInts(100, 150, 200, 255), 0.5)
|
||||
|
||||
expect(color?.equals(RGBA.fromInts(178, 203, 228, 255))).toBe(true)
|
||||
expect(brightenColor(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("creates numbered style keys and ANSI ramps", () => {
|
||||
const styles = numberedStyleKeys("requestFade", [1, 2, 3] as const)
|
||||
const theme = createAnsiRampTheme(styles, [0, 0, 0], [12, 24, 36])
|
||||
|
||||
expect(styles).toEqual(["requestFade1", "requestFade2", "requestFade3"])
|
||||
expect(theme.requestFade1).toBe("\x1b[38;2;3;6;9m")
|
||||
expect(theme.requestFade2).toBe("\x1b[38;2;6;12;18m")
|
||||
expect(theme.requestFade3).toBe("\x1b[38;2;9;18;27m")
|
||||
})
|
||||
|
||||
test("normalizes and resolves leveled diagram color maps", () => {
|
||||
const red = RGBA.fromInts(255, 0, 0, 255)
|
||||
const blue = RGBA.fromInts(0, 0, 255, 255)
|
||||
const colors = normalizeDiagramColorMap({
|
||||
A: red,
|
||||
B: undefined,
|
||||
[diagramCellColorKey("C", 2)]: blue,
|
||||
})
|
||||
|
||||
expect(colors.size).toBe(2)
|
||||
expect(diagramCellColorKey("A", -1)).toBe("A::cell:0")
|
||||
expect(diagramCellColorKey("A", 10)).toBe("A::cell:5")
|
||||
expect(baseDiagramCellColorKey("A::cell:3")).toBe("A")
|
||||
expect(mappedDiagramColor(colors, "A")?.equals(red)).toBe(true)
|
||||
expect(mappedDiagramColor(colors, "C::cell:2")?.equals(blue)).toBe(true)
|
||||
expect(mappedDiagramColor(colors, "C::cell:4")).toBeUndefined()
|
||||
expect(diagramColorMapsEqual(colors, normalizeDiagramColorMap(new Map(colors)))).toBe(true)
|
||||
})
|
||||
|
||||
test("computes radial diagram cell color levels", () => {
|
||||
const bounds = { width: 9, height: 5, centerX: 4, centerY: 2 }
|
||||
|
||||
expect(diagramRadialCellColorLevel(bounds, 4, 2)).toBe(5)
|
||||
expect(diagramRadialCellColorLevel(bounds, 0, 0)).toBe(0)
|
||||
expect(diagramRadialCellColorLevel(bounds, 4, 2, true)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export type DiagramRgb = readonly [number, number, number]
|
||||
export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
|
||||
|
||||
export function numberedStyleKeys<Prefix extends string, Step extends number>(
|
||||
prefix: Prefix,
|
||||
steps: readonly Step[],
|
||||
): Array<`${Prefix}${Step}`> {
|
||||
return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
|
||||
}
|
||||
|
||||
export function mixChannel(left: number, right: number, amount: number): number {
|
||||
return Math.round(left + (right - left) * amount)
|
||||
}
|
||||
|
||||
export function mixRgb(left: DiagramRgb, right: DiagramRgb, amount: number): DiagramRgb {
|
||||
return [
|
||||
mixChannel(left[0], right[0], amount),
|
||||
mixChannel(left[1], right[1], amount),
|
||||
mixChannel(left[2], right[2], amount),
|
||||
]
|
||||
}
|
||||
|
||||
export function ansiFg(rgb: DiagramRgb): string {
|
||||
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
|
||||
}
|
||||
|
||||
export function ansiBg(rgb: DiagramRgb): string {
|
||||
return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
|
||||
}
|
||||
|
||||
export function rgba(rgb: DiagramRgb): RGBA {
|
||||
return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
|
||||
}
|
||||
|
||||
export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
|
||||
if (!from && !to) return undefined
|
||||
if (!from) return to
|
||||
if (!to) return from
|
||||
|
||||
const [fromR, fromG, fromB, fromA] = from.toInts()
|
||||
const [toR, toG, toB, toA] = to.toInts()
|
||||
const mix = (left: number, right: number) => left + (right - left) * amount
|
||||
|
||||
return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
|
||||
}
|
||||
|
||||
export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
|
||||
if (!left || !right) return left === right
|
||||
return left.equals(right)
|
||||
}
|
||||
|
||||
export function brightenColor(color: RGBA | undefined, amount: number = 0.35): RGBA | undefined {
|
||||
if (!color) return undefined
|
||||
|
||||
const [r, g, b, a] = color.toInts()
|
||||
return RGBA.fromInts(mixChannel(r, 255, amount), mixChannel(g, 255, amount), mixChannel(b, 255, amount), a)
|
||||
}
|
||||
|
||||
export function createAnsiRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: DiagramRgb,
|
||||
to: DiagramRgb,
|
||||
): Record<Style, string> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, ansiFg(mixRgb(from, to, (index + 1) / (styles.length + 1)))]),
|
||||
) as Record<Style, string>
|
||||
}
|
||||
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA,
|
||||
to: RGBA,
|
||||
): Record<Style, RGBA>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
|
||||
) as Record<Style, RGBA | undefined>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import { DiagramCanvas } from "./canvas.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "./drawing.js"
|
||||
|
||||
describe("diagram drawing", () => {
|
||||
test("merges line glyphs with square and rounded corners", () => {
|
||||
expect(mergeDiagramLineGlyph("─", "│")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "│", "rounded")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "╭", "rounded")).toBe("┬")
|
||||
expect(mergeDiagramLineGlyph("a", "│")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("draws frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(8, 4)
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 4, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("draws diamond frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(9, 5)
|
||||
drawDiagramDiamond({ left: 0, top: 0, width: 9, height: 5, centerX: 4, centerY: 2 }, (x, y, char) =>
|
||||
canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭───╮\n╭─╯ ╰─╮\n│ │\n╰─╮ ╭─╯\n ╰───╯")
|
||||
expect(canvas.toString()).not.toMatch(/[╱╲\\/]/)
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with selected corner style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ cornerStyle: "rounded" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("───╮\n │\n ╰──")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with heavy line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "heavy" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("━━━┓\n ┃\n ┗━━")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with dashed line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(9, 1)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 8, y: 0 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "dashed" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("─ ─ ─ ─")
|
||||
})
|
||||
|
||||
test("keeps container frame policy separate from edge drawing policy", () => {
|
||||
const canvas = new DiagramCanvas<"group" | "edge">(8, 5, {
|
||||
mergeCell: (existing, incoming) => {
|
||||
if (incoming.style === "edge") return incoming
|
||||
return existing.char === " " ? incoming : existing
|
||||
},
|
||||
})
|
||||
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 5, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "group"),
|
||||
)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
)
|
||||
canvas.setCell(7, 2, "▶", "edge")
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n───────▶\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("selects filled and line arrow heads", () => {
|
||||
expect(diagramArrowHead("right")).toBe("▶")
|
||||
expect(diagramArrowHead("left", "line")).toBe("←")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { BorderChars, type BorderCharacters } from "@opentui/core"
|
||||
import {
|
||||
directionBetween,
|
||||
walkOrthogonalSegment,
|
||||
type DiagramBounds,
|
||||
type DiagramDirection,
|
||||
type DiagramPoint,
|
||||
} from "./geometry.js"
|
||||
|
||||
export type DiagramLineCornerStyle = "square" | "rounded"
|
||||
export type DiagramLineStyle = "single" | "heavy" | "dashed"
|
||||
export type DiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface DiagramDiamondCharacters {
|
||||
topLeft: string
|
||||
topRight: string
|
||||
upperLeft: string
|
||||
upperLeftJoin: string
|
||||
upperRightJoin: string
|
||||
upperRight: string
|
||||
vertical: string
|
||||
lowerLeft: string
|
||||
lowerLeftJoin: string
|
||||
lowerRightJoin: string
|
||||
lowerRight: string
|
||||
bottomLeft: string
|
||||
bottomRight: string
|
||||
horizontal: string
|
||||
}
|
||||
|
||||
export const DIAGRAM_ARROW_HEADS = new Set(["▶", "◀", "▼", "▲", "→", "←", "↓", "↑"])
|
||||
const HEAVY_LINE_GLYPHS = new Set(Object.values(BorderChars.heavy))
|
||||
|
||||
export const DIAGRAM_DIAMOND_CHARS = {
|
||||
topLeft: "╭",
|
||||
topRight: "╮",
|
||||
upperLeft: "╭",
|
||||
upperLeftJoin: "╯",
|
||||
upperRightJoin: "╰",
|
||||
upperRight: "╮",
|
||||
vertical: "│",
|
||||
lowerLeft: "╰",
|
||||
lowerLeftJoin: "╮",
|
||||
lowerRightJoin: "╭",
|
||||
lowerRight: "╯",
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
} as const satisfies DiagramDiamondCharacters
|
||||
|
||||
export function diagramDiamondCharactersFromBorder(chars: BorderCharacters): DiagramDiamondCharacters {
|
||||
return {
|
||||
topLeft: chars.topLeft,
|
||||
topRight: chars.topRight,
|
||||
upperLeft: chars.topLeft,
|
||||
upperLeftJoin: chars.bottomRight,
|
||||
upperRightJoin: chars.bottomLeft,
|
||||
upperRight: chars.topRight,
|
||||
vertical: chars.vertical,
|
||||
lowerLeft: chars.bottomLeft,
|
||||
lowerLeftJoin: chars.topRight,
|
||||
lowerRightJoin: chars.topLeft,
|
||||
lowerRight: chars.bottomRight,
|
||||
bottomLeft: chars.bottomLeft,
|
||||
bottomRight: chars.bottomRight,
|
||||
horizontal: chars.horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
function lineDirections(char: string): readonly DiagramDirection[] | undefined {
|
||||
switch (char) {
|
||||
case "─":
|
||||
return ["left", "right"]
|
||||
case "│":
|
||||
return ["up", "down"]
|
||||
case "━":
|
||||
return ["left", "right"]
|
||||
case "┃":
|
||||
return ["up", "down"]
|
||||
case "┌":
|
||||
case "╭":
|
||||
case "┏":
|
||||
return ["right", "down"]
|
||||
case "┐":
|
||||
case "╮":
|
||||
case "┓":
|
||||
return ["left", "down"]
|
||||
case "└":
|
||||
case "╰":
|
||||
case "┗":
|
||||
return ["up", "right"]
|
||||
case "┘":
|
||||
case "╯":
|
||||
case "┛":
|
||||
return ["up", "left"]
|
||||
case "├":
|
||||
case "┣":
|
||||
return ["up", "down", "right"]
|
||||
case "┤":
|
||||
case "┫":
|
||||
return ["up", "down", "left"]
|
||||
case "┬":
|
||||
case "┳":
|
||||
return ["left", "right", "down"]
|
||||
case "┴":
|
||||
case "┻":
|
||||
return ["left", "right", "up"]
|
||||
case "┼":
|
||||
case "╋":
|
||||
return ["up", "down", "left", "right"]
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function diagramLineGlyph(
|
||||
directions: ReadonlySet<DiagramDirection>,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
lineStyle: DiagramLineStyle = "single",
|
||||
): string {
|
||||
const up = directions.has("up")
|
||||
const down = directions.has("down")
|
||||
const left = directions.has("left")
|
||||
const right = directions.has("right")
|
||||
if (lineStyle === "heavy") {
|
||||
const chars = BorderChars.heavy
|
||||
if (up && down && left && right) return chars.cross
|
||||
if (up && down && right) return chars.leftT
|
||||
if (up && down && left) return chars.rightT
|
||||
if (left && right && down) return chars.topT
|
||||
if (left && right && up) return chars.bottomT
|
||||
if (up && right) return chars.bottomLeft
|
||||
if (up && left) return chars.bottomRight
|
||||
if (down && right) return chars.topLeft
|
||||
if (down && left) return chars.topRight
|
||||
if (up || down) return chars.vertical
|
||||
return chars.horizontal
|
||||
}
|
||||
if (up && down && left && right) return "┼"
|
||||
if (up && down && right) return "├"
|
||||
if (up && down && left) return "┤"
|
||||
if (left && right && down) return "┬"
|
||||
if (left && right && up) return "┴"
|
||||
if (up && right) return cornerStyle === "rounded" ? "╰" : "└"
|
||||
if (up && left) return cornerStyle === "rounded" ? "╯" : "┘"
|
||||
if (down && right) return cornerStyle === "rounded" ? "╭" : "┌"
|
||||
if (down && left) return cornerStyle === "rounded" ? "╮" : "┐"
|
||||
if (up || down) return "│"
|
||||
return "─"
|
||||
}
|
||||
|
||||
function isHeavyLineGlyph(char: string): boolean {
|
||||
return HEAVY_LINE_GLYPHS.has(char)
|
||||
}
|
||||
|
||||
function segmentGlyph(direction: DiagramDirection, lineStyle: DiagramLineStyle | undefined): string {
|
||||
const directions = new Set<DiagramDirection>(
|
||||
direction === "left" || direction === "right" ? ["left", "right"] : ["up", "down"],
|
||||
)
|
||||
return diagramLineGlyph(directions, "square", lineStyle === "heavy" ? "heavy" : "single")
|
||||
}
|
||||
|
||||
export function mergeDiagramLineGlyph(
|
||||
existing: string,
|
||||
incoming: string,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
): string | undefined {
|
||||
const existingDirections = lineDirections(existing)
|
||||
const incomingDirections = lineDirections(incoming)
|
||||
if (!existingDirections || !incomingDirections) return undefined
|
||||
|
||||
return diagramLineGlyph(
|
||||
new Set([...existingDirections, ...incomingDirections]),
|
||||
cornerStyle,
|
||||
isHeavyLineGlyph(existing) && isHeavyLineGlyph(incoming) ? "heavy" : "single",
|
||||
)
|
||||
}
|
||||
|
||||
export function diagramArrowHead(direction: DiagramDirection, style: DiagramArrowHeadStyle = "filled"): string {
|
||||
if (style === "line") {
|
||||
if (direction === "right") return "→"
|
||||
if (direction === "left") return "←"
|
||||
if (direction === "up") return "↑"
|
||||
return "↓"
|
||||
}
|
||||
|
||||
if (direction === "right") return "▶"
|
||||
if (direction === "left") return "◀"
|
||||
if (direction === "up") return "▲"
|
||||
return "▼"
|
||||
}
|
||||
|
||||
export function diagramArrowHeadBetween(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
style: DiagramArrowHeadStyle = "filled",
|
||||
): string {
|
||||
const direction = directionBetween(from, to)
|
||||
return direction ? diagramArrowHead(direction, style) : diagramArrowHead("right", style)
|
||||
}
|
||||
|
||||
export function drawDiagramFrame(
|
||||
bounds: DiagramBounds,
|
||||
chars: BorderCharacters,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
): void {
|
||||
setCell(bounds.left, bounds.top, chars.topLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top, chars.topRight)
|
||||
setCell(bounds.left, bounds.top + bounds.height - 1, chars.bottomLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top + bounds.height - 1, chars.bottomRight)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
setCell(x, bounds.top, chars.horizontal)
|
||||
setCell(x, bounds.top + bounds.height - 1, chars.horizontal)
|
||||
}
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
setCell(bounds.left, y, chars.vertical)
|
||||
setCell(bounds.left + bounds.width - 1, y, chars.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawDiagramDiamond(
|
||||
bounds: DiagramBounds,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
chars: DiagramDiamondCharacters = DIAGRAM_DIAMOND_CHARS,
|
||||
): void {
|
||||
const left = bounds.left
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const top = bounds.top
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
const capInset = Math.min(2, Math.max(1, Math.floor((bounds.width - 1) / 2)))
|
||||
const capLeft = left + capInset
|
||||
const capRight = right - capInset
|
||||
|
||||
setCell(capLeft, top, chars.topLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, top, chars.horizontal)
|
||||
setCell(capRight, top, chars.topRight)
|
||||
|
||||
setCell(left, top + 1, chars.upperLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(capLeft, top + 1, chars.upperLeftJoin)
|
||||
setCell(capRight, top + 1, chars.upperRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(right, top + 1, chars.upperRight)
|
||||
|
||||
for (let y = top + 2; y < bottom - 1; y++) {
|
||||
setCell(left, y, chars.vertical)
|
||||
setCell(right, y, chars.vertical)
|
||||
}
|
||||
|
||||
setCell(left, bottom - 1, chars.lowerLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(capLeft, bottom - 1, chars.lowerLeftJoin)
|
||||
setCell(capRight, bottom - 1, chars.lowerRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(right, bottom - 1, chars.lowerRight)
|
||||
|
||||
setCell(capLeft, bottom, chars.bottomLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, bottom, chars.horizontal)
|
||||
setCell(capRight, bottom, chars.bottomRight)
|
||||
}
|
||||
|
||||
export function drawOrthogonalPath(
|
||||
points: readonly DiagramPoint[],
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
options: { cornerStyle?: DiagramLineCornerStyle; lineStyle?: DiagramLineStyle } = {},
|
||||
): void {
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
const glyph = segmentGlyph(direction, options.lineStyle)
|
||||
let step = index === 1 ? 0 : 1
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (options.lineStyle !== "dashed" || step % 2 === 0) setCell(point.x, point.y, glyph)
|
||||
step += 1
|
||||
})
|
||||
}
|
||||
|
||||
for (let index = 1; index < points.length - 1; index++) {
|
||||
const previous = points[index - 1]!
|
||||
const current = points[index]!
|
||||
const next = points[index + 1]!
|
||||
const fromDirection = directionBetween(current, previous)
|
||||
const toDirection = directionBetween(current, next)
|
||||
const directions = new Set<DiagramDirection>()
|
||||
if (fromDirection) directions.add(fromDirection)
|
||||
if (toDirection) directions.add(toDirection)
|
||||
setCell(
|
||||
current.x,
|
||||
current.y,
|
||||
diagramLineGlyph(directions, options.cornerStyle, options.lineStyle === "heavy" ? "heavy" : "single"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
diagramBoundsFromRect,
|
||||
boundsSidePoint,
|
||||
directionBetween,
|
||||
lane,
|
||||
orthogonalPath,
|
||||
orthogonalPathPoints,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
point,
|
||||
translateDiagramBounds,
|
||||
walkOrthogonalSegment,
|
||||
} from "./geometry.js"
|
||||
|
||||
describe("diagram geometry", () => {
|
||||
test("directions are only defined for orthogonal point pairs", () => {
|
||||
expect(directionBetween(point(1, 2), point(5, 2))).toBe("right")
|
||||
expect(directionBetween(point(1, 2), point(1, 0))).toBe("up")
|
||||
expect(directionBetween(point(1, 2), point(5, 4))).toBeUndefined()
|
||||
expect(directionBetween(point(1, 2), point(1, 2))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds side points describe border and outside ports", () => {
|
||||
const bounds = { left: 10, top: 4, width: 8, height: 5, centerX: 14, centerY: 6 }
|
||||
|
||||
expect(boundsSidePoint(bounds, "left", "border")).toEqual(point(10, 6))
|
||||
expect(boundsSidePoint(bounds, "left")).toEqual(point(9, 6))
|
||||
expect(boundsSidePoint(bounds, "right", "border")).toEqual(point(17, 6))
|
||||
expect(boundsSidePoint(bounds, "right")).toEqual(point(18, 6))
|
||||
expect(boundsSidePoint(bounds, "top", "border")).toEqual(point(14, 4))
|
||||
expect(boundsSidePoint(bounds, "bottom")).toEqual(point(14, 9))
|
||||
})
|
||||
|
||||
test("bounds helpers create, translate, and union bounds", () => {
|
||||
const bounds = diagramBoundsFromRect(2, 3, 5, 4)
|
||||
|
||||
expect(bounds).toEqual({ left: 2, top: 3, width: 5, height: 4, centerX: 4, centerY: 5 })
|
||||
translateDiagramBounds(bounds, 3, -1)
|
||||
expect(bounds).toEqual({ left: 5, top: 2, width: 5, height: 4, centerX: 7, centerY: 4 })
|
||||
expect(diagramBoundsFromBounds([bounds, diagramBoundsFromRect(0, 0, 2, 2)])).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 6,
|
||||
centerX: 5,
|
||||
centerY: 3,
|
||||
})
|
||||
expect(diagramBoundsFromPoints([point(2, 2), point(4, 5)])).toEqual({
|
||||
left: 2,
|
||||
top: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
centerX: 3,
|
||||
centerY: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test("paths compose through lanes while removing duplicate joints", () => {
|
||||
expect(pathThrough([point(0, 0), point(0, 0), point(3, 0)])).toEqual([point(0, 0), point(3, 0)])
|
||||
expect(pathViaLane(point(0, 0), lane("x", 4), point(8, 3))).toEqual([
|
||||
point(0, 0),
|
||||
point(4, 0),
|
||||
point(4, 3),
|
||||
point(8, 3),
|
||||
])
|
||||
})
|
||||
|
||||
test("composed paths do not alias caller-owned points", () => {
|
||||
const start = point(0, 0)
|
||||
const end = point(8, 3)
|
||||
const firstPath = pathViaLane(start, lane("x", 4), end)
|
||||
const secondPath = pathViaLane(start, lane("x", 5), end)
|
||||
|
||||
expect(firstPath[0]).not.toBe(start)
|
||||
expect(firstPath[firstPath.length - 1]).not.toBe(end)
|
||||
expect(firstPath[0]).not.toBe(secondPath[0])
|
||||
|
||||
firstPath[0]!.x += 10
|
||||
firstPath[firstPath.length - 1]!.y += 10
|
||||
|
||||
expect(start).toEqual(point(0, 0))
|
||||
expect(end).toEqual(point(8, 3))
|
||||
expect(secondPath[0]).toEqual(point(0, 0))
|
||||
expect(secondPath[secondPath.length - 1]).toEqual(point(8, 3))
|
||||
})
|
||||
|
||||
test("orthogonal paths choose a terminal lane on the dominant axis", () => {
|
||||
expect(orthogonalPath(point(0, 0), point(10, 4))).toEqual([point(0, 0), point(6, 0), point(6, 4), point(10, 4)])
|
||||
})
|
||||
|
||||
test("orthogonal segment walkers exclude endpoints", () => {
|
||||
const visited: Array<{ x: number; y: number }> = []
|
||||
walkOrthogonalSegment(point(0, 0), point(3, 0), false, (next) => {
|
||||
visited.push(next)
|
||||
})
|
||||
expect(visited).toEqual([point(1, 0), point(2, 0)])
|
||||
})
|
||||
|
||||
test("orthogonal path points include endpoints without duplicating joints", () => {
|
||||
expect(orthogonalPathPoints([point(0, 0), point(3, 0), point(3, 2)])).toEqual([
|
||||
point(0, 0),
|
||||
point(1, 0),
|
||||
point(2, 0),
|
||||
point(3, 0),
|
||||
point(3, 1),
|
||||
point(3, 2),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
export type DiagramAxis = "x" | "y"
|
||||
export type DiagramDirection = "up" | "down" | "left" | "right"
|
||||
export type DiagramSide = "left" | "right" | "top" | "bottom"
|
||||
|
||||
export interface DiagramPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DiagramBounds {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface DiagramSegment {
|
||||
from: DiagramPoint
|
||||
to: DiagramPoint
|
||||
axis: DiagramAxis
|
||||
direction: DiagramDirection
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface DiagramLane {
|
||||
axis: DiagramAxis
|
||||
coordinate: number
|
||||
}
|
||||
|
||||
export interface DiagramSpan {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const DIRECTION_AXIS = {
|
||||
left: "x",
|
||||
right: "x",
|
||||
up: "y",
|
||||
down: "y",
|
||||
} as const satisfies Record<DiagramDirection, DiagramAxis>
|
||||
|
||||
const DIRECTION_SIGN = {
|
||||
left: -1,
|
||||
right: 1,
|
||||
up: -1,
|
||||
down: 1,
|
||||
} as const satisfies Record<DiagramDirection, -1 | 1>
|
||||
|
||||
const DIRECTION_SIDE = {
|
||||
left: "left",
|
||||
right: "right",
|
||||
up: "top",
|
||||
down: "bottom",
|
||||
} as const satisfies Record<DiagramDirection, DiagramSide>
|
||||
|
||||
const OPPOSITE_SIDE = {
|
||||
left: "right",
|
||||
right: "left",
|
||||
top: "bottom",
|
||||
bottom: "top",
|
||||
} as const satisfies Record<DiagramSide, DiagramSide>
|
||||
|
||||
export function point(x: number, y: number): DiagramPoint {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function diagramBoundsFromRect(left: number, top: number, width: number, height: number): DiagramBounds {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDiagramBounds(bounds: DiagramBounds, dx: number, dy: number): void {
|
||||
bounds.left += dx
|
||||
bounds.top += dy
|
||||
bounds.centerX += dx
|
||||
bounds.centerY += dy
|
||||
}
|
||||
|
||||
export function diagramBoundsFromBounds(bounds: readonly DiagramBounds[]): DiagramBounds | undefined {
|
||||
if (bounds.length === 0) return undefined
|
||||
const left = Math.min(...bounds.map((bound) => bound.left))
|
||||
const top = Math.min(...bounds.map((bound) => bound.top))
|
||||
const right = Math.max(...bounds.map((bound) => bound.left + bound.width))
|
||||
const bottom = Math.max(...bounds.map((bound) => bound.top + bound.height))
|
||||
return diagramBoundsFromRect(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
export function diagramBoundsFromPoints(points: readonly DiagramPoint[]): DiagramBounds | undefined {
|
||||
if (points.length === 0) return undefined
|
||||
const left = Math.min(...points.map((point) => point.x))
|
||||
const top = Math.min(...points.map((point) => point.y))
|
||||
const right = Math.max(...points.map((point) => point.x))
|
||||
const bottom = Math.max(...points.map((point) => point.y))
|
||||
return diagramBoundsFromRect(left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
export function coordinate(point: DiagramPoint, axis: DiagramAxis): number {
|
||||
return point[axis]
|
||||
}
|
||||
|
||||
export function withCoordinate(point: DiagramPoint, axis: DiagramAxis, value: number): DiagramPoint {
|
||||
return axis === "x" ? { x: value, y: point.y } : { x: point.x, y: value }
|
||||
}
|
||||
|
||||
export function shiftPoint(point: DiagramPoint, direction: DiagramDirection, distance = 1): DiagramPoint {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
return { x: point.x - distance, y: point.y }
|
||||
case "right":
|
||||
return { x: point.x + distance, y: point.y }
|
||||
case "up":
|
||||
return { x: point.x, y: point.y - distance }
|
||||
case "down":
|
||||
return { x: point.x, y: point.y + distance }
|
||||
}
|
||||
}
|
||||
|
||||
export function clampPoint(point: DiagramPoint, min: DiagramPoint = { x: 0, y: 0 }): DiagramPoint {
|
||||
return { x: Math.max(min.x, point.x), y: Math.max(min.y, point.y) }
|
||||
}
|
||||
|
||||
export function samePoint(left: DiagramPoint, right: DiagramPoint): boolean {
|
||||
return left.x === right.x && left.y === right.y
|
||||
}
|
||||
|
||||
export function directionAxis(direction: DiagramDirection): DiagramAxis {
|
||||
return DIRECTION_AXIS[direction]
|
||||
}
|
||||
|
||||
export function directionSign(direction: DiagramDirection): -1 | 1 {
|
||||
return DIRECTION_SIGN[direction]
|
||||
}
|
||||
|
||||
export function sideForDirection(direction: DiagramDirection): DiagramSide {
|
||||
return DIRECTION_SIDE[direction]
|
||||
}
|
||||
|
||||
export function oppositeSide(side: DiagramSide): DiagramSide {
|
||||
return OPPOSITE_SIDE[side]
|
||||
}
|
||||
|
||||
export function directionBetween(from: DiagramPoint, to: DiagramPoint): DiagramDirection | undefined {
|
||||
if (from.y === to.y) {
|
||||
if (to.x > from.x) return "right"
|
||||
if (to.x < from.x) return "left"
|
||||
}
|
||||
if (from.x === to.x) {
|
||||
if (to.y > from.y) return "down"
|
||||
if (to.y < from.y) return "up"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function boundsCenter(bounds: DiagramBounds): DiagramPoint {
|
||||
return point(bounds.centerX, bounds.centerY)
|
||||
}
|
||||
|
||||
export function boundsSidePoint(
|
||||
bounds: DiagramBounds,
|
||||
side: DiagramSide,
|
||||
surface: "border" | "outside" = "outside",
|
||||
): DiagramPoint {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return point(bounds.left - (surface === "outside" ? 1 : 0), bounds.centerY)
|
||||
case "right":
|
||||
return point(bounds.left + bounds.width - (surface === "outside" ? 0 : 1), bounds.centerY)
|
||||
case "top":
|
||||
return point(bounds.centerX, bounds.top - (surface === "outside" ? 1 : 0))
|
||||
case "bottom":
|
||||
return point(bounds.centerX, bounds.top + bounds.height - (surface === "outside" ? 0 : 1))
|
||||
}
|
||||
}
|
||||
|
||||
export function centerCoordinate(bounds: DiagramBounds, axis: DiagramAxis): number {
|
||||
return axis === "x" ? bounds.centerX : bounds.centerY
|
||||
}
|
||||
|
||||
export function snapCoordinate(source: number, target: number, tolerance: number): number {
|
||||
return Math.abs(source - target) <= tolerance ? target : source
|
||||
}
|
||||
|
||||
export function pathThrough(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (const next of points) {
|
||||
if (!path.length || !samePoint(path[path.length - 1]!, next)) path.push(point(next.x, next.y))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function lane(axis: DiagramAxis, coordinate: number): DiagramLane {
|
||||
return { axis, coordinate }
|
||||
}
|
||||
|
||||
export function pathViaLane(start: DiagramPoint, routeLane: DiagramLane, end: DiagramPoint): DiagramPoint[] {
|
||||
return pathThrough([
|
||||
start,
|
||||
withCoordinate(start, routeLane.axis, routeLane.coordinate),
|
||||
withCoordinate(end, routeLane.axis, routeLane.coordinate),
|
||||
end,
|
||||
])
|
||||
}
|
||||
|
||||
function dominantAxis(start: DiagramPoint, end: DiagramPoint): DiagramAxis {
|
||||
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y"
|
||||
}
|
||||
|
||||
function terminalLane(start: DiagramPoint, end: DiagramPoint, axis: DiagramAxis, terminalClearance: number): number {
|
||||
const startCoordinate = coordinate(start, axis)
|
||||
const endCoordinate = coordinate(end, axis)
|
||||
const delta = endCoordinate - startCoordinate
|
||||
const sign = Math.sign(delta)
|
||||
if (sign === 0) return endCoordinate
|
||||
return endCoordinate - sign * Math.min(terminalClearance, Math.max(1, Math.abs(delta) - 1))
|
||||
}
|
||||
|
||||
export function orthogonalPath(
|
||||
start: DiagramPoint,
|
||||
end: DiagramPoint,
|
||||
options: { preferredAxis?: DiagramAxis; terminalClearance?: number } = {},
|
||||
): DiagramPoint[] {
|
||||
if (start.x === end.x || start.y === end.y) return pathThrough([start, end])
|
||||
|
||||
const laneAxis = options.preferredAxis ?? dominantAxis(start, end)
|
||||
return pathViaLane(start, lane(laneAxis, terminalLane(start, end, laneAxis, options.terminalClearance ?? 4)), end)
|
||||
}
|
||||
|
||||
export function segmentBetween(from: DiagramPoint, to: DiagramPoint): DiagramSegment | undefined {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return undefined
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
axis: directionAxis(direction),
|
||||
direction,
|
||||
length: Math.abs(coordinate(to, directionAxis(direction)) - coordinate(from, directionAxis(direction))),
|
||||
}
|
||||
}
|
||||
|
||||
export function walkOrthogonalSegment(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
includeStart: boolean,
|
||||
visit: (point: DiagramPoint) => boolean | void,
|
||||
): void {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = includeStart ? from : point(from.x + dx, from.y + dy)
|
||||
|
||||
while (!samePoint(cursor, to)) {
|
||||
if (visit(cursor) === false) return
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
|
||||
export function orthogonalPathPoints(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = path.length === 0 ? point(from.x, from.y) : point(from.x + dx, from.y + dy)
|
||||
while (true) {
|
||||
path.push(cursor)
|
||||
if (samePoint(cursor, to)) break
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function orderedSpan(left: number, right: number): DiagramSpan {
|
||||
return left <= right ? { start: left, end: right } : { start: right, end: left }
|
||||
}
|
||||
|
||||
export function segmentSpan(segment: DiagramSegment): DiagramSpan {
|
||||
return orderedSpan(coordinate(segment.from, segment.axis), coordinate(segment.to, segment.axis))
|
||||
}
|
||||
|
||||
export function pointOnSegment(segment: DiagramSegment, coordinateValue: number): DiagramPoint {
|
||||
return withCoordinate(segment.from, segment.axis, coordinateValue)
|
||||
}
|
||||
|
||||
export function insetSpan(span: DiagramSpan, amount: number): DiagramSpan {
|
||||
return { start: span.start + amount, end: span.end - amount }
|
||||
}
|
||||
|
||||
export function spanCapacity(span: DiagramSpan): number {
|
||||
return Math.max(0, span.end - span.start + 1)
|
||||
}
|
||||
|
||||
export function centeredSpanStart(span: DiagramSpan, width: number): number {
|
||||
return span.start + Math.floor((spanCapacity(span) - width) / 2)
|
||||
}
|
||||
|
||||
export function midpoint(span: DiagramSpan): number {
|
||||
return Math.round((span.start + span.end) / 2)
|
||||
}
|
||||
|
||||
export function advanceCoordinate(origin: number, direction: DiagramDirection, distance: number): number {
|
||||
return origin + directionSign(direction) * distance
|
||||
}
|
||||
|
||||
export function beforeNearestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const nearest = directionSign(direction) > 0 ? Math.min(...coordinates) : Math.max(...coordinates)
|
||||
return advanceCoordinate(nearest, direction, -clearance)
|
||||
}
|
||||
|
||||
export function afterFarthestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const farthest = directionSign(direction) > 0 ? Math.max(...coordinates) : Math.min(...coordinates)
|
||||
return advanceCoordinate(farthest, direction, clearance)
|
||||
}
|
||||
|
||||
export function keepBefore(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.min(preferred, boundary) : Math.max(preferred, boundary)
|
||||
}
|
||||
|
||||
export function keepAfter(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.max(preferred, boundary) : Math.min(preferred, boundary)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface NumberedMermaidLine {
|
||||
readonly lineNumber: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export function* numberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
const lines = content.split(/\r?\n/)
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
yield { lineNumber: index + 1, text: lines[index]!.trim() }
|
||||
}
|
||||
}
|
||||
|
||||
export function* meaningfulNumberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
for (const line of numberedMermaidLines(content)) {
|
||||
if (line.text && !line.text.startsWith("%%")) yield line
|
||||
}
|
||||
}
|
||||
|
||||
export function firstMeaningfulMermaidLine(content: string): string | undefined {
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (line && !line.startsWith("%%")) return line
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function stripMermaidQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { StyledText, type TextChunk } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
|
||||
import { ANSI } from "./terminal/ansi.js"
|
||||
|
||||
export interface RenderDiagramGridAnsiOptions<
|
||||
Style extends string,
|
||||
Metadata extends object = object,
|
||||
> extends DiagramCanvasRunOptions<Style, Metadata> {
|
||||
trimOutputEnd?: boolean
|
||||
}
|
||||
|
||||
export function renderDiagramGridAnsi<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
styleAnsi: (run: DiagramCanvasRun<Style, Metadata>) => string | undefined,
|
||||
options: RenderDiagramGridAnsiOptions<Style, Metadata> = {},
|
||||
): string {
|
||||
const { trimOutputEnd, ...runOptions } = options
|
||||
const output: string[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
const ansi = styleAnsi(run)
|
||||
output.push(ansi ? `${ansi}${run.text}${ANSI.reset}` : run.text)
|
||||
},
|
||||
() => {
|
||||
output.push("\n")
|
||||
},
|
||||
runOptions,
|
||||
)
|
||||
|
||||
const text = output.join("")
|
||||
return trimOutputEnd ? text.trimEnd() : text
|
||||
}
|
||||
|
||||
export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
|
||||
bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
|
||||
options?: DiagramCanvasRunOptions<Style, Metadata>,
|
||||
): StyledText {
|
||||
const chunks: TextChunk[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
chunks.push({ __isChunk: true, text: run.text, fg: fg(run), bg: bg?.(run) })
|
||||
},
|
||||
() => {
|
||||
chunks.push({ __isChunk: true, text: "\n" })
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function splitDiagramLines(value: string): string[] {
|
||||
return value.split(/<br\s*\/?>/i).map((line) => line.trim())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "./text.js"
|
||||
|
||||
describe("diagram text helpers", () => {
|
||||
test("splits Mermaid-style line breaks", () => {
|
||||
expect(splitDiagramLines("one<br/> two <br>three")).toEqual(["one", "two", "three"])
|
||||
})
|
||||
|
||||
test("measures padded text boxes", () => {
|
||||
expect(measureDiagramTextBox("wide<br/>x", { paddingX: 2, paddingY: 1 })).toEqual({
|
||||
width: 8,
|
||||
height: 4,
|
||||
lines: ["wide", "x"],
|
||||
})
|
||||
})
|
||||
|
||||
test("measures terminal cell width", () => {
|
||||
expect(diagramTextWidth("abc")).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import stringWidth from "string-width"
|
||||
import { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export interface DiagramTextBoxSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export function diagramTextWidth(value: string): number {
|
||||
return stringWidth(value)
|
||||
}
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function* diagramTextGraphemes(value: string): Generator<string> {
|
||||
for (const { segment } of graphemeSegmenter.segment(value)) yield segment
|
||||
}
|
||||
|
||||
export function measureDiagramTextBox(
|
||||
value: string,
|
||||
options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
|
||||
): DiagramTextBoxSize {
|
||||
const lines = splitDiagramLines(value)
|
||||
const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
|
||||
return {
|
||||
width: innerWidth + (options.paddingX ?? 0) * 2,
|
||||
height: lines.length + (options.paddingY ?? 0) * 2,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { isMermaidStateDiagram } from "./state/parser.js"
|
||||
|
||||
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
|
||||
if (isMermaidFlowchartDiagram(content)) return "flowchart"
|
||||
if (isMermaidSequenceDiagram(content)) return "sequence"
|
||||
if (isMermaidStateDiagram(content)) return "state"
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that merman does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
readonly _tag = "MermaidSyntaxError"
|
||||
|
||||
constructor(
|
||||
readonly kind: MermaidDiagramKind,
|
||||
readonly lineNumber: number,
|
||||
readonly sourceLine: string,
|
||||
reason = "Unsupported syntax",
|
||||
) {
|
||||
super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
|
||||
this.name = "MermaidSyntaxError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { orthogonalPathPoints, walkOrthogonalSegment } from "../core/geometry.js"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { diagramRadialCellColorLevel } from "../core/color/map.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
DIAGRAM_ARROW_HEADS,
|
||||
diagramArrowHeadBetween,
|
||||
diagramDiamondCharactersFromBorder,
|
||||
diagramLineGlyph,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "../core/drawing.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
|
||||
import {
|
||||
DATABASE_EDGE_FADE_STYLES,
|
||||
flowchartNodeColorKey,
|
||||
NODE_EDGE_FADE_STYLES,
|
||||
type FlowchartCellMetadata,
|
||||
type FlowchartCellStyle,
|
||||
type FlowchartEdgeFadeStyle,
|
||||
type FlowchartGrid,
|
||||
} from "./style.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartActiveEdgeSelection,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
function mergeFlowchartCell(
|
||||
existing: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
|
||||
incoming: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
|
||||
): DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata> {
|
||||
if (incoming.style !== "edge" && incoming.style !== "activeEdge") return incoming
|
||||
if (existing.style === "label") return incoming.style === "activeEdge" ? incoming : existing
|
||||
if (incoming.char === " ") return existing
|
||||
if ((existing.style !== "edge" && existing.style !== "activeEdge") || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
char: mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char,
|
||||
} as DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>
|
||||
}
|
||||
|
||||
function nodeMetadataForCell(
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
border = false,
|
||||
): FlowchartCellMetadata {
|
||||
const key = flowchartNodeColorKey(nodeId, diagramRadialCellColorLevel(bounds, x, y, border))
|
||||
return { nodeId: key, bgNodeId: key }
|
||||
}
|
||||
|
||||
function setNodeText(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, (cellX, cellY) => nodeMetadataForCell(bounds, nodeId, cellX, cellY))
|
||||
}
|
||||
|
||||
function fillNodeInterior(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, " ", style, nodeMetadataForCell(bounds, nodeId, x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawNode(
|
||||
grid: FlowchartGrid,
|
||||
node: FlowchartNode,
|
||||
bounds: FlowchartNodeBounds,
|
||||
borderStyle: BorderStyle,
|
||||
active: boolean,
|
||||
): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
const style: FlowchartCellStyle = active ? "activeNode" : node.shape === "database" ? "database" : "node"
|
||||
|
||||
if (node.shape === "decision") {
|
||||
drawDiagramDiamond(
|
||||
bounds,
|
||||
(x, y, char) => grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
|
||||
diagramDiamondCharactersFromBorder(chars),
|
||||
)
|
||||
} else if (node.shape === "subroutine") {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawSubroutineNode(grid, bounds, chars, style, node.id)
|
||||
} else if (node.shape === "database") {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawDatabaseNode(grid, bounds, chars, style, node.id)
|
||||
} else {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
|
||||
)
|
||||
}
|
||||
|
||||
const textTop =
|
||||
node.shape === "decision"
|
||||
? bounds.top + Math.floor((bounds.height - bounds.lines.length) / 2)
|
||||
: node.shape === "database"
|
||||
? bounds.top + 2
|
||||
: bounds.top + 1
|
||||
for (const [index, line] of bounds.lines.entries()) {
|
||||
const lineX =
|
||||
node.shape === "subroutine"
|
||||
? bounds.left + 3
|
||||
: bounds.left + Math.max(1, Math.floor((bounds.width - visualLength(line)) / 2))
|
||||
setNodeText(grid, bounds, node.id, lineX, textTop + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubroutineNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
nodeId: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
|
||||
)
|
||||
const leftRailX = bounds.left + 2
|
||||
const rightRailX = bounds.left + bounds.width - 3
|
||||
grid.setCell(
|
||||
leftRailX,
|
||||
bounds.top,
|
||||
chars.topT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top, true),
|
||||
)
|
||||
grid.setCell(
|
||||
rightRailX,
|
||||
bounds.top,
|
||||
chars.topT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top, true),
|
||||
)
|
||||
grid.setCell(
|
||||
leftRailX,
|
||||
bounds.top + bounds.height - 1,
|
||||
chars.bottomT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top + bounds.height - 1, true),
|
||||
)
|
||||
grid.setCell(
|
||||
rightRailX,
|
||||
bounds.top + bounds.height - 1,
|
||||
chars.bottomT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top + bounds.height - 1, true),
|
||||
)
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
grid.setCell(leftRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, leftRailX, y, true))
|
||||
grid.setCell(rightRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, rightRailX, y, true))
|
||||
}
|
||||
}
|
||||
|
||||
function drawDatabaseNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
nodeId: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
|
||||
)
|
||||
const topRailY = bounds.top + 1
|
||||
const bottomRailY = bounds.top + bounds.height - 2
|
||||
for (const y of [topRailY, bottomRailY]) {
|
||||
grid.setCell(bounds.left, y, chars.leftT, style, nodeMetadataForCell(bounds, nodeId, bounds.left, y, true))
|
||||
grid.setCell(
|
||||
bounds.left + bounds.width - 1,
|
||||
y,
|
||||
chars.rightT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, bounds.left + bounds.width - 1, y, true),
|
||||
)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, chars.horizontal, style, nodeMetadataForCell(bounds, nodeId, x, y, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubgraphFrame(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds, borderStyle: BorderStyle): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, "group"))
|
||||
}
|
||||
|
||||
function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds): void {
|
||||
if (bounds.label) {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const labelY = bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length
|
||||
for (const [index, line] of lines.entries()) {
|
||||
grid.setText(bounds.left + 2, labelY + index, ` ${line} `, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
for (const [index, line] of label.lines.entries()) {
|
||||
grid.setText(label.point.x, label.point.y + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute, active = false): void {
|
||||
const { edge, points } = route
|
||||
if (points.length < 2) return
|
||||
const style: FlowchartCellStyle = active ? "activeEdge" : "edge"
|
||||
|
||||
drawOrthogonalPath(points, (x, y, char) => grid.setCell(x, y, char, style), {
|
||||
cornerStyle: "rounded",
|
||||
lineStyle: edge.style === "thick" ? "heavy" : edge.style === "dashed" ? "dashed" : "single",
|
||||
})
|
||||
const end = points[points.length - 1]!
|
||||
const arrowFrom = points[points.length - 2]!
|
||||
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
|
||||
if (edge.label) {
|
||||
drawEdgeLabel(grid, route, active ? "activeEdge" : "label")
|
||||
}
|
||||
}
|
||||
|
||||
function activeEdgeMatches(
|
||||
route: FlowchartEdgeRoute,
|
||||
edgeIndex: number,
|
||||
activeEdge: FlowchartActiveEdgeSelection,
|
||||
): boolean {
|
||||
return (
|
||||
route.edge.from === activeEdge.from &&
|
||||
route.edge.to === activeEdge.to &&
|
||||
(activeEdge.index ?? edgeIndex) === edgeIndex
|
||||
)
|
||||
}
|
||||
|
||||
function activeRoute(
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
diagram: FlowchartDiagram,
|
||||
activeEdge: FlowchartActiveEdgeSelection | undefined,
|
||||
): FlowchartEdgeRoute | undefined {
|
||||
if (!activeEdge) return undefined
|
||||
const edgeIndexes = new Map(diagram.edges.map((edge, index) => [edge, index]))
|
||||
for (let index = 0; index < routes.length; index++) {
|
||||
const route = routes[index]!
|
||||
if (activeEdgeMatches(route, edgeIndexes.get(route.edge) ?? index, activeEdge)) return route
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function activeRoutePoints(
|
||||
route: FlowchartEdgeRoute,
|
||||
from: FlowchartNodeBounds | undefined,
|
||||
): readonly FlowchartPoint[] {
|
||||
const sourcePoint = route.points[0]
|
||||
return from && sourcePoint ? [{ ...flowchartSourceConnector(from, sourcePoint) }, ...route.points] : route.points
|
||||
}
|
||||
|
||||
function styleActivePathCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartCellStyle): void {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (cell && cell.char !== " ") cell.style = style
|
||||
}
|
||||
|
||||
function drawActiveRoute(grid: FlowchartGrid, route: FlowchartEdgeRoute, from: FlowchartNodeBounds | undefined): void {
|
||||
for (const point of orthogonalPathPoints(activeRoutePoints(route, from))) {
|
||||
styleActivePathCell(grid, point.x, point.y, "activeEdge")
|
||||
}
|
||||
if (route.edge.label) {
|
||||
drawEdgeLabel(grid, route, "activeEdge")
|
||||
}
|
||||
}
|
||||
|
||||
function flowchartNodeStyle(node: FlowchartNode | undefined): "node" | "database" {
|
||||
return node?.shape === "database" ? "database" : "node"
|
||||
}
|
||||
|
||||
function sourceFadeStyles(sourceStyle: "node" | "database"): readonly FlowchartEdgeFadeStyle[] {
|
||||
return sourceStyle === "database" ? DATABASE_EDGE_FADE_STYLES : NODE_EDGE_FADE_STYLES
|
||||
}
|
||||
|
||||
function styleExistingEdgeCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartEdgeFadeStyle): boolean {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (!cell || cell.char === " " || cell.style === "label" || DIAGRAM_ARROW_HEADS.has(cell.char)) return false
|
||||
grid.setCell(x, y, cell.char, style)
|
||||
return true
|
||||
}
|
||||
|
||||
function fadeSourcePath(
|
||||
grid: FlowchartGrid,
|
||||
points: FlowchartPoint[],
|
||||
styles: readonly FlowchartEdgeFadeStyle[],
|
||||
): void {
|
||||
let styleIndex = 1
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (let index = 1; index < points.length && styleIndex < styles.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = flowchartDirectionBetween(from, to)
|
||||
if (!direction) continue
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (styleIndex >= styles.length) return false
|
||||
const key = `${point.x}:${point.y}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
if (styleExistingEdgeCell(grid, point.x, point.y, styles[styleIndex]!)) styleIndex += 1
|
||||
}
|
||||
return styleIndex < styles.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function drawSourceConnectors(
|
||||
grid: FlowchartGrid,
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const nodesById = new Map(diagram.nodes.map((node) => [node.id, node]))
|
||||
|
||||
for (const route of routes) {
|
||||
const from = bounds.get(route.edge.from)
|
||||
const sourcePoint = route.points[0]
|
||||
if (!from || !sourcePoint) continue
|
||||
const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from)))
|
||||
const connector = flowchartSourceConnector(from, sourcePoint)
|
||||
grid.setCell(connector.x, connector.y, connector.char, styles[0])
|
||||
const routeDirection = route.points[1] ? flowchartDirectionBetween(sourcePoint, route.points[1]!) : undefined
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell) {
|
||||
cell.char = diagramLineGlyph(
|
||||
new Set([routeDirection, connectorDirection]),
|
||||
"rounded",
|
||||
route.edge.style === "thick" ? "heavy" : "single",
|
||||
)
|
||||
cell.style = "edge"
|
||||
}
|
||||
}
|
||||
fadeSourcePath(grid, route.points, styles)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFlowchartDiagramGrid(
|
||||
diagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartGrid {
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const { bounds, routes, subgraphBounds, width, height } = layout
|
||||
diagram = layout.diagram
|
||||
const grid = new DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>(width, height, {
|
||||
mergeCell: mergeFlowchartCell,
|
||||
})
|
||||
const selectedRoute = activeRoute(routes, diagram, options.activeEdge)
|
||||
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphFrame(grid, bound, borderStyle)
|
||||
}
|
||||
for (const route of routes) drawRoutedEdge(grid, route)
|
||||
for (const node of diagram.nodes) {
|
||||
const bound = bounds.get(node.id)
|
||||
if (bound) drawNode(grid, node, bound, borderStyle, node.id === options.activeNode)
|
||||
}
|
||||
drawSourceConnectors(grid, diagram, bounds, routes)
|
||||
if (selectedRoute) {
|
||||
const from = bounds.get(selectedRoute.edge.from)
|
||||
drawActiveRoute(grid, selectedRoute, from)
|
||||
}
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphLabel(grid, bound)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { renderFlowchartDiagram, renderFlowchartDiagramAnsi } from "./render.js"
|
||||
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
|
||||
|
||||
export type {
|
||||
FlowchartDiagram as Diagram,
|
||||
FlowchartActiveEdgeSelection as ActiveEdgeSelection,
|
||||
FlowchartDirection as Direction,
|
||||
FlowchartEdge as Edge,
|
||||
FlowchartEdgeDirection as EdgeDirection,
|
||||
FlowchartEdgeRoute as EdgeRoute,
|
||||
FlowchartNode as Node,
|
||||
FlowchartNodeBounds as NodeBounds,
|
||||
FlowchartNodeShape as NodeShape,
|
||||
FlowchartPoint as Point,
|
||||
FlowchartSubgraph as Subgraph,
|
||||
FlowchartSubgraphBounds as SubgraphBounds,
|
||||
} from "./types.js"
|
||||
export type {
|
||||
FlowchartDiagramAnsiOptions as AnsiRenderOptions,
|
||||
FlowchartDiagramOptions as RenderableOptions,
|
||||
FlowchartDiagramRenderOptions as PlainRenderOptions,
|
||||
} from "./options.js"
|
||||
export type { FlowchartDiagramAnsiTheme as Theme, FlowchartNodeColors as NodeColors } from "./style.js"
|
||||
export { flowchartNodeColorKey as nodeColorKey } from "./style.js"
|
||||
export { isMermaidFlowchartDiagram as is, parseMermaidFlowchartDiagram as parse } from "./parser.js"
|
||||
export { FlowchartDiagramRenderable as Renderable } from "./renderable.js"
|
||||
|
||||
export interface RenderOptions extends FlowchartDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid flowchart string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color
|
||||
? renderFlowchartDiagramAnsi(content, rest)
|
||||
: renderFlowchartDiagram(content, rest as FlowchartDiagramRenderOptions)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
|
||||
const measure = (text: string): number => text.length
|
||||
|
||||
describe("flowchart edge labels", () => {
|
||||
test("places vertical-route labels beside the bus", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 23, y: 5 })
|
||||
})
|
||||
|
||||
test("places labels inline only when padded text fits with clearance", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 13, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 2 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 9, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
})
|
||||
|
||||
test("uses vertical bus labels before short terminal branches", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 155, y: 5 },
|
||||
{ x: 150, y: 5 },
|
||||
{ x: 150, y: 9 },
|
||||
{ x: 146, y: 9 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 151, y: 7 })
|
||||
})
|
||||
|
||||
test("measures br-delimited edge label lines as a block", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 20, y: 2 },
|
||||
],
|
||||
"first<br/>second line",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.lines).toEqual([" first ", " second line "])
|
||||
expect(layout.width).toBe(13)
|
||||
expect(layout.height).toBe(2)
|
||||
})
|
||||
|
||||
test("places multiline horizontal edge labels outside the route row", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 20, y: 5 },
|
||||
],
|
||||
"first<br/>second",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point.y + layout.height).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
test("centers multiline vertical edge labels beside their route", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 2 },
|
||||
{ x: 22, y: 10 },
|
||||
],
|
||||
"one<br/>two<br/>three",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point).toEqual({ x: 23, y: 5 })
|
||||
expect(layout.height).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
clampPoint,
|
||||
centeredSpanStart,
|
||||
insetSpan,
|
||||
midpoint,
|
||||
point,
|
||||
pointOnSegment,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
shiftPoint,
|
||||
spanCapacity,
|
||||
type DiagramSegment,
|
||||
} from "../core/geometry.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import type { FlowchartPoint } from "./types.js"
|
||||
|
||||
const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
const LABEL_LINE_CLEARANCE = 2
|
||||
const LABEL_PADDING = 1
|
||||
|
||||
export interface FlowchartEdgeLabelLayout {
|
||||
lines: string[]
|
||||
point: FlowchartPoint
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function flowchartLabelText(label: string): string {
|
||||
return `${" ".repeat(LABEL_PADDING)}${label}${" ".repeat(LABEL_PADDING)}`
|
||||
}
|
||||
|
||||
export function flowchartLabelWidth(label: string, measure: (text: string) => number): number {
|
||||
return Math.max(...splitDiagramLines(label).map((line) => measure(line) + LABEL_PADDING * 2))
|
||||
}
|
||||
|
||||
function minimumInlineLabelLength(labelWidth: number): number {
|
||||
return labelWidth + LABEL_LINE_CLEARANCE * 2 - 1
|
||||
}
|
||||
|
||||
export function flowchartHorizontalLabelRankGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + 1
|
||||
}
|
||||
|
||||
export function flowchartVerticalBranchLabelGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + LABEL_NODE_CLEARANCE
|
||||
}
|
||||
|
||||
function inlineLabelSlot(segment: DiagramSegment, labelWidth: number): { x: number; fits: boolean } {
|
||||
const slot = insetSpan(segmentSpan(segment), LABEL_LINE_CLEARANCE)
|
||||
return { x: centeredSpanStart(slot, labelWidth), fits: spanCapacity(slot) >= labelWidth }
|
||||
}
|
||||
|
||||
function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHeight: number): FlowchartPoint {
|
||||
if (segment.axis === "x") {
|
||||
const slot = inlineLabelSlot(segment, labelWidth)
|
||||
if (labelHeight === 1 && slot.fits) return point(slot.x, segment.from.y)
|
||||
|
||||
if (labelHeight > 1) {
|
||||
return shiftPoint(point(slot.x, segment.from.y), "up", labelHeight)
|
||||
}
|
||||
|
||||
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
|
||||
}
|
||||
|
||||
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
|
||||
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
|
||||
}
|
||||
|
||||
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
|
||||
let roomyHorizontal: DiagramSegment | undefined
|
||||
let verticalBus: DiagramSegment | undefined
|
||||
let longest: DiagramSegment | undefined
|
||||
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = segmentBetween(points[index - 1]!, points[index]!)
|
||||
if (!segment) continue
|
||||
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
|
||||
if (!verticalBus && segment.axis === "y") verticalBus = segment
|
||||
if (!longest || segment.length > longest.length) longest = segment
|
||||
}
|
||||
|
||||
return roomyHorizontal ?? verticalBus ?? longest
|
||||
}
|
||||
|
||||
function flowchartLabelPoint(
|
||||
points: readonly FlowchartPoint[],
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
): FlowchartPoint {
|
||||
const segment = bestLabelSegment(points, labelWidth)
|
||||
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
|
||||
}
|
||||
|
||||
export function flowchartEdgeLabelLayout(
|
||||
points: readonly FlowchartPoint[],
|
||||
label: string,
|
||||
measure: (text: string) => number,
|
||||
): FlowchartEdgeLabelLayout {
|
||||
const lines = splitDiagramLines(label).map(flowchartLabelText)
|
||||
const width = flowchartLabelWidth(label, measure)
|
||||
const height = lines.length
|
||||
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
translateDiagramBounds,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
flowchartEdgeLabelLayout,
|
||||
flowchartHorizontalLabelRankGap,
|
||||
flowchartLabelWidth,
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartNodeSize,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_MIN_NODE_GAP = 5
|
||||
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
|
||||
export const DEFAULT_MIN_RANK_GAP = 10
|
||||
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
|
||||
export const COMPACT_MIN_RANK_GAP = 4
|
||||
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
|
||||
const SUBGRAPH_PADDING_X = 2
|
||||
const SUBGRAPH_PADDING_TOP = 1
|
||||
const SUBGRAPH_PADDING_BOTTOM = 1
|
||||
|
||||
export interface FlowchartLayout {
|
||||
diagram: FlowchartDiagram
|
||||
bounds: Map<string, FlowchartNodeBounds>
|
||||
routes: FlowchartEdgeRoute[]
|
||||
subgraphBounds: Map<string, FlowchartSubgraphBounds>
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
|
||||
|
||||
function horizontalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelGap)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function verticalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelHeight = splitDiagramLines(edge.label).length
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function isHorizontalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "LR" || direction === "RL"
|
||||
}
|
||||
|
||||
export function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback
|
||||
return Math.max(1, Math.trunc(value))
|
||||
}
|
||||
|
||||
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
|
||||
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
|
||||
const innerWidth = width - 4
|
||||
if (node.shape === "decision") {
|
||||
const width = innerWidth + 6
|
||||
return {
|
||||
width: width % 2 === 0 ? width + 1 : width,
|
||||
height: Math.max(5, lines.length + 4),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
|
||||
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
|
||||
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
||||
}
|
||||
|
||||
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
||||
const ranks = new Map<string, number>()
|
||||
const outgoing = new Map<string, string[]>()
|
||||
const incoming = new Set<string>()
|
||||
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
const list = outgoing.get(edge.from) ?? []
|
||||
list.push(edge.to)
|
||||
outgoing.set(edge.from, list)
|
||||
incoming.add(edge.to)
|
||||
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
|
||||
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
|
||||
|
||||
const queue = starts.map((node) => node.id)
|
||||
for (const node of starts) ranks.set(node.id, 0)
|
||||
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index]!
|
||||
const rank = ranks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
const nextRank = rank + 1
|
||||
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
||||
ranks.set(to, nextRank)
|
||||
queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
|
||||
}
|
||||
|
||||
const acyclicRanks = new Map<string, number>()
|
||||
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
|
||||
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
|
||||
for (let index = 0; index < acyclicQueue.length; index++) {
|
||||
const id = acyclicQueue[index]!
|
||||
const rank = acyclicRanks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
|
||||
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
|
||||
incomingCounts.set(to, remainingIncoming)
|
||||
if (remainingIncoming === 0) acyclicQueue.push(to)
|
||||
}
|
||||
}
|
||||
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
|
||||
|
||||
return ranks
|
||||
}
|
||||
|
||||
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
|
||||
translateDiagramBounds(bounds, dx, dy)
|
||||
}
|
||||
|
||||
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
|
||||
for (const route of routes) {
|
||||
for (const point of route.points) {
|
||||
point.x += dx
|
||||
point.y += dy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromBounds(children)
|
||||
}
|
||||
|
||||
function subgraphBoundFromChildren(
|
||||
id: string,
|
||||
label: string,
|
||||
children: readonly FlowchartBounds[],
|
||||
): FlowchartSubgraphBounds {
|
||||
const labelLines = splitDiagramLines(label)
|
||||
const labelHeight = labelLines.length
|
||||
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
|
||||
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
|
||||
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
|
||||
const bottom =
|
||||
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
|
||||
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
|
||||
|
||||
if (right - left < minWidth) {
|
||||
const extra = minWidth - (right - left)
|
||||
left -= Math.floor(extra / 2)
|
||||
right += Math.ceil(extra / 2)
|
||||
}
|
||||
|
||||
const width = right - left
|
||||
const height = Math.max(3, bottom - top)
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
labelSide: "top",
|
||||
}
|
||||
}
|
||||
|
||||
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
|
||||
return leftStart <= rightEnd && rightStart <= leftEnd
|
||||
}
|
||||
|
||||
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const left = bounds.left + 2
|
||||
const height = lines.length
|
||||
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
|
||||
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
|
||||
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
|
||||
}
|
||||
|
||||
function segmentOverlapsSlot(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
slot: FlowchartBounds,
|
||||
): boolean {
|
||||
const segment = segmentBetween(from, to)
|
||||
if (!segment) return false
|
||||
|
||||
const slotRight = slot.left + slot.width - 1
|
||||
const slotBottom = slot.top + slot.height - 1
|
||||
const span = segmentSpan(segment)
|
||||
if (segment.axis === "x") {
|
||||
return (
|
||||
segment.from.y >= slot.top &&
|
||||
segment.from.y <= slotBottom &&
|
||||
spansOverlap(span.start, span.end, slot.left, slotRight)
|
||||
)
|
||||
}
|
||||
return (
|
||||
segment.from.x >= slot.left &&
|
||||
segment.from.x <= slotRight &&
|
||||
spansOverlap(span.start, span.end, slot.top, slotBottom)
|
||||
)
|
||||
}
|
||||
|
||||
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
|
||||
}
|
||||
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (
|
||||
!routeLabelBounds ||
|
||||
!spansOverlap(
|
||||
routeLabelBounds.top,
|
||||
routeLabelBounds.top + routeLabelBounds.height - 1,
|
||||
slot.top,
|
||||
slot.top + slot.height - 1,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return spansOverlap(
|
||||
routeLabelBounds.left,
|
||||
routeLabelBounds.left + routeLabelBounds.width - 1,
|
||||
slot.left,
|
||||
slot.left + slot.width - 1,
|
||||
)
|
||||
}
|
||||
|
||||
function chooseSubgraphLabelSide(
|
||||
bounds: FlowchartSubgraphBounds,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): FlowchartSubgraphBounds["labelSide"] {
|
||||
const topSlot = labelSlot(bounds, "top")
|
||||
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
|
||||
|
||||
const bottomSlot = labelSlot(bounds, "bottom")
|
||||
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
|
||||
}
|
||||
|
||||
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromPoints(points)
|
||||
}
|
||||
|
||||
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
|
||||
if (!route.edge.label) return undefined
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
const { point, width, height } = label
|
||||
return {
|
||||
left: point.x,
|
||||
top: point.y,
|
||||
width,
|
||||
height,
|
||||
centerX: point.x + Math.floor(width / 2),
|
||||
centerY: point.y + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
return routeRenderBounds(
|
||||
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
|
||||
)
|
||||
}
|
||||
|
||||
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
const bounds: FlowchartBounds[] = []
|
||||
for (const route of routes) {
|
||||
const routeBounds = pathBounds(route.points)
|
||||
if (routeBounds) bounds.push(routeBounds)
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (routeLabelBounds) bounds.push(routeLabelBounds)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutRankedNodes(
|
||||
diagram: FlowchartDiagram,
|
||||
direction: FlowchartDirection,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): Map<string, FlowchartNodeBounds> {
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
let widestPaddedEdgeLabel = 0
|
||||
for (const edge of diagram.edges) {
|
||||
if (edge.label)
|
||||
widestPaddedEdgeLabel = Math.max(widestPaddedEdgeLabel, flowchartLabelWidth(edge.label, visualLength))
|
||||
}
|
||||
const rankNodeGap = horizontal
|
||||
? minNodeGap
|
||||
: Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
|
||||
const ranks = rankNodes(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
const ranksByIndex = new Map<number, FlowchartNode[]>()
|
||||
const normalizedRanks = new Map<string, number>()
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
const rank = ranks.get(node.id) ?? 0
|
||||
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
|
||||
normalizedRanks.set(node.id, normalizedRank)
|
||||
const nodes = ranksByIndex.get(normalizedRank) ?? []
|
||||
nodes.push(node)
|
||||
ranksByIndex.set(normalizedRank, nodes)
|
||||
}
|
||||
|
||||
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
|
||||
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
||||
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
||||
const bounds = new Map<string, FlowchartNodeBounds>()
|
||||
|
||||
if (horizontal) {
|
||||
const columnWidths = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
|
||||
)
|
||||
const columnHeights = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasHeight = Math.max(1, ...columnHeights)
|
||||
let x = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const columnWidth = columnWidths[rankIndex]!
|
||||
let y = Math.floor((canvasHeight - columnHeights[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const left = x + Math.floor((columnWidth - size.width) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left,
|
||||
top: y,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: y + Math.floor(size.height / 2),
|
||||
})
|
||||
y += size.height + rankNodeGap
|
||||
}
|
||||
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
} else {
|
||||
const rowHeights = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
|
||||
)
|
||||
const rowWidths = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rowWidths)
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const rowHeight = rowHeights[rankIndex]!
|
||||
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left: x,
|
||||
top,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + rankNodeGap
|
||||
}
|
||||
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): void {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
|
||||
const nodeIds = new Set(subgraph.nodeIds)
|
||||
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
|
||||
if (!currentBounds) continue
|
||||
|
||||
const localDiagram: FlowchartDiagram = {
|
||||
direction: subgraph.direction,
|
||||
nodes,
|
||||
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
|
||||
subgraphs: [],
|
||||
}
|
||||
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
|
||||
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
|
||||
const localExtent = boundsFromChildren([...localBounds.values()])
|
||||
if (!localExtent) continue
|
||||
|
||||
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
|
||||
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
|
||||
const dx = targetLeft - localExtent.left
|
||||
const dy = targetTop - localExtent.top
|
||||
|
||||
for (const [nodeId, bound] of localBounds) {
|
||||
translateBounds(bound, dx, dy)
|
||||
nodeBounds.set(nodeId, bound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction) continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
|
||||
}
|
||||
return diagram.direction
|
||||
}
|
||||
|
||||
function hasLocalSubgraphDirection(diagram: FlowchartDiagram): boolean {
|
||||
return (diagram.subgraphs ?? []).some((subgraph) => subgraph.direction && subgraph.direction !== diagram.direction)
|
||||
}
|
||||
|
||||
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
|
||||
const nodeIds = new Set<string>()
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
|
||||
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
|
||||
if (subgraph.parentId === subgraphId) {
|
||||
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
return nodeIds
|
||||
}
|
||||
|
||||
function separateLocalSubgraphItems(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
gap: number,
|
||||
): void {
|
||||
if (!hasLocalSubgraphDirection(diagram)) return
|
||||
|
||||
const coveredNodeIds = new Set<string>()
|
||||
const items: { bounds: FlowchartBounds; nodeIds: Set<string> }[] = []
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.parentId) continue
|
||||
const bounds = subgraphBounds.get(subgraph.id)
|
||||
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
||||
if (!bounds || nodeIds.size === 0) continue
|
||||
items.push({ bounds, nodeIds })
|
||||
for (const nodeId of nodeIds) coveredNodeIds.add(nodeId)
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (coveredNodeIds.has(node.id)) continue
|
||||
const bounds = nodeBounds.get(node.id)
|
||||
if (bounds) items.push({ bounds, nodeIds: new Set([node.id]) })
|
||||
}
|
||||
|
||||
const horizontal = isHorizontalDirection(diagram.direction)
|
||||
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
|
||||
|
||||
let cursor: number | undefined
|
||||
for (const item of items) {
|
||||
const start = horizontal ? item.bounds.left : item.bounds.top
|
||||
const size = horizontal ? item.bounds.width : item.bounds.height
|
||||
if (cursor === undefined) {
|
||||
cursor = start + size + gap
|
||||
continue
|
||||
}
|
||||
const shift = cursor - start
|
||||
if (shift !== 0) {
|
||||
for (const nodeId of item.nodeIds) {
|
||||
const bounds = nodeBounds.get(nodeId)
|
||||
if (bounds) translateBounds(bounds, horizontal ? shift : 0, horizontal ? 0 : shift)
|
||||
}
|
||||
}
|
||||
cursor = start + shift + size + gap
|
||||
}
|
||||
}
|
||||
|
||||
function layoutSubgraphs(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): Map<string, FlowchartSubgraphBounds> {
|
||||
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
|
||||
for (const subgraph of [...subgraphs].reverse()) {
|
||||
const children: FlowchartBounds[] = []
|
||||
for (const nodeId of subgraph.nodeIds) {
|
||||
const bound = nodeBounds.get(nodeId)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
|
||||
for (const childSubgraph of subgraphs) {
|
||||
if (childSubgraph.parentId !== subgraph.id) continue
|
||||
const bound = subgraphBounds.get(childSubgraph.id)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
if (children.length > 0) {
|
||||
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
|
||||
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
||||
subgraphBounds.set(subgraph.id, bound)
|
||||
}
|
||||
}
|
||||
|
||||
return subgraphBounds
|
||||
}
|
||||
|
||||
function layoutFlowchartWithDirection(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartLayout {
|
||||
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
|
||||
const requestedMinRankGap = normalizePositiveInt(
|
||||
options.minRankGap,
|
||||
options.compact
|
||||
? horizontal
|
||||
? COMPACT_MIN_RANK_GAP
|
||||
: COMPACT_MIN_VERTICAL_RANK_GAP
|
||||
: horizontal
|
||||
? DEFAULT_MIN_RANK_GAP
|
||||
: DEFAULT_MIN_VERTICAL_RANK_GAP,
|
||||
)
|
||||
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
|
||||
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
|
||||
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
|
||||
|
||||
let routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
let subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
separateLocalSubgraphItems(diagram, bounds, subgraphBounds, Math.max(1, Math.floor(requestedMinRankGap / 2)))
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
|
||||
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
|
||||
if (dx > 0 || dy > 0) {
|
||||
for (const bound of allBounds) translateBounds(bound, dx, dy)
|
||||
translateRoutes(routes, dx, dy)
|
||||
}
|
||||
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
|
||||
}
|
||||
|
||||
export function layoutFlowchartDiagram(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartLayout {
|
||||
const direction = options.direction ?? sourceDiagram.direction
|
||||
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
|
||||
const maxWidth = options.layoutMaxWidth
|
||||
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
|
||||
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
|
||||
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
|
||||
import type { FlowchartDiagramAnsiTheme, FlowchartNodeColors } from "./style.js"
|
||||
import type { FlowchartActiveEdgeSelection, FlowchartDirection } from "./types.js"
|
||||
|
||||
export interface FlowchartDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
direction?: FlowchartDirection
|
||||
borderStyle?: BorderStyle
|
||||
minNodeGap?: number
|
||||
minRankGap?: number
|
||||
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
|
||||
layoutMaxWidth?: number
|
||||
activeNode?: string
|
||||
activeEdge?: FlowchartActiveEdgeSelection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagramAnsiOptions extends FlowchartDiagramRenderOptions {
|
||||
theme?: FlowchartDiagramAnsiTheme
|
||||
}
|
||||
|
||||
export interface FlowchartDiagramOptions extends TextBufferOptions, FlowchartDiagramRenderOptions {
|
||||
content?: string
|
||||
nodeColor?: ColorInput
|
||||
nodeColors?: FlowchartNodeColors
|
||||
nodeBgColors?: FlowchartNodeColors
|
||||
databaseColor?: ColorInput
|
||||
edgeColor?: ColorInput
|
||||
activeNodeColor?: ColorInput
|
||||
activeEdgeColor?: ColorInput
|
||||
labelColor?: ColorInput
|
||||
groupColor?: ColorInput
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeStyle,
|
||||
FlowchartNode,
|
||||
FlowchartSubgraph,
|
||||
} from "./types.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
|
||||
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
|
||||
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
|
||||
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
|
||||
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
|
||||
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
|
||||
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
|
||||
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
|
||||
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
|
||||
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
|
||||
const ROUNDED_NODE_RE = new RegExp(`^(${ID_RE})\\((.+)\\)$`)
|
||||
const DECISION_NODE_RE = new RegExp(`^(${ID_RE})\\{(.+)\\}$`)
|
||||
const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
|
||||
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
|
||||
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
|
||||
|
||||
function normalizeDirection(value?: string): FlowchartDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
function normalizeSubgraphId(value: string, index: number): string {
|
||||
const stripped = stripQuotes(value)
|
||||
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
|
||||
}
|
||||
|
||||
function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgraph, "id" | "label"> {
|
||||
const trimmed = token
|
||||
.trim()
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
|
||||
if (withLabel) {
|
||||
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
|
||||
}
|
||||
|
||||
const label = stripQuotes(trimmed)
|
||||
return { id: normalizeSubgraphId(trimmed, index), label }
|
||||
}
|
||||
|
||||
function parseNodeToken(token: string): FlowchartNode {
|
||||
const trimmed = token.trim().replace(/;$/, "")
|
||||
const database = trimmed.match(DATABASE_NODE_RE)
|
||||
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
|
||||
|
||||
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
|
||||
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
|
||||
|
||||
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
|
||||
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
|
||||
|
||||
const rounded = trimmed.match(ROUNDED_NODE_RE)
|
||||
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
|
||||
|
||||
const decision = trimmed.match(DECISION_NODE_RE)
|
||||
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
|
||||
|
||||
const box = trimmed.match(BOX_NODE_RE)
|
||||
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
|
||||
|
||||
return { id: trimmed, label: trimmed, shape: "box" }
|
||||
}
|
||||
|
||||
function hasExplicitNodeShape(token: string): boolean {
|
||||
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
|
||||
}
|
||||
|
||||
function ensureNode(nodes: Map<string, FlowchartNode>, token: string): FlowchartNode {
|
||||
const node = parseNodeToken(token)
|
||||
const existing = nodes.get(node.id)
|
||||
if (!existing) {
|
||||
nodes.set(node.id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(token)) {
|
||||
existing.label = node.label
|
||||
existing.shape = node.shape
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
function addNodeToSubgraph(subgraph: FlowchartSubgraph | undefined, nodeId: string): void {
|
||||
if (!subgraph || subgraph.nodeIds.includes(nodeId)) return
|
||||
subgraph.nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
function stripNodeToken(token: string): string {
|
||||
return token
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
|
||||
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
|
||||
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createEdge(from: string, to: string, label: string, style: FlowchartEdgeStyle | undefined): FlowchartEdge {
|
||||
return style ? { from, to, label, style } : { from, to, label }
|
||||
}
|
||||
|
||||
export function isMermaidFlowchartDiagram(content: string): boolean {
|
||||
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
|
||||
const nodes = new Map<string, FlowchartNode>()
|
||||
const edges: FlowchartEdge[] = []
|
||||
const subgraphs: FlowchartSubgraph[] = []
|
||||
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
|
||||
let direction: FlowchartDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
const header = line.match(FLOWCHART_HEADER_RE)
|
||||
if (header) {
|
||||
direction = normalizeDirection(header[2])
|
||||
continue
|
||||
}
|
||||
|
||||
// Mermaid CSS styling does not apply to terminal theme rendering.
|
||||
if (IGNORED_PRESENTATION_RE.test(line)) continue
|
||||
|
||||
const subgraphMatch = line.match(SUBGRAPH_RE)
|
||||
if (subgraphMatch) {
|
||||
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
|
||||
const subgraph: FlowchartSubgraph = {
|
||||
...parsed,
|
||||
nodeIds: [],
|
||||
parentId: subgraphStack[subgraphStack.length - 1]?.subgraph.id,
|
||||
}
|
||||
subgraphs.push(subgraph)
|
||||
subgraphStack.push({ subgraph, lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^end$/i.test(line)) {
|
||||
if (subgraphStack.length === 0) {
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, 'Unexpected "end" without an open subgraph')
|
||||
}
|
||||
subgraphStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const currentSubgraph = subgraphStack[subgraphStack.length - 1]?.subgraph
|
||||
|
||||
const subgraphDirection = line.match(SUBGRAPH_DIRECTION_RE)
|
||||
if (subgraphDirection) {
|
||||
if (!currentSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'A "direction" statement requires an open subgraph',
|
||||
)
|
||||
}
|
||||
currentSubgraph.direction = normalizeDirection(subgraphDirection[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeEdge = line.match(/^(.+?)\s*(-->|==>|-\.->)\s*(?:\|([^|]*)\|\s*)?(.+)$/)
|
||||
const textEdge = line.match(/^(.+?)\s*(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)\s*(.+)$/)
|
||||
const edgeMatch = textEdge ?? pipeEdge
|
||||
if (edgeMatch) {
|
||||
const from = ensureNode(nodes, stripNodeToken(edgeMatch[1]!))
|
||||
const toToken = textEdge ? edgeMatch[5]! : edgeMatch[4]!
|
||||
const to = ensureNode(nodes, stripNodeToken(toToken))
|
||||
addNodeToSubgraph(currentSubgraph, from.id)
|
||||
addNodeToSubgraph(currentSubgraph, to.id)
|
||||
const arrow = textEdge ? edgeMatch[4]! : edgeMatch[2]!
|
||||
const label = textEdge ? edgeMatch[3]! : (edgeMatch[3] ?? "")
|
||||
edges.push(createEdge(from.id, to.id, label.trim(), edgeStyleFromArrow(textEdge ? edgeMatch[2]! : arrow, arrow)))
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(line)) {
|
||||
const node = ensureNode(nodes, line)
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedSubgraph = subgraphStack[subgraphStack.length - 1]
|
||||
if (unclosedSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
unclosedSubgraph.lineNumber,
|
||||
unclosedSubgraph.sourceLine,
|
||||
'Unclosed subgraph; expected "end"',
|
||||
)
|
||||
}
|
||||
|
||||
return { direction, nodes: [...nodes.values()], edges, subgraphs }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import { renderGridAnsi } from "./style.js"
|
||||
|
||||
export function renderFlowchartDiagram(content: string, options: FlowchartDiagramRenderOptions = {}): string {
|
||||
return drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options).toString({
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function renderFlowchartDiagramAnsi(content: string, options: FlowchartDiagramAnsiOptions = {}): string {
|
||||
return renderGridAnsi(drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { RGBA, type BorderStyle, type ColorInput, type RenderContext } from "@opentui/core"
|
||||
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
|
||||
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
|
||||
import { diagramColorMapsEqual, normalizeDiagramColorMap } from "../core/color/map.js"
|
||||
import { DEFAULT_BORDER_STYLE, drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramRenderOptions, FlowchartDiagramOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import {
|
||||
renderGridStyledText,
|
||||
resolveFlowchartStyleColors,
|
||||
type FlowchartGrid,
|
||||
type FlowchartNodeColors,
|
||||
} from "./style.js"
|
||||
import type { FlowchartActiveEdgeSelection, FlowchartDiagram, FlowchartDirection, FlowchartEdge } from "./types.js"
|
||||
|
||||
interface IndexedFlowchartEdge {
|
||||
edge: FlowchartEdge
|
||||
index: number
|
||||
}
|
||||
|
||||
function flowchartActiveEdgesEqual(
|
||||
left: FlowchartActiveEdgeSelection | undefined,
|
||||
right: FlowchartActiveEdgeSelection | undefined,
|
||||
): boolean {
|
||||
return left?.from === right?.from && left?.to === right?.to && left?.index === right?.index
|
||||
}
|
||||
|
||||
export class FlowchartDiagramRenderable extends DiagramRenderable<FlowchartDiagram, FlowchartGrid> {
|
||||
private _compact: boolean
|
||||
private _direction?: FlowchartDirection
|
||||
private _borderStyle: BorderStyle
|
||||
private _minNodeGap?: number
|
||||
private _minRankGap?: number
|
||||
private _layoutMaxWidth?: number
|
||||
private _nodeColor?: RGBA
|
||||
private _databaseColor?: RGBA
|
||||
private _edgeColor?: RGBA
|
||||
private _activeNodeColor?: RGBA
|
||||
private _activeEdgeColor?: RGBA
|
||||
private _nodeColors: Map<string, RGBA>
|
||||
private _nodeBgColors: Map<string, RGBA>
|
||||
private _labelColor?: RGBA
|
||||
private _groupColor?: RGBA
|
||||
private _activeNode?: string
|
||||
private _activeEdge?: FlowchartActiveEdgeSelection
|
||||
private _selectedConnectionIndex = 0
|
||||
constructor(ctx: RenderContext, options: FlowchartDiagramOptions = {}) {
|
||||
super(ctx, options)
|
||||
this._compact = options.compact ?? false
|
||||
this._direction = options.direction
|
||||
this._borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
this._minNodeGap = options.minNodeGap
|
||||
this._minRankGap = options.minRankGap
|
||||
this._layoutMaxWidth = options.layoutMaxWidth
|
||||
this._nodeColor = parseDiagramRenderableColor(options.nodeColor)
|
||||
this._databaseColor = parseDiagramRenderableColor(options.databaseColor)
|
||||
this._edgeColor = parseDiagramRenderableColor(options.edgeColor)
|
||||
this._activeNodeColor = parseDiagramRenderableColor(options.activeNodeColor)
|
||||
this._activeEdgeColor = parseDiagramRenderableColor(options.activeEdgeColor)
|
||||
this._nodeColors = normalizeDiagramColorMap(options.nodeColors)
|
||||
this._nodeBgColors = normalizeDiagramColorMap(options.nodeBgColors)
|
||||
this._labelColor = parseDiagramRenderableColor(options.labelColor)
|
||||
this._groupColor = parseDiagramRenderableColor(options.groupColor)
|
||||
this._activeNode = options.activeNode
|
||||
this._activeEdge = options.activeEdge
|
||||
this.initializeDiagram({
|
||||
parse: () => parseMermaidFlowchartDiagram(this.content),
|
||||
draw: (diagram) => drawFlowchartDiagramGrid(diagram, this.renderOptions()),
|
||||
publish: (grid) => this.styledText(grid),
|
||||
measure: { trimTop: true, trimBottom: true },
|
||||
})
|
||||
}
|
||||
|
||||
protected override contentChanged(): void {
|
||||
this._activeNode = undefined
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
}
|
||||
|
||||
get compact(): boolean {
|
||||
return this._compact
|
||||
}
|
||||
|
||||
set compact(value: boolean) {
|
||||
if (this._compact === value) return
|
||||
this._compact = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set direction(value: FlowchartDirection | undefined) {
|
||||
if (this._direction === value) return
|
||||
this._direction = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set borderStyle(value: BorderStyle | undefined) {
|
||||
const next = value ?? DEFAULT_BORDER_STYLE
|
||||
if (this._borderStyle === next) return
|
||||
this._borderStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get layoutMaxWidth(): number | undefined {
|
||||
return this._layoutMaxWidth
|
||||
}
|
||||
|
||||
set layoutMaxWidth(value: number | undefined) {
|
||||
if (this._layoutMaxWidth === value) return
|
||||
this._layoutMaxWidth = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set nodeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._nodeColor, value, (color) => (this._nodeColor = color))
|
||||
}
|
||||
|
||||
set databaseColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._databaseColor, value, (color) => (this._databaseColor = color))
|
||||
}
|
||||
|
||||
set edgeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._edgeColor, value, (color) => (this._edgeColor = color))
|
||||
}
|
||||
|
||||
set activeNodeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeNodeColor, value, (color) => (this._activeNodeColor = color))
|
||||
}
|
||||
|
||||
set activeEdgeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeEdgeColor, value, (color) => (this._activeEdgeColor = color))
|
||||
}
|
||||
|
||||
set nodeColors(value: FlowchartNodeColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._nodeColors, next)) return
|
||||
this._nodeColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
set nodeBgColors(value: FlowchartNodeColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._nodeBgColors, next)) return
|
||||
this._nodeBgColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
get activeNode(): string | undefined {
|
||||
return this._activeNode
|
||||
}
|
||||
|
||||
set activeNode(value: string | undefined) {
|
||||
if (this._activeNode === value) return
|
||||
this._activeNode = value
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get selectedConnectionIndex(): number {
|
||||
return this._selectedConnectionIndex
|
||||
}
|
||||
|
||||
get selectedConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
const selected = this.selectedOutgoingEdge()
|
||||
return selected ? { from: selected.edge.from, to: selected.edge.to, index: selected.index } : undefined
|
||||
}
|
||||
|
||||
get activeEdge(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this._activeEdge
|
||||
}
|
||||
|
||||
set activeEdge(value: FlowchartActiveEdgeSelection | undefined) {
|
||||
if (flowchartActiveEdgesEqual(this._activeEdge, value)) return
|
||||
this._activeEdge = value ? { ...value } : undefined
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set labelColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._labelColor, value, (color) => (this._labelColor = color))
|
||||
}
|
||||
|
||||
set groupColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._groupColor, value, (color) => (this._groupColor = color))
|
||||
}
|
||||
|
||||
activateFirstNode(): string | undefined {
|
||||
if (this._activeNode) return this._activeNode
|
||||
const node = this.parsedDiagram().nodes[0]
|
||||
if (!node) return undefined
|
||||
this.activeNode = node.id
|
||||
return node.id
|
||||
}
|
||||
|
||||
selectNextConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this.selectConnection(1)
|
||||
}
|
||||
|
||||
selectPreviousConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this.selectConnection(-1)
|
||||
}
|
||||
|
||||
private selectConnection(delta: 1 | -1): FlowchartActiveEdgeSelection | undefined {
|
||||
this.activateFirstNode()
|
||||
const outgoing = this.activeOutgoingEdges()
|
||||
if (outgoing.length === 0) return undefined
|
||||
this._activeEdge = undefined
|
||||
if (outgoing.length === 1) {
|
||||
this.invalidateGrid()
|
||||
return this.selectedConnection
|
||||
}
|
||||
this._selectedConnectionIndex = (this._selectedConnectionIndex + delta + outgoing.length) % outgoing.length
|
||||
this.invalidateGrid()
|
||||
return this.selectedConnection
|
||||
}
|
||||
|
||||
followSelectedConnection(): string | undefined {
|
||||
const selected = this.selectedOutgoingEdge()
|
||||
if (!selected) return undefined
|
||||
this._activeNode = selected.edge.to
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
this.invalidateGrid()
|
||||
return selected.edge.to
|
||||
}
|
||||
|
||||
private activeOutgoingEdges(): IndexedFlowchartEdge[] {
|
||||
if (!this._activeNode) return []
|
||||
return this.parsedDiagram().edges.flatMap((edge, index) =>
|
||||
edge.from === this._activeNode ? [{ edge, index }] : [],
|
||||
)
|
||||
}
|
||||
|
||||
private selectedOutgoingEdge(): IndexedFlowchartEdge | undefined {
|
||||
const outgoing = this.activeOutgoingEdges()
|
||||
if (outgoing.length === 0) return undefined
|
||||
const index = ((this._selectedConnectionIndex % outgoing.length) + outgoing.length) % outgoing.length
|
||||
return outgoing[index]
|
||||
}
|
||||
|
||||
private renderOptions(): FlowchartDiagramRenderOptions {
|
||||
return {
|
||||
compact: this._compact,
|
||||
direction: this._direction,
|
||||
borderStyle: this._borderStyle,
|
||||
minNodeGap: this._minNodeGap,
|
||||
minRankGap: this._minRankGap,
|
||||
layoutMaxWidth: this._layoutMaxWidth,
|
||||
activeNode: this._activeNode,
|
||||
activeEdge: this._activeEdge ?? this.selectedConnection,
|
||||
}
|
||||
}
|
||||
|
||||
private styledText(grid: FlowchartGrid) {
|
||||
return renderGridStyledText(
|
||||
grid,
|
||||
resolveFlowchartStyleColors({
|
||||
node: this._nodeColor,
|
||||
database: this._databaseColor,
|
||||
edge: this._edgeColor,
|
||||
activeNode: this._activeNodeColor,
|
||||
activeEdge: this._activeEdgeColor,
|
||||
label: this._labelColor,
|
||||
group: this._groupColor,
|
||||
}),
|
||||
this._nodeColors,
|
||||
this._nodeBgColors,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
|
||||
function bounds(id: string, left: number, top: number): FlowchartNodeBounds {
|
||||
const width = 5
|
||||
const height = 3
|
||||
return {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
lines: [id],
|
||||
left,
|
||||
top,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagram["edges"]): FlowchartDiagram {
|
||||
return { direction, nodes: [], edges, subgraphs: [] }
|
||||
}
|
||||
|
||||
describe("flowchart routing", () => {
|
||||
test("routes a simple horizontal edge from source port to target port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes a simple reverse horizontal edge into the target right port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 19, y: 1 },
|
||||
{ x: 5, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes horizontal back-edges above forward lanes", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: -1 },
|
||||
{ x: 22, y: -4 },
|
||||
{ x: 2, y: -4 },
|
||||
{ x: 2, y: -1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes parallel horizontal edges on independent lanes", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 3 },
|
||||
{ x: 2, y: 6 },
|
||||
{ x: 22, y: 6 },
|
||||
{ x: 22, y: 3 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("spaces parallel horizontal lanes for multiline labels", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second 1<br/>second 2<br/>second 3" },
|
||||
{ from: "A", to: "B", label: "third 1<br/>third 2<br/>third 3" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes[2]!.points[1]!.y).toBeGreaterThan(routes[1]!.points[1]!.y + 3)
|
||||
})
|
||||
|
||||
test("routes horizontal fan-out through a shared bus lane", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "" },
|
||||
{ from: "A", to: "C", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 6)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
["C", bounds("C", 20, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 13 },
|
||||
{ x: 19, y: 13 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("routes each horizontal edge once when fan-in and fan-out overlap", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "A", to: "D", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
{ from: "B", to: "D", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
["C", bounds("C", 24, 0)],
|
||||
["D", bounds("D", 24, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toHaveLength(edges.length)
|
||||
expect(routes.map((route) => `${route.edge.from}->${route.edge.to}`).sort()).toEqual([
|
||||
"A->C",
|
||||
"A->D",
|
||||
"B->C",
|
||||
"B->D",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes vertical back-edges around the left side", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("TD", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: -1, y: 13 },
|
||||
{ x: -4, y: 13 },
|
||||
{ x: -4, y: 1 },
|
||||
{ x: -1, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes self edges below the source node", () => {
|
||||
const edge = { from: "A", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(diagram("TD", [edge]), new Map([["A", bounds("A", 0, 0)]]))
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 8, y: 4 },
|
||||
{ x: 2, y: 4 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes same-column horizontal-flow edges through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "rollback" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 20, 8)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes overlapping horizontal-flow columns through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "merge" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 1, 8)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 2, y: 7 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,703 @@
|
||||
import {
|
||||
advanceCoordinate,
|
||||
afterFarthestCoordinate,
|
||||
beforeNearestCoordinate,
|
||||
boundsCenter,
|
||||
boundsSidePoint,
|
||||
centerCoordinate,
|
||||
coordinate,
|
||||
keepAfter,
|
||||
keepBefore,
|
||||
lane,
|
||||
oppositeSide,
|
||||
orthogonalPath,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
withCoordinate,
|
||||
type DiagramAxis,
|
||||
type DiagramDirection,
|
||||
type DiagramLane,
|
||||
type DiagramSide,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraph,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
|
||||
|
||||
const BUS_CLEARANCE = 3
|
||||
const NODE_CLEARANCE = 2
|
||||
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
|
||||
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
|
||||
type PortRole = "source" | "target"
|
||||
|
||||
interface EdgeRecord {
|
||||
edge: FlowchartEdge
|
||||
sourcePort: FlowchartPoint
|
||||
targetPort: FlowchartPoint
|
||||
}
|
||||
|
||||
function isVerticalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "TB" || direction === "TD" || direction === "BT"
|
||||
}
|
||||
|
||||
function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
|
||||
return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
|
||||
}
|
||||
|
||||
function isVerticalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "BT"
|
||||
? centerCoordinate(to, "y") > centerCoordinate(from, "y")
|
||||
: centerCoordinate(to, "y") < centerCoordinate(from, "y")
|
||||
}
|
||||
|
||||
function isHorizontalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "RL"
|
||||
? centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
: centerCoordinate(to, "x") < centerCoordinate(from, "x")
|
||||
}
|
||||
|
||||
function horizontalTravel(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): HorizontalTravel {
|
||||
const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
|
||||
return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
|
||||
}
|
||||
|
||||
function verticalBackEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "left")
|
||||
const end = boundsSidePoint(to, "left")
|
||||
const busX = Math.min(
|
||||
afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
|
||||
leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
|
||||
)
|
||||
return pathViaLane(start, lane("x", busX), end)
|
||||
}
|
||||
|
||||
function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const travel = verticalTravel(from, to)
|
||||
const startSide = sideForDirection(travel)
|
||||
const endSide = oppositeSide(startSide)
|
||||
const sourceCenter = boundsCenter(from)
|
||||
const targetCenter = boundsCenter(to)
|
||||
const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
|
||||
const end = boundsSidePoint(to, endSide)
|
||||
return orthogonalPath(start, end, { preferredAxis: "y" })
|
||||
}
|
||||
|
||||
function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
|
||||
return pathViaLane(start, lane("y", busY), end)
|
||||
}
|
||||
|
||||
function horizontalEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartPoint[] {
|
||||
const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
|
||||
if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
|
||||
|
||||
if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
|
||||
|
||||
const travel = horizontalTravel(from, to, direction)
|
||||
const startSide = sideForDirection(travel)
|
||||
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
|
||||
}
|
||||
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(bounds, "right")
|
||||
const end = boundsSidePoint(bounds, "bottom")
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
|
||||
const bottomLaneY = bounds.top + bounds.height + 1
|
||||
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
|
||||
}
|
||||
|
||||
function parallelEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
laneCoordinate: number,
|
||||
): FlowchartPoint[] {
|
||||
if (!isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "bottom")
|
||||
const end = boundsSidePoint(to, "bottom")
|
||||
return pathViaLane(start, lane("y", laneCoordinate), end)
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
return pathViaLane(start, lane("x", laneCoordinate), end)
|
||||
}
|
||||
|
||||
function labelHeight(edge: FlowchartEdge): number {
|
||||
return edge.label ? splitDiagramLines(edge.label).length : 0
|
||||
}
|
||||
|
||||
function rightRenderExtent(route: FlowchartEdgeRoute): number {
|
||||
let right = Math.max(...route.points.map((point) => point.x))
|
||||
if (route.edge.label) {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth)
|
||||
right = Math.max(right, label.point.x + label.width - 1)
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
function edgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
if (from.id === to.id) return selfEdgePath(from)
|
||||
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
|
||||
return isVerticalBackEdge(from, to, direction)
|
||||
? verticalBackEdgePath(from, to, leftBoundary)
|
||||
: verticalForwardEdgePath(from, to)
|
||||
}
|
||||
|
||||
function sourceFanOutLane(
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPorts: readonly FlowchartPoint[],
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const sourceCoordinate = coordinate(sourcePort, axis)
|
||||
const unclamped = keepBefore(
|
||||
advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
|
||||
beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepAfter(unclamped, sourceCoordinate, travel)
|
||||
}
|
||||
|
||||
function targetFanInLane(
|
||||
sourcePorts: readonly FlowchartPoint[],
|
||||
targetPort: FlowchartPoint,
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const targetCoordinate = coordinate(targetPort, axis)
|
||||
const unclamped = keepAfter(
|
||||
advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
|
||||
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepBefore(unclamped, targetCoordinate, travel)
|
||||
}
|
||||
|
||||
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
|
||||
const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
|
||||
return boundsSidePoint(bounds, side)
|
||||
}
|
||||
|
||||
function horizontalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "RL"
|
||||
? centerCoordinate(target, "x") < centerCoordinate(source, "x")
|
||||
: centerCoordinate(target, "x") > centerCoordinate(source, "x")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function verticalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "BT"
|
||||
? centerCoordinate(target, "y") < centerCoordinate(source, "y")
|
||||
: centerCoordinate(target, "y") > centerCoordinate(source, "y")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
|
||||
return subgraph.direction === "RL" ? "left" : "right"
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
|
||||
return subgraph.direction === "RL"
|
||||
? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
: subgraphBound.left - BUS_CLEARANCE
|
||||
}
|
||||
|
||||
function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
|
||||
if (from.centerY <= targetSubgraphBound.centerY) {
|
||||
const start = from.top + from.height
|
||||
const end = targetSubgraphBound.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : start
|
||||
}
|
||||
|
||||
const start = targetSubgraphBound.top + targetSubgraphBound.height
|
||||
const end = from.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : end
|
||||
}
|
||||
|
||||
function horizontalSubgraphExitJoinY(
|
||||
from: FlowchartSubgraphBounds,
|
||||
targetPort: FlowchartPoint,
|
||||
targetBelow: boolean,
|
||||
): number {
|
||||
if (targetBelow) {
|
||||
const outside = from.top + from.height
|
||||
const beforeTarget = targetPort.y - 1
|
||||
const preferred = targetPort.y - BUS_CLEARANCE
|
||||
return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
|
||||
}
|
||||
|
||||
const outside = from.top - 1
|
||||
const afterTarget = targetPort.y + 1
|
||||
const preferred = targetPort.y + BUS_CLEARANCE
|
||||
return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
|
||||
}
|
||||
|
||||
function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
|
||||
const groups = new Map<string, Record[]>()
|
||||
for (const record of records) {
|
||||
const groupKey = key(record)
|
||||
const group = groups.get(groupKey) ?? []
|
||||
group.push(record)
|
||||
groups.set(groupKey, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function fanRoute(
|
||||
edge: FlowchartEdge,
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPort: FlowchartPoint,
|
||||
routeLane: DiagramLane,
|
||||
): FlowchartEdgeRoute {
|
||||
return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
|
||||
}
|
||||
|
||||
function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
|
||||
const xs = records.map((record) => record.sourcePort.x)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
if (maxX - minX > 1) return [...records]
|
||||
|
||||
const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
|
||||
return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
|
||||
}
|
||||
|
||||
function routeHorizontalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
leftBoundary: number | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`)
|
||||
for (const edges of groups.values()) {
|
||||
if (edges.length < 2) continue
|
||||
const from = bounds.get(edges[0]!.from)
|
||||
const to = bounds.get(edges[0]!.to)
|
||||
if (!from || !to || from.id === to.id) continue
|
||||
const direction = directionForEdge(edges[0]!)
|
||||
const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) }
|
||||
routes.push(canonicalRoute)
|
||||
handled.add(edges[0]!)
|
||||
let previousRoute = canonicalRoute
|
||||
for (let index = 1; index < edges.length; index++) {
|
||||
const edge = edges[index]!
|
||||
const laneCoordinate = isVerticalDirection(direction)
|
||||
? Math.max(
|
||||
Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE,
|
||||
rightRenderExtent(previousRoute) + NODE_CLEARANCE,
|
||||
)
|
||||
: Math.max(
|
||||
Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE,
|
||||
Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1),
|
||||
)
|
||||
const route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
|
||||
routes.push(route)
|
||||
handled.add(edge)
|
||||
previousRoute = route
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphExitFanIn(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalExitSubgraph(diagram, edge)
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!subgraph || !source || !target) continue
|
||||
|
||||
const key = `${subgraph.id}:${edge.to}`
|
||||
const group = groups.get(key) ?? []
|
||||
group.push({ edge, subgraph, source })
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const subgraph = group[0]!.subgraph
|
||||
const subgraphBound = subgraphBounds.get(subgraph.id)
|
||||
const target = bounds.get(group[0]!.edge.to)
|
||||
if (!subgraphBound || !target) continue
|
||||
|
||||
const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
|
||||
const busX =
|
||||
subgraph.direction === "RL"
|
||||
? subgraphBound.left - BUS_CLEARANCE
|
||||
: subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
|
||||
const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
|
||||
const targetBelow = target.centerY >= subgraphBound.centerY
|
||||
const targetPort = targetSubgraph
|
||||
? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
|
||||
: boundsSidePoint(target, targetBelow ? "top" : "bottom")
|
||||
const joinY = targetSubgraphBound
|
||||
? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
|
||||
: horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
|
||||
const entryX =
|
||||
targetSubgraph && targetSubgraphBound
|
||||
? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
|
||||
: targetPort.x
|
||||
|
||||
for (const record of group) {
|
||||
const sourcePort = portForTravel(record.source, travel, "source")
|
||||
routes.push({
|
||||
edge: record.edge,
|
||||
points: pathThrough([
|
||||
sourcePort,
|
||||
{ x: busX, y: sourcePort.y },
|
||||
{ x: busX, y: joinY },
|
||||
{ x: entryX, y: joinY },
|
||||
{ x: entryX, y: targetPort.y },
|
||||
targetPort,
|
||||
]),
|
||||
})
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphEntries(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalEntrySubgraph(diagram, edge)
|
||||
const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!subgraph || !subgraphBound || !from || !to) continue
|
||||
|
||||
const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
|
||||
const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
|
||||
const travel = verticalTravel(from, to)
|
||||
const sourcePort = portForTravel(from, travel, "source")
|
||||
routes.push({
|
||||
edge,
|
||||
points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
|
||||
})
|
||||
handled.add(edge)
|
||||
}
|
||||
}
|
||||
|
||||
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
if (from.x === to.x) {
|
||||
if (
|
||||
from.x >= bounds.left &&
|
||||
from.x <= right &&
|
||||
Math.max(from.y, to.y) >= bounds.top &&
|
||||
Math.min(from.y, to.y) <= bottom
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
from.y >= bounds.top &&
|
||||
from.y <= bottom &&
|
||||
Math.max(from.x, to.x) >= bounds.left &&
|
||||
Math.min(from.x, to.x) <= right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartEdgeRoute {
|
||||
const obstacle = [...bounds.values()].some(
|
||||
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
|
||||
)
|
||||
if (!obstacle) return route
|
||||
|
||||
const from = bounds.get(route.edge.from)
|
||||
const to = bounds.get(route.edge.to)
|
||||
if (!from || !to) return route
|
||||
if (isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
const busX = Math.max(...[...bounds.values()].map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("x", busX), end) }
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = Math.min(...[...bounds.values()].map((bound) => bound.top)) - BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("y", busY), end) }
|
||||
}
|
||||
|
||||
export function routeFlowchartEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
|
||||
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): FlowchartEdgeRoute[] {
|
||||
const handled = new Set<FlowchartEdge>()
|
||||
const routes: FlowchartEdgeRoute[] = []
|
||||
const leftBoundary = subgraphBounds
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(diagram, bounds, directionForEdge, leftBoundary, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
|
||||
if (horizontalEdges.length === 0) continue
|
||||
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
|
||||
routeHorizontalFanOut(records, direction, handled, routes)
|
||||
routeHorizontalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
routeHorizontalSubgraphExitFanIn(diagram, bounds, subgraphBounds, handled, routes)
|
||||
routeHorizontalSubgraphEntries(diagram, bounds, subgraphBounds, handled, routes)
|
||||
|
||||
for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
|
||||
const verticalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
|
||||
if (verticalEdges.length === 0) continue
|
||||
const records = verticalForwardRecords(verticalEdges, bounds, direction)
|
||||
routeVerticalFanOut(records, direction, handled, routes)
|
||||
routeVerticalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!from || !to) continue
|
||||
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
|
||||
}
|
||||
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
if (sourcePoint.x < bounds.left) return "left"
|
||||
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
|
||||
if (sourcePoint.y < bounds.top) return "top"
|
||||
return "bottom"
|
||||
}
|
||||
|
||||
function connectorChar(side: DiagramSide): string {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return "┤"
|
||||
case "right":
|
||||
return "├"
|
||||
case "top":
|
||||
return "┴"
|
||||
case "bottom":
|
||||
return "┬"
|
||||
}
|
||||
}
|
||||
|
||||
export function flowchartSourceConnector(
|
||||
from: FlowchartNodeBounds,
|
||||
sourcePoint: FlowchartPoint,
|
||||
): { x: number; y: number; char: string } {
|
||||
const side = sideForOutsidePoint(from, sourcePoint)
|
||||
const connector = boundsSidePoint(from, side, "border")
|
||||
return {
|
||||
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
|
||||
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
|
||||
char: connectorChar(side),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { RGBA, type ColorInput, type StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRunOptions } from "../core/canvas.js"
|
||||
import { diagramCellColorKey, mappedDiagramColor } from "../core/color/map.js"
|
||||
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import {
|
||||
ansiFg,
|
||||
createColorRampTheme,
|
||||
createAnsiRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramFadeStep,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
|
||||
export type FlowchartBaseCellStyle = "node" | "activeNode" | "database" | "edge" | "activeEdge" | "label" | "group"
|
||||
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
||||
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
|
||||
export interface FlowchartCellMetadata {
|
||||
nodeId?: string
|
||||
bgNodeId?: string
|
||||
}
|
||||
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>
|
||||
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
||||
export type FlowchartDiagramAnsiTheme = Partial<Record<FlowchartCellStyle, string>>
|
||||
export type FlowchartNodeColorMap = ReadonlyMap<string, RGBA>
|
||||
export type FlowchartNodeColors = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
|
||||
|
||||
export function flowchartNodeColorKey(nodeId: string, level: number): string {
|
||||
return diagramCellColorKey(nodeId, level)
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_RGB = {
|
||||
node: [228, 239, 232],
|
||||
activeNode: [221, 255, 246],
|
||||
database: [228, 239, 232],
|
||||
edge: [134, 225, 200],
|
||||
activeEdge: [221, 255, 246],
|
||||
label: [134, 225, 200],
|
||||
group: [76, 99, 89],
|
||||
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
|
||||
|
||||
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
|
||||
const DEFAULT_ANSI_THEME: Required<Record<FlowchartCellStyle, string>> = {
|
||||
node: ansiFg(DEFAULT_THEME_RGB.node),
|
||||
activeNode: ansiFg(DEFAULT_THEME_RGB.activeNode),
|
||||
database: ansiFg(DEFAULT_THEME_RGB.database),
|
||||
edge: ansiFg(DEFAULT_THEME_RGB.edge),
|
||||
activeEdge: ansiFg(DEFAULT_THEME_RGB.activeEdge),
|
||||
label: ansiFg(DEFAULT_THEME_RGB.label),
|
||||
group: ansiFg(DEFAULT_THEME_RGB.group),
|
||||
...createAnsiRampTheme(NODE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.node, DEFAULT_THEME_RGB.edge),
|
||||
...createAnsiRampTheme(DATABASE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.database, DEFAULT_THEME_RGB.edge),
|
||||
}
|
||||
|
||||
export function resolveFlowchartStyleColors(
|
||||
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
||||
): FlowchartStyleColors {
|
||||
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
||||
const activeNode = colors.activeNode ?? rgba(DEFAULT_THEME_RGB.activeNode)
|
||||
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
||||
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
||||
const activeEdge = colors.activeEdge ?? rgba(DEFAULT_THEME_RGB.activeEdge)
|
||||
return {
|
||||
node,
|
||||
activeNode,
|
||||
database,
|
||||
edge,
|
||||
activeEdge,
|
||||
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
|
||||
}
|
||||
}
|
||||
|
||||
function nodeMappedColor(colors: FlowchartNodeColorMap | undefined, nodeId: string | undefined): RGBA | undefined {
|
||||
return mappedDiagramColor(colors, nodeId)
|
||||
}
|
||||
|
||||
export function renderGridStyledText(
|
||||
grid: FlowchartGrid,
|
||||
colors: FlowchartStyleColors,
|
||||
nodeColors?: FlowchartNodeColorMap,
|
||||
nodeBgColors?: FlowchartNodeColorMap,
|
||||
): StyledText {
|
||||
const useNodeRuns = Boolean(nodeColors?.size || nodeBgColors?.size)
|
||||
const runOptions: DiagramCanvasRunOptions<FlowchartCellStyle, FlowchartCellMetadata> = useNodeRuns
|
||||
? { trimTop: true, trimBottom: true, key: (cell) => [cell.style, cell.nodeId, cell.bgNodeId] }
|
||||
: { trimTop: true, trimBottom: true }
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => nodeMappedColor(nodeColors, run.cell.nodeId) ?? (run.style ? colors[run.style] : undefined),
|
||||
(run) => nodeMappedColor(nodeBgColors, run.cell.bgNodeId),
|
||||
runOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function renderGridAnsi(grid: FlowchartGrid, theme: FlowchartDiagramAnsiTheme = {}): string {
|
||||
const resolved = { ...DEFAULT_ANSI_THEME, ...theme }
|
||||
return renderDiagramGridAnsi(grid, (run) => (run.style ? resolved[run.style] : undefined), {
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
|
||||
|
||||
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
|
||||
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
|
||||
export type FlowchartEdgeStyle = "thick" | "dashed"
|
||||
|
||||
export interface FlowchartNode {
|
||||
id: string
|
||||
label: string
|
||||
shape: FlowchartNodeShape
|
||||
}
|
||||
|
||||
export interface FlowchartEdge {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style?: FlowchartEdgeStyle
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraph {
|
||||
id: string
|
||||
label: string
|
||||
nodeIds: string[]
|
||||
parentId?: string
|
||||
direction?: FlowchartDirection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagram {
|
||||
direction: FlowchartDirection
|
||||
nodes: FlowchartNode[]
|
||||
edges: FlowchartEdge[]
|
||||
subgraphs?: FlowchartSubgraph[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraphBounds extends DiagramBounds {
|
||||
id: string
|
||||
label: string
|
||||
labelSide: "top" | "bottom"
|
||||
}
|
||||
|
||||
export type FlowchartPoint = DiagramPoint
|
||||
|
||||
export interface FlowchartEdgeRoute {
|
||||
edge: FlowchartEdge
|
||||
points: FlowchartPoint[]
|
||||
}
|
||||
|
||||
export interface FlowchartActiveEdgeSelection {
|
||||
from: string
|
||||
to: string
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type FlowchartEdgeDirection = DiagramDirection
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as Flowchart from "./flowchart/index.js"
|
||||
import * as Sequence from "./sequence/index.js"
|
||||
import * as State from "./state/index.js"
|
||||
import { type MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
|
||||
export { Flowchart, Sequence, State }
|
||||
export { MermaidSyntaxError } from "./diagnostics.js"
|
||||
export { createMermaidMarkdownRenderer, type MermaidMarkdownRendererOptions } from "./markdown.js"
|
||||
|
||||
export type DiagramKind = MermaidDiagramKind
|
||||
|
||||
export type ParsedDiagram =
|
||||
| { readonly kind: "flowchart"; readonly diagram: Flowchart.Diagram }
|
||||
| { readonly kind: "sequence"; readonly diagram: Sequence.Diagram }
|
||||
| { readonly kind: "state"; readonly diagram: State.Diagram }
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
/** Theme override. Forwarded to the matching renderer. */
|
||||
theme?: Flowchart.Theme | Sequence.Theme | State.Theme
|
||||
}
|
||||
|
||||
export class UnknownDiagramError extends Error {
|
||||
readonly _tag = "UnknownDiagramError"
|
||||
constructor(content: string) {
|
||||
const head = firstMeaningfulLine(content) ?? "(empty)"
|
||||
super(
|
||||
`Could not detect diagram kind. Expected the first non-empty line to start with ` +
|
||||
`"flowchart", "graph", "sequenceDiagram", or "stateDiagram[-v2]". Got: "${head}"`,
|
||||
)
|
||||
this.name = "UnknownDiagramError"
|
||||
}
|
||||
}
|
||||
|
||||
function firstMeaningfulLine(content: string): string | undefined {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("%%"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the diagram kind from the leading Mermaid declaration.
|
||||
*
|
||||
* Returns `undefined` if the content does not look like any supported diagram.
|
||||
*/
|
||||
export function detect(content: string): DiagramKind | undefined {
|
||||
return detectMermaidDiagram(content)
|
||||
}
|
||||
|
||||
/** True if the content is recognizably a supported Mermaid diagram. */
|
||||
export function isMermaid(content: string): boolean {
|
||||
return detect(content) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render any supported Mermaid string for the terminal.
|
||||
*
|
||||
* The leading `flowchart`/`sequenceDiagram`/`stateDiagram-v2` line picks the
|
||||
* right renderer. Defaults to ANSI-colored output; pass `{ color: false }`
|
||||
* for plain text.
|
||||
*
|
||||
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const kind = detect(content)
|
||||
if (!kind) throw new UnknownDiagramError(content)
|
||||
const opts = options as { color?: boolean; theme?: unknown }
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return Flowchart.render(content, opts as Flowchart.RenderOptions)
|
||||
case "sequence":
|
||||
return Sequence.render(content, opts as Sequence.RenderOptions)
|
||||
case "state":
|
||||
return State.render(content, opts as State.RenderOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse any supported Mermaid string into a discriminated union AST.
|
||||
*
|
||||
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
|
||||
*/
|
||||
export function parse(content: string): ParsedDiagram {
|
||||
const kind = detect(content)
|
||||
if (!kind) throw new UnknownDiagramError(content)
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return { kind, diagram: Flowchart.parse(content) }
|
||||
case "sequence":
|
||||
return { kind, diagram: Sequence.parse(content) }
|
||||
case "state":
|
||||
return { kind, diagram: State.parse(content) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
createMarkdownCodeBlockRenderer,
|
||||
type ColorInput,
|
||||
type MarkdownOptions,
|
||||
type MouseEvent,
|
||||
type RenderContext,
|
||||
} from "@opentui/core"
|
||||
import { MermaidSyntaxError } from "./diagnostics.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
import { FlowchartDiagramRenderable } from "./flowchart/renderable.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { SequenceDiagramRenderable } from "./sequence/renderable.js"
|
||||
import { parseMermaidStateDiagram } from "./state/parser.js"
|
||||
import { StateDiagramRenderable } from "./state/renderable.js"
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
secondary?: ColorInput
|
||||
muted?: ColorInput
|
||||
accent?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an OpenTUI Markdown node renderer for fenced Mermaid diagrams. */
|
||||
export function createMermaidMarkdownRenderer(
|
||||
ctx: RenderContext,
|
||||
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
|
||||
): NonNullable<MarkdownOptions["renderNode"]> {
|
||||
return createMarkdownCodeBlockRenderer({
|
||||
mermaid: (token) => {
|
||||
const kind = detectMermaidDiagram(token.text)
|
||||
if (!kind) return undefined
|
||||
|
||||
try {
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
parseMermaidFlowchartDiagram(token.text)
|
||||
break
|
||||
case "sequence":
|
||||
parseMermaidSequenceDiagram(token.text)
|
||||
break
|
||||
case "state":
|
||||
parseMermaidStateDiagram(token.text)
|
||||
break
|
||||
}
|
||||
const options = typeof input === "function" ? input() : input
|
||||
const colors = options.colors ?? {}
|
||||
const diagram = (() => {
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return new FlowchartDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
compact: options.compact,
|
||||
nodeColor: colors.primary,
|
||||
databaseColor: colors.secondary,
|
||||
edgeColor: colors.secondary,
|
||||
labelColor: colors.text,
|
||||
groupColor: colors.muted,
|
||||
})
|
||||
case "sequence":
|
||||
return new SequenceDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
compact: options.compact,
|
||||
participantColor: colors.primary,
|
||||
lifelineColor: colors.muted,
|
||||
groupColor: colors.secondary,
|
||||
requestColor: colors.primary,
|
||||
responseColor: colors.primary,
|
||||
noteColor: colors.warning,
|
||||
noteBackgroundColor: colors.background,
|
||||
})
|
||||
case "state":
|
||||
return new StateDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
stateColor: colors.primary,
|
||||
compositeColor: colors.muted,
|
||||
transitionColor: colors.secondary,
|
||||
labelColor: colors.text,
|
||||
noteBorderColor: colors.warning,
|
||||
noteTextColor: colors.warning,
|
||||
noteConnectorColor: colors.muted,
|
||||
activeStateColor: colors.accent,
|
||||
activeTransitionColor: colors.accent,
|
||||
startColor: colors.muted,
|
||||
endColor: colors.muted,
|
||||
choiceColor: colors.primary,
|
||||
})
|
||||
}
|
||||
})()
|
||||
diagram.width = "100%"
|
||||
diagram.height = diagram.renderedHeight
|
||||
diagram.marginTop = 1
|
||||
diagram.selectable = false
|
||||
let drag: { x: number; y: number } | undefined
|
||||
diagram.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
drag = { x: event.x, y: event.y }
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
diagram.onMouseDrag = (event: MouseEvent) => {
|
||||
if (!drag) return
|
||||
const dx = event.x - drag.x
|
||||
drag = { x: event.x, y: event.y }
|
||||
if (dx) diagram.scrollX -= dx
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
diagram.onMouseDragEnd = () => {
|
||||
drag = undefined
|
||||
}
|
||||
diagram.onMouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
drag = undefined
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
return diagram
|
||||
} catch (error) {
|
||||
if (error instanceof MermaidSyntaxError) return undefined
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})!
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMermaidMarkdownRenderer } from "./markdown.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.merman",
|
||||
setup(context) {
|
||||
context.markdown.registerCodeBlockRenderer(
|
||||
"mermaid",
|
||||
createMermaidMarkdownRenderer(context.renderer, () => ({
|
||||
compact: true,
|
||||
colors: {
|
||||
text: context.theme.markdown.text,
|
||||
primary: context.theme.text.default,
|
||||
secondary: context.theme.text.subdued,
|
||||
muted: context.theme.border.default,
|
||||
accent: context.theme.text.action.primary.focused,
|
||||
warning: context.theme.text.default,
|
||||
background: context.theme.background.default,
|
||||
},
|
||||
})),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,896 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderSequenceDiagram, renderSequenceDiagramAnsi } from "./diagram.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { SequenceDiagramRenderable } from "./renderable.js"
|
||||
|
||||
describe("SequenceDiagram", () => {
|
||||
test("parses Mermaid sequenceDiagram participants and messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expect(diagram.participants).toEqual([
|
||||
{ id: "B", label: "Browser" },
|
||||
{ id: "S", label: "Server" },
|
||||
])
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "B", to: "S", label: "GET /", style: "solid" },
|
||||
{ from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "B", to: "S", label: "GET /", style: "solid" } },
|
||||
{ type: "message", message: { from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a terminal sequence diagram", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────▶
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◀─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders a compact terminal sequence diagram without participant boxes", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Leaf as leaf tool
|
||||
participant Location as LocationMutation
|
||||
participant File as FileMutation
|
||||
Leaf->>Location: resolve(path)
|
||||
Location-->>Leaf: Plan(target, authority anchor)
|
||||
Leaf->>File: commit(plan)
|
||||
File->>Location: revalidate(plan)
|
||||
Location-->>File: same target or reject
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
leaf tool LocationMutation FileMutation
|
||||
│ │ │
|
||||
├─ resolve(path) ───────────────────▶ │
|
||||
│ │ │
|
||||
◀─ Plan(target, authority anchor) ──┤ │
|
||||
│ │ │
|
||||
├─ commit(plan) ───────────────────────────────────────────────▶
|
||||
│ │ │
|
||||
│ ◀─ revalidate(plan) ───────┤
|
||||
│ │ │
|
||||
│ ├─ same target or reject ──▶
|
||||
│ │ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps structured sequence steps visible in compact mode", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Worker
|
||||
participant Store
|
||||
Note over Worker,Store: transaction
|
||||
alt accepted
|
||||
Worker->>Worker: prepare
|
||||
Worker->>Store: commit
|
||||
end
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toContainInOrder("Worker", "Store", "transaction", "alt: accepted", "prepare", "commit")
|
||||
})
|
||||
|
||||
test("keeps compact labels above arrows when they do not fit inline", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
"sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: this label is deliberately much too long to fit between endpoints despite intermediate spacing",
|
||||
{ compact: true },
|
||||
)
|
||||
const lines = output.split("\n")
|
||||
|
||||
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
|
||||
lines.findIndex((line) => line.includes("▶")),
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes invalid participant gaps for text and live rendering", async () => {
|
||||
const content = "sequenceDiagram\n A->>B: hello"
|
||||
|
||||
expect(renderSequenceDiagram(content, { minParticipantGap: Number.NaN })).toContain("hello")
|
||||
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content,
|
||||
minParticipantGap: Number.NaN,
|
||||
})
|
||||
|
||||
expect(diagram.minParticipantGap).toBe(18)
|
||||
diagram.minParticipantGap = 0
|
||||
expect(diagram.minParticipantGap).toBe(1)
|
||||
diagram.minParticipantGap = 3.9
|
||||
expect(diagram.minParticipantGap).toBe(3)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("connects participant headers to lifelines", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("renders notes and long cross-participant messages in order", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
participant Store as Ticket store
|
||||
Note over Browser,Server: native browser Basic prompt
|
||||
Browser->>Server: POST connect-token
|
||||
Server->>Store: issue { ptyID, scope }
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder(
|
||||
"native browser Basic prompt",
|
||||
"POST connect-token",
|
||||
"issue { ptyID, scope }",
|
||||
)
|
||||
})
|
||||
|
||||
test("renders notes to the left and right of a participant", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant OpenTUI
|
||||
Note right of OpenTUI: child is not renderable
|
||||
Note left of OpenTUI: remove failed`)
|
||||
|
||||
expect(output).toContain("child is not renderable")
|
||||
expect(output).toContain("remove failed")
|
||||
})
|
||||
|
||||
test("preserves the parsed shape of notes over participants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
Note over A: hello`)
|
||||
|
||||
expect(diagram.steps).toContainEqual({ type: "note", note: { over: ["A"], label: "hello" } })
|
||||
})
|
||||
|
||||
test("parses activation shorthand and control blocks", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
alt accepted
|
||||
Server-->>-Browser: response
|
||||
else rejected
|
||||
activate Server
|
||||
Server-->>Browser: error
|
||||
deactivate Server
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Browser",
|
||||
to: "Server",
|
||||
label: "request",
|
||||
style: "solid",
|
||||
activate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "alt", label: "accepted" } },
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Server",
|
||||
to: "Browser",
|
||||
label: "response",
|
||||
style: "dashed",
|
||||
deactivate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "else", label: "rejected" } },
|
||||
{ type: "activation", activation: { participant: "Server", active: true } },
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Server", to: "Browser", label: "error", style: "dashed" },
|
||||
},
|
||||
{ type: "activation", activation: { participant: "Server", active: false } },
|
||||
{ type: "fragment", fragment: { kind: "end", label: "alt" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses activation syntax without rendering activation bars", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
Server-->>-Browser: response
|
||||
`)
|
||||
|
||||
expect(output).not.toContain("┃")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "A", to: "B", label: "open solid", style: "solid", head: "open" },
|
||||
{ from: "B", to: "A", label: "open dashed", style: "dashed", head: "open" },
|
||||
{ from: "A", to: "B", label: "failed solid", style: "solid", head: "cross" },
|
||||
{ from: "B", to: "A", label: "failed dashed", style: "dashed", head: "cross" },
|
||||
{ from: "A", to: "B", label: "async solid", style: "solid", head: "async" },
|
||||
{ from: "B", to: "A", label: "async dashed", style: "dashed", head: "async" },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders Mermaid arrow head variants", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders boxed alt else regions", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
alt accepted
|
||||
Browser->>Server: ok
|
||||
else rejected
|
||||
Server-->>Browser: no
|
||||
end
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder("╭─ alt: accepted", "ok", "├─ else: rejected", "no", "╰")
|
||||
expect(output).not.toContain("end alt")
|
||||
})
|
||||
|
||||
test("expands a fragment frame for a longer else label", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`)
|
||||
const lines = output.split("\n")
|
||||
const elseRow = lines.find((line) => line.includes("validation failed"))!
|
||||
const endRow = [...lines].reverse().find((line) => line.includes("╰"))!
|
||||
|
||||
expect(elseRow.lastIndexOf("┤")).toBe(endRow.lastIndexOf("╯"))
|
||||
})
|
||||
|
||||
test("preserves combined graphemes in participant names", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A as Cafe\u0301
|
||||
participant B
|
||||
A->>B: hi`)
|
||||
|
||||
expect(output).toContain("Cafe\u0301")
|
||||
})
|
||||
|
||||
test("renders fragment boxes with lifeline overhang", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
})
|
||||
|
||||
test("supports configurable fragment border styles", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else no
|
||||
B-->>A: no
|
||||
end
|
||||
`,
|
||||
{ fragmentBorderStyle: "double" },
|
||||
)
|
||||
|
||||
expect(output).toContain("╔")
|
||||
expect(output).toContain("╠")
|
||||
expect(output).toContain("╚")
|
||||
expect(output).toContain("═")
|
||||
expect(output).toContain("║")
|
||||
})
|
||||
|
||||
test("parses and renders autonumbered messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([1, 2])
|
||||
expect(output).toContain("1. request")
|
||||
expect(output).toContain("2. response")
|
||||
})
|
||||
|
||||
test("supports autonumber start and increment", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([10, 15])
|
||||
expect(output).toContain("10. first")
|
||||
expect(output).toContain("15. second")
|
||||
})
|
||||
|
||||
test("parses and renders loop regions", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps[0]).toEqual({
|
||||
type: "fragment",
|
||||
fragment: { kind: "loop", label: "retry up to 3x" },
|
||||
})
|
||||
expect(output).toContain("╭─ ↻ loop: retry up to 3x")
|
||||
expect(output).not.toContain("end loop")
|
||||
expect(output.indexOf("loop: retry up to 3x")).toBeLessThan(output.indexOf("GET /users/42"))
|
||||
})
|
||||
|
||||
test("parses Mermaid box participant groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
end
|
||||
box Purple Storage Layer
|
||||
participant DB
|
||||
end
|
||||
box "Purple Literal Label"
|
||||
participant Worker
|
||||
end
|
||||
Browser->>API: request
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([
|
||||
{ label: "Backend", participantIds: ["API", "Cache"] },
|
||||
{ label: "Storage Layer", participantIds: ["DB"] },
|
||||
{ label: "Purple Literal Label", participantIds: ["Worker"] },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Browser", to: "API", label: "request", style: "solid" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("adds implicit participants inside box groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant API
|
||||
box Backend
|
||||
API->>DB: query
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([{ label: "Backend", participantIds: ["API", "DB"] }])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "API", to: "DB", label: "query", style: "solid" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not clip long non-adjacent messages or notes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room
|
||||
`)
|
||||
|
||||
expect(output).toContain("this message needs room past the final participant")
|
||||
expect(output).toContain("this note also needs full horizontal room")
|
||||
})
|
||||
|
||||
test("keeps long content inside participant groups and fragment frames", () => {
|
||||
const group = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragment = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`)
|
||||
|
||||
const groupMessageRow = group.split("\n").find((line) => line.includes("this message text"))!
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
const groupedNote = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note over A,C: this note text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragmentNote = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
Note over A,C: this non adjacent note is deliberately much wider than the frame
|
||||
end`)
|
||||
const nested = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`)
|
||||
|
||||
expect(groupedNote).toContain("this note text runs far outside of the group container boundary")
|
||||
expect(fragmentNote).toContain("this non adjacent note is deliberately much wider than the frame")
|
||||
expect(nested).toContain("span ─╮│")
|
||||
expect(nested).toContain("──────╯│")
|
||||
})
|
||||
|
||||
test("does not draw external participants inside groups expanded by self messages", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`)
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
|
||||
test("renders full-height participant group boxes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
participant DB
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
API->>Cache: get user:42
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────▶ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("lets message lines pass through group borders without intersections", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
`)
|
||||
const arrowLine = output.split("\n").find((line) => line.includes("▶"))!
|
||||
|
||||
expect(arrowLine).toContain("───────────────▶")
|
||||
expect(arrowLine).not.toContain("┼")
|
||||
})
|
||||
|
||||
test("renders self messages as loopback arrows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◀────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("places two spacer rows above note badges and one below", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
Note over Browser,Server: phase
|
||||
Browser->>Server: two
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const noteRow = lines.findIndex((line) => line.includes("phase"))
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
test("renders br-delimited message labels across multiple rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
`)
|
||||
|
||||
expect(output).toContain("POST connect-token")
|
||||
expect(output).toContain("· Basic (cached by browser)")
|
||||
expect(output).toContain("· X-OpenCode-Ticket: 1")
|
||||
expect(output.indexOf("· X-OpenCode-Ticket: 1")).toBeLessThan(output.indexOf("├"))
|
||||
})
|
||||
|
||||
test("colors request and response messages differently", async () => {
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const responseColor = parseColor("#F59E0B")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Browser->>Server: request
|
||||
Server-->>Browser: response`,
|
||||
requestColor,
|
||||
responseColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const requestSpan = spans.find((span) => span.text.includes("request"))
|
||||
const responseSpan = spans.find((span) => span.text.includes("response"))
|
||||
|
||||
expect(requestSpan?.fg.equals(requestColor)).toBe(true)
|
||||
expect(responseSpan?.fg.equals(responseColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("repaints sequence colors after mounting without changing diagram text", async () => {
|
||||
const initialColor = parseColor("#38BDF8")
|
||||
const updatedColor = parseColor("#A78BFA")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: "sequenceDiagram\n Browser->>Server: request",
|
||||
requestColor: initialColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
const before = testRenderer.captureCharFrame()
|
||||
|
||||
diagram.batchUpdate(() => {
|
||||
diagram.requestColor = updatedColor
|
||||
diagram.lifelineColor = updatedColor
|
||||
})
|
||||
await testRenderer.renderOnce()
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(testRenderer.captureCharFrame()).toBe(before)
|
||||
expect(spans.find((span) => span.text.includes("request"))?.fg.equals(updatedColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tweens arrow departure colors away from lifelines over five cells", async () => {
|
||||
const lifelineColor = parseColor("#94A3B8")
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Browser->>Server: request`,
|
||||
lifelineColor,
|
||||
requestColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const arrowLine = testRenderer
|
||||
.captureSpans()
|
||||
.lines.find((line) => line.spans.some((span) => span.text.includes("▶")))
|
||||
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan).toBeDefined()
|
||||
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tweens self-message departure colors away from lifelines", async () => {
|
||||
const lifelineColor = parseColor("#94A3B8")
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Service->>Service: validate`,
|
||||
lifelineColor,
|
||||
requestColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const arrowLine = testRenderer
|
||||
.captureSpans()
|
||||
.lines.find((line) => line.spans.some((span) => span.text.includes("├")))
|
||||
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan).toBeDefined()
|
||||
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors headers, header rules, and note badges separately", async () => {
|
||||
const participantColor = parseColor("#E5E7EB")
|
||||
const lifelineColor = parseColor("#64748B")
|
||||
const noteColor = parseColor("#A78BFA")
|
||||
const noteBackgroundColor = parseColor("#312E81")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
Note over Browser,Server: native browser Basic prompt`,
|
||||
participantColor,
|
||||
lifelineColor,
|
||||
noteColor,
|
||||
noteBackgroundColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const participantSpan = spans.find((span) => span.text.includes("Browser"))
|
||||
const headerRuleSpan = spans.find((span) => span.text.includes("┬"))
|
||||
const noteSpan = spans.find((span) => span.text.includes("native browser Basic prompt"))
|
||||
|
||||
expect(participantSpan?.fg.equals(participantColor)).toBe(true)
|
||||
expect(headerRuleSpan?.fg.equals(lifelineColor)).toBe(true)
|
||||
expect(noteSpan?.fg.equals(noteColor)).toBe(true)
|
||||
expect(noteSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors group boxes separately from fragments", async () => {
|
||||
const groupColor = parseColor("#8BA394")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
alt ok
|
||||
API->>API: validate
|
||||
end`,
|
||||
groupColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const groupSpan = spans.find((span) => span.text.includes("Backend"))
|
||||
const fragmentSpan = spans.find((span) => span.text.includes("alt: ok"))
|
||||
|
||||
expect(groupSpan?.fg.equals(groupColor)).toBe(true)
|
||||
expect(fragmentSpan?.fg.equals(groupColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("adds a background to fragment labels", async () => {
|
||||
const noteBackgroundColor = parseColor("#24382F")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
alt ok
|
||||
Browser->>Server: request
|
||||
else no
|
||||
Server-->>Browser: response
|
||||
end`,
|
||||
noteBackgroundColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const altSpan = spans.find((span) => span.text.includes("alt: ok"))
|
||||
const elseSpan = spans.find((span) => span.text.includes("else: no"))
|
||||
|
||||
expect(altSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
expect(elseSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("prints ANSI colors for terminal dumps", () => {
|
||||
const output = renderSequenceDiagramAnsi(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: request
|
||||
Server-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(output).toContain("\x1b[38;2;134;225;200m")
|
||||
expect(output).toContain("\x1b[38;2;230;177;126m")
|
||||
expect(output).toContain("\x1b[38;2;115;153;138m")
|
||||
expect(output).toContain("\x1b[38;2;130;211;188m")
|
||||
expect(output).toContain("\x1b[38;2;131;145;126m")
|
||||
expect(output).toContain("\x1b[38;2;210;171;126m")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
expect(output).toContain("◀")
|
||||
expect(output).toContain("┤")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { renderSequenceGridAnsi, renderSequenceGridText } from "./render-grid.js"
|
||||
import type { SequenceDiagramAnsiOptions, SequenceDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderSequenceDiagram(content: string, options: SequenceDiagramRenderOptions = {}): string {
|
||||
return renderSequenceGridText(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options))
|
||||
}
|
||||
|
||||
export function renderSequenceDiagramAnsi(content: string, options: SequenceDiagramAnsiOptions = {}): string {
|
||||
return renderSequenceGridAnsi(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options), options.theme)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user