mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 19:49:48 -04:00
Compare commits
2 Commits
v2
..
git-head-watch
| Author | SHA1 | Date | |
|---|---|---|---|
| 868a303b31 | |||
| e5b576ce3a |
@@ -1,4 +1,3 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
@@ -42,9 +41,9 @@ export default migration
|
||||
|
||||
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
|
||||
if (content === undefined) return
|
||||
const input = Option.getOrUndefined(decodeJson(content))
|
||||
const file = Bun.file(filepath)
|
||||
if (!(yield* Effect.promise(() => file.exists()))) return
|
||||
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
export * as LocationWatcher from "./location-watcher"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { Watcher } from "./watcher"
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const configService = yield* Config.Service
|
||||
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
|
||||
bus.publish(FileSystem.Event.Changed, {
|
||||
file: update.path,
|
||||
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
if (location.vcs?.type === "hg") {
|
||||
const store = location.vcs.store
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
|
||||
if (!config.includes(".hg") && !config.includes(vcs)) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
|
||||
})
|
||||
@@ -15,7 +15,6 @@ import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Image } from "./image"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
@@ -99,8 +98,6 @@ const locationServiceNodes = [
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
|
||||
|
||||
+28
-20
@@ -3,7 +3,6 @@ export * as Vcs from "./vcs"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -11,6 +10,8 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "./bus"
|
||||
import { Git } from "./git"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
@@ -44,29 +45,36 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const git = yield* Git.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const impl = adapter(proc, fs, location)
|
||||
const vcs = location.vcs
|
||||
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
|
||||
|
||||
if (vcs && impl) {
|
||||
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
|
||||
const isBranchMetadata =
|
||||
vcs.type === "git"
|
||||
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
|
||||
: (file: string) => path.resolve(file) === path.join(store, "branch")
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter((event) => isBranchMetadata(event.data.file)),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* impl.info()
|
||||
const changed = state.info.branch.current !== next.branch.current
|
||||
state.info = next
|
||||
if (!changed) return
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const discovered = vcs.type === "git" ? (yield* git.repo.discover(location.directory))?.gitDirectory : undefined
|
||||
const target = discovered ?? vcs.store
|
||||
const dir = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
|
||||
const keep = vcs.type === "git" ? ["HEAD", "HEAD.lock"] : ["branch"]
|
||||
const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (keep.includes(entry.name) ? [] : [entry.name]),
|
||||
)
|
||||
const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore })
|
||||
yield* updates.pipe(
|
||||
Stream.filter((update) => keep.includes(path.basename(update.path))),
|
||||
Stream.runForEach((update) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* impl.info()
|
||||
const changed = state.info.branch.current !== next.branch.current
|
||||
state.info = next
|
||||
if (!changed) return
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: update.path } })),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to watch vcs metadata", { cause })))
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
@@ -88,5 +96,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
|
||||
})
|
||||
|
||||
@@ -164,20 +164,6 @@ describe("DatabaseMigration", () => {
|
||||
expect(await Bun.file(source).text()).toBe(content)
|
||||
})
|
||||
|
||||
test("skips legacy credential import when the source file is absent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* db.transaction((tx) => importLegacyCredentials(tx, path.join(tmp.path, "missing-auth.json")))
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rolls back a failed migration without recording it", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
|
||||
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 { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
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])))
|
||||
|
||||
const configLayer = Config.testLayer()
|
||||
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
|
||||
|
||||
describe("Watcher.testLayer", () => {
|
||||
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
|
||||
@@ -40,7 +27,6 @@ describe("Watcher.testLayer", () => {
|
||||
yield* test.emit({ type: "update", path: "/root/file.md" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
|
||||
// subscriptions() reports acquired watches, so paths come back resolved.
|
||||
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
|
||||
}).pipe(Effect.provide(Watcher.testLayer)),
|
||||
)
|
||||
@@ -126,167 +112,20 @@ describe("Watcher lifecycle", () => {
|
||||
expect(counts.unsubscribes).toBe(0)
|
||||
return consumer
|
||||
}).pipe(withNative(native))
|
||||
// Closing the layer scope tears the native subscription down while the
|
||||
// consumer still holds a reference; the consumer's own release as its
|
||||
// stream ends must not tear it down a second time.
|
||||
yield* Fiber.join(consumer)
|
||||
expect(counts.unsubscribes).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 })),
|
||||
)
|
||||
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>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
},
|
||||
) {
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const tmp = await tmpdir()
|
||||
if (options?.vcs === "hg") {
|
||||
await fs.mkdir(path.join(tmp.path, ".hg"))
|
||||
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
|
||||
}
|
||||
if (options?.vcs !== "git") return { tmp, vcs: undefined }
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
|
||||
await $`git config user.name Test`.cwd(tmp.path).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
|
||||
await options.init?.(tmp.path)
|
||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||
}),
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
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
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
return { deferred, fiber }
|
||||
})
|
||||
}
|
||||
|
||||
function maybeNextUpdate<E>(
|
||||
check: (event: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
timeout: Duration.Input = "5 seconds",
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(check),
|
||||
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}
|
||||
|
||||
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* maybeNextUpdate(check, trigger)
|
||||
if (Option.isSome(result)) return result.value
|
||||
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
||||
})
|
||||
}
|
||||
|
||||
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
while (true) {
|
||||
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
|
||||
if (Option.isSome(result)) return result.value
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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 === eventFile,
|
||||
() => fs.writeFileString(file, content),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
describeNative("LocationWatcher", () => {
|
||||
describeNative("Watcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -333,68 +172,4 @@ describeNative("LocationWatcher", () => {
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes .git/HEAD events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* ready(head)
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toEqual({ file: head, event: "change" })
|
||||
}),
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
|
||||
describeSymlink("symlinked .git", () => {
|
||||
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
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 })))
|
||||
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(
|
||||
yield* nextUpdate(
|
||||
(event) => event.file === path.join(actual, "HEAD"),
|
||||
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
||||
),
|
||||
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
|
||||
}),
|
||||
{
|
||||
vcs: "git",
|
||||
init: async (directory) => {
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
await fs.rename(path.join(directory, ".git"), actual)
|
||||
await fs.symlink(actual, path.join(directory, ".git"))
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("publishes .hg/branch events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
}),
|
||||
{ vcs: "hg" },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -42,7 +41,9 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
|
||||
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
withTmp((directory) =>
|
||||
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
|
||||
Effect.promise(() => hg(directory, "init")).pipe(
|
||||
Effect.andThen(f(directory).pipe(provide(directory))),
|
||||
),
|
||||
)
|
||||
|
||||
async function hg(directory: string, ...args: string[]) {
|
||||
@@ -124,13 +125,7 @@ describeHg("Vcs mercurial", () => {
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, {
|
||||
file: path.join(directory, ".hg", "branch"),
|
||||
event: "change",
|
||||
})
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
|
||||
+128
-40
@@ -8,27 +8,66 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
|
||||
const locationLayer = (directory: string, git?: boolean) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [[Location.node, locationLayer(directory, input.git)]]),
|
||||
)
|
||||
|
||||
function fakeWatcher() {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const active = new Set<(update: Watcher.Update) => void>()
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
subscriptions.push(
|
||||
input.type === "file"
|
||||
? { path: input.target, type: "file" }
|
||||
: input.ignore.length > 0
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
)
|
||||
active.add(input.publish)
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
active.delete(input.publish)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
return {
|
||||
subscriptions: () => [...subscriptions],
|
||||
emit: (update: Watcher.Update) => {
|
||||
for (const publish of active) publish(update)
|
||||
},
|
||||
layer: Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))),
|
||||
}
|
||||
}
|
||||
|
||||
const provideFake = (directory: string, fake: ReturnType<typeof fakeWatcher>, git = true) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Location.node, locationLayer(directory, git)],
|
||||
[Watcher.node, fake.layer],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -93,35 +132,84 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes HEAD changes", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
}),
|
||||
),
|
||||
it.live("watches git branch metadata", () =>
|
||||
withTmp((directory) => {
|
||||
const fake = fakeWatcher()
|
||||
return Effect.promise(() => initRepo(directory)).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
yield* Vcs.Service
|
||||
expect(fake.subscriptions()).toHaveLength(1)
|
||||
const git = fake.subscriptions()[0]
|
||||
if (git?.type !== "directory") throw new Error("expected a directory watch")
|
||||
expect(git.path).toBe(path.join(directory, ".git"))
|
||||
expect(git.ignore ?? []).not.toContain("HEAD")
|
||||
expect(git.ignore ?? []).toContain("objects")
|
||||
}).pipe(provideFake(directory, fake)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes HEAD changes", () =>
|
||||
withTmp((directory) => {
|
||||
const fake = fakeWatcher()
|
||||
return Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
|
||||
|
||||
fake.emit({ type: "update", path: path.join(directory, ".git", "HEAD.lock") })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
|
||||
}).pipe(provideFake(directory, fake)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
describeNative("native watches", () => {
|
||||
it.live("publishes branch updates on git checkout", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
|
||||
}),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
})
|
||||
|
||||
it.live("diffs the working copy against HEAD with patches", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -44,10 +44,6 @@ export interface MermaidMarkdownRendererOptions {
|
||||
muted?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
request?: ColorInput
|
||||
response?: ColorInput
|
||||
note?: ColorInput
|
||||
noteBackground?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,12 +131,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
participant: color(colors.primary),
|
||||
lifeline: color(colors.muted),
|
||||
group: color(colors.secondary),
|
||||
request: color(colors.request ?? colors.primary),
|
||||
response: color(colors.response ?? colors.primary),
|
||||
request: color(colors.primary),
|
||||
response: color(colors.primary),
|
||||
fragment: color(colors.secondary),
|
||||
fragmentLabelBg: color(colors.background),
|
||||
note: color(colors.note ?? colors.warning),
|
||||
noteBg: color(colors.noteBackground ?? colors.background),
|
||||
note: color(colors.warning),
|
||||
noteBg: color(colors.background),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor } from "./core/color/style.js"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
@@ -32,15 +31,11 @@ describe("OpenCode diagram palette", () => {
|
||||
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
|
||||
const primary = rgb(text)
|
||||
const info = RGBA.fromInts(40, 120, 220)
|
||||
const success = RGBA.fromInts(80, 180, 120)
|
||||
const warning = RGBA.fromInts(220, 160, 80)
|
||||
const background = RGBA.fromInts(10, 20, 30)
|
||||
const palette = createOpenCodeDiagramPalette({
|
||||
text: primary,
|
||||
subdued: rgb(subdued),
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
background,
|
||||
})
|
||||
|
||||
@@ -50,9 +45,5 @@ describe("OpenCode diagram palette", () => {
|
||||
expect(palette.muted.equals(rgb(muted))).toBe(true)
|
||||
expect(palette.warning).toBe(info)
|
||||
expect(palette.background).toBe(background)
|
||||
expect(palette.request).toBe(success)
|
||||
expect(palette.response).toBe(warning)
|
||||
expect(palette.note).toBe(primary)
|
||||
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ export interface OpenCodeDiagramPaletteInput {
|
||||
readonly text: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly info: RGBA
|
||||
readonly success: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly background: RGBA
|
||||
}
|
||||
|
||||
@@ -18,9 +16,5 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
|
||||
muted: blendColor(input.text, input.subdued, 0.7),
|
||||
warning: input.info,
|
||||
background: input.background,
|
||||
request: input.success,
|
||||
response: input.warning,
|
||||
note: input.text,
|
||||
noteBackground: blendColor(input.background, input.subdued, 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ export default Plugin.define({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
info: context.theme.text.feedback.info.default,
|
||||
success: context.theme.text.feedback.success.default,
|
||||
warning: context.theme.text.feedback.warning.default,
|
||||
background: context.theme.background.default,
|
||||
}),
|
||||
})),
|
||||
|
||||
@@ -51,15 +51,16 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
Browser Server
|
||||
───┬─── ───┬──
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -139,13 +140,13 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[0]!.indexOf("w")
|
||||
const serverCenter = lines[0]!.indexOf("v")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[1]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[2]?.[browserCenter]).toBe("│")
|
||||
expect(lines[1]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[2]?.[serverCenter]).toBe("│")
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("ramps participant frames into neutral lifelines", () => {
|
||||
@@ -276,8 +277,8 @@ sequenceDiagram
|
||||
A->>B: hello`)
|
||||
|
||||
expect(output).not.toContain("<br")
|
||||
expect(output).toContain("First line")
|
||||
expect(output).toContain("Second line")
|
||||
expect(output).toContain("│ First line │")
|
||||
expect(output).toContain("│ Second line │")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
@@ -313,27 +314,28 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" A B
|
||||
─┬─ ─┬─
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -385,7 +387,7 @@ sequenceDiagram
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
@@ -555,7 +557,7 @@ sequenceDiagram
|
||||
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)
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
@@ -598,7 +600,7 @@ sequenceDiagram
|
||||
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("─")
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
@@ -653,17 +655,18 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ───────────────────────────────╮
|
||||
Browser │ API Cache DB │
|
||||
───┬─── │ ─┬─ ──┬── ─┬─ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────╯"
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -703,17 +706,18 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"Service
|
||||
───┬───
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders note badges in their reserved rows", () => {
|
||||
test("frames notes in their reserved rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
@@ -725,9 +729,11 @@ sequenceDiagram
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow]).toContain(" phase ")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 1]).toContain("╭")
|
||||
expect(lines[noteRow - 1]).toContain("╮")
|
||||
expect(lines[noteRow]).toContain("│ phase │")
|
||||
expect(lines[noteRow + 1]).toContain("╰")
|
||||
expect(lines[noteRow + 1]).toContain("╯")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -191,9 +191,29 @@ function renderSelfMessage(
|
||||
}
|
||||
|
||||
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
|
||||
placement.textLines.forEach((line, index) =>
|
||||
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
|
||||
)
|
||||
const width = Math.max(...placement.textLines.map(diagramTextWidth))
|
||||
const left = placement.textX
|
||||
const right = left + width - 1
|
||||
const top = placement.textY - 1
|
||||
const bottom = placement.textY + placement.textLines.length
|
||||
|
||||
for (let x = left + 1; x < right; x++) {
|
||||
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
|
||||
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
|
||||
}
|
||||
for (let y = top + 1; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
|
||||
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
|
||||
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
|
||||
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
|
||||
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
|
||||
for (let y = placement.textY; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
@@ -216,12 +236,22 @@ export function drawSequenceDiagramGrid(
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
} else {
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
|
||||
)
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
|
||||
for (let y = participantHeaderY; y < participantRuleY; y++) {
|
||||
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
}
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ ${Array.from(
|
||||
expect(explicit.activations).toEqual(shorthand.activations)
|
||||
})
|
||||
|
||||
test("left-aligns message label blocks inside their arrow span", () => {
|
||||
test("centers message label blocks over their arrow span", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
@@ -214,6 +214,8 @@ ${Array.from(
|
||||
A->>B: short<br/>a much longer line`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
expect(message.labelX).toBe(message.leftX + 2)
|
||||
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
|
||||
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
const width = labelLinesWidth(mermaidLabelLines(label))
|
||||
return compact ? width : Math.max(3, width)
|
||||
return compact ? width : Math.max(5, width + 4)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
@@ -247,7 +247,7 @@ function getStepContentBounds(
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const labelWidth = messageWidth(step.message)
|
||||
const labelLeftX = leftX + 2
|
||||
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
|
||||
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
@@ -525,8 +525,8 @@ export function createSequencePlacementPlan(
|
||||
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
|
||||
)
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
@@ -650,7 +650,7 @@ export function createSequencePlacementPlan(
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
|
||||
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
|
||||
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
|
||||
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { defineScript, Effect, Llm } from "opencode-drive"
|
||||
|
||||
const theme = Bun.env.DRIVE_THEME ?? "opencode"
|
||||
const output = Bun.env.DRIVE_SCREENSHOT ?? `artifacts/mermaid-${theme}.png`
|
||||
const animate = Bun.env.DRIVE_ANIMATE === "1"
|
||||
const cycleThemes = Bun.env.DRIVE_CYCLE_THEMES === "1"
|
||||
|
||||
const response = `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
participant T as Ticket store
|
||||
participant P as PTY
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
Note over B,S: native browser Basic prompt
|
||||
B->>S: GET / · Basic
|
||||
S-->>B: 200 web UI
|
||||
Note over B,S: user opens terminal
|
||||
B->>S: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
S->>T: issue { ptyID, … }
|
||||
S-->>B: { ticket }
|
||||
B->>S: WS …?ticket=…<br/>Upgrade: websocket
|
||||
S->>T: consume(token,scope)
|
||||
T-->>S: ok, delete
|
||||
S->>P: attach
|
||||
P-->>B: WS frames
|
||||
\`\`\``
|
||||
|
||||
export default defineScript({
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tuiConfig: {
|
||||
theme: {
|
||||
name: theme,
|
||||
mode: "dark",
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
viewport: { cols: 180, rows: 64 },
|
||||
},
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Show the connection flow as a Mermaid sequence diagram")
|
||||
yield* llm.send(
|
||||
Llm.text(response, animate ? { delay: 80, chunkSize: 20 } : { delay: 0, chunkSize: response.length }),
|
||||
)
|
||||
yield* ui.waitFor("WS frames", { timeout: 10_000 })
|
||||
if (cycleThemes) {
|
||||
yield* Effect.sleep(800)
|
||||
yield* Effect.forEach(
|
||||
["everforest", "synthwave84", "matrix", "opencode"],
|
||||
(next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.press("x", { ctrl: true })
|
||||
yield* ui.press("t")
|
||||
yield* ui.waitFor("Themes")
|
||||
yield* ui.type(next)
|
||||
yield* Effect.sleep(700)
|
||||
yield* ui.enter()
|
||||
yield* Effect.sleep(1_200)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
const screenshot = yield* ui.screenshot(`mermaid-${theme}`)
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir("artifacts", { recursive: true })
|
||||
await Bun.write(output, Bun.file(screenshot))
|
||||
})
|
||||
yield* Effect.log(`Saved ${output}`)
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user