mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6639cb5ad | |||
| b300116d0a | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 |
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
|
|||||||
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -134,8 +134,6 @@ export interface Interface {
|
|||||||
readonly after?: number
|
readonly after?: number
|
||||||
readonly follow?: boolean
|
readonly follow?: boolean
|
||||||
}) => Stream.Stream<LogItem>
|
}) => Stream.Stream<LogItem>
|
||||||
/** Latest committed seq per aggregate. Aggregates without events are absent. */
|
|
||||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
|
|
||||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||||
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||||
@@ -657,19 +655,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
|
|
||||||
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
|
|
||||||
return db
|
|
||||||
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
|
|
||||||
.all()
|
|
||||||
.pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
listeners.push(listener)
|
listeners.push(listener)
|
||||||
@@ -691,7 +676,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
publish,
|
publish,
|
||||||
subscribe,
|
subscribe,
|
||||||
log,
|
log,
|
||||||
sequences,
|
|
||||||
listen,
|
listen,
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as Formatter from "./formatter"
|
export * as Formatter from "./formatter"
|
||||||
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
@@ -11,16 +11,7 @@ import { Config } from "./config"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { make, type Info } from "./formatter/builtins"
|
import { make, type Info } from "./formatter/builtins"
|
||||||
|
|
||||||
export const Status = Schema.Struct({
|
|
||||||
name: Schema.String,
|
|
||||||
extensions: Schema.Array(Schema.String),
|
|
||||||
enabled: Schema.Boolean,
|
|
||||||
}).annotate({ identifier: "FormatterStatus" })
|
|
||||||
export type Status = typeof Status.Type
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
|
||||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,23 +75,6 @@ const layer = Layer.effect(
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("Formatter.init")(function* () {
|
|
||||||
yield* load
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Formatter.status")(function* () {
|
|
||||||
yield* load
|
|
||||||
return yield* Effect.forEach(formatters, (formatter) =>
|
|
||||||
command(formatter).pipe(
|
|
||||||
Effect.map((enabled) => ({
|
|
||||||
name: formatter.name,
|
|
||||||
extensions: [...formatter.extensions],
|
|
||||||
enabled: enabled !== false,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||||
yield* load
|
yield* load
|
||||||
const matching = formatters.filter((formatter) =>
|
const matching = formatters.filter((formatter) =>
|
||||||
@@ -143,7 +117,7 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ init, status, file })
|
return Service.of({ file })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-224
@@ -1,8 +1,7 @@
|
|||||||
export * as Git from "./git"
|
export * as Git from "./git"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { randomUUID } from "crypto"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { AbsolutePath, RelativePath } from "./schema"
|
import { AbsolutePath, RelativePath } from "./schema"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
@@ -36,9 +35,6 @@ const snapshotConfig = `[core]
|
|||||||
threads = true
|
threads = true
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
|
||||||
export type ChangeSet = typeof ChangeSet.Type
|
|
||||||
|
|
||||||
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
||||||
export type TreeID = typeof TreeID.Type
|
export type TreeID = typeof TreeID.Type
|
||||||
|
|
||||||
@@ -73,13 +69,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
|||||||
cause: Schema.optional(Schema.Defect()),
|
cause: Schema.optional(Schema.Defect()),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
|
|
||||||
operation: Schema.Literals(["capture", "apply", "reset"]),
|
|
||||||
directory: AbsolutePath,
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect()),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly repo: {
|
readonly repo: {
|
||||||
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
||||||
@@ -116,20 +105,6 @@ export interface Interface {
|
|||||||
) => Effect.Effect<void, OperationError>
|
) => Effect.Effect<void, OperationError>
|
||||||
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
||||||
}
|
}
|
||||||
readonly change: {
|
|
||||||
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
|
|
||||||
readonly apply: (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
changes: ChangeSet
|
|
||||||
}) => Effect.Effect<void, PatchError>
|
|
||||||
readonly discard: (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
index: "preserve" | "reset"
|
|
||||||
untracked: "preserve" | "remove"
|
|
||||||
}) => Effect.Effect<void, PatchError>
|
|
||||||
}
|
|
||||||
readonly worktree: {
|
readonly worktree: {
|
||||||
readonly create: (input: {
|
readonly create: (input: {
|
||||||
repository: Repository
|
repository: Repository
|
||||||
@@ -175,17 +150,10 @@ export interface Interface {
|
|||||||
context?: number
|
context?: number
|
||||||
paths?: readonly RelativePath[]
|
paths?: readonly RelativePath[]
|
||||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||||
readonly preview: (input: {
|
|
||||||
repository: Repository
|
|
||||||
current: TreeID
|
|
||||||
files: ReadonlyMap<RelativePath, TreeID>
|
|
||||||
context?: number
|
|
||||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
|
||||||
readonly restore: (input: {
|
readonly restore: (input: {
|
||||||
repository: Repository
|
repository: Repository
|
||||||
files: ReadonlyMap<RelativePath, TreeID>
|
files: ReadonlyMap<RelativePath, TreeID>
|
||||||
}) => Effect.Effect<void, OperationError>
|
}) => Effect.Effect<void, OperationError>
|
||||||
readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect<void, OperationError>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,58 +625,6 @@ const layer = Layer.effect(
|
|||||||
return { mode: match[1], object: match[2] }
|
return { mode: match[1], object: match[2] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = Effect.fn("Git.tree.preview")(
|
|
||||||
(input: {
|
|
||||||
repository: Repository
|
|
||||||
current: TreeID
|
|
||||||
files: ReadonlyMap<RelativePath, TreeID>
|
|
||||||
context?: number
|
|
||||||
}) =>
|
|
||||||
locked(
|
|
||||||
input.repository,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
|
|
||||||
const env = { GIT_INDEX_FILE: index }
|
|
||||||
return yield* Effect.gen(function* () {
|
|
||||||
yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
|
|
||||||
yield* Effect.forEach(
|
|
||||||
input.files,
|
|
||||||
([file, tree]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = yield* entry(input.repository, tree, file)
|
|
||||||
if (!source) {
|
|
||||||
yield* repositoryOperation(
|
|
||||||
"diff",
|
|
||||||
input.repository,
|
|
||||||
["update-index", "--force-remove", "--", file],
|
|
||||||
{ env },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* repositoryOperation(
|
|
||||||
"diff",
|
|
||||||
input.repository,
|
|
||||||
["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
|
|
||||||
{ env },
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
const target = TreeID.make(
|
|
||||||
(yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
|
|
||||||
)
|
|
||||||
return yield* treeDiff({
|
|
||||||
repository: input.repository,
|
|
||||||
from: input.current,
|
|
||||||
to: target,
|
|
||||||
context: input.context,
|
|
||||||
paths: Array.from(input.files.keys()),
|
|
||||||
})
|
|
||||||
}).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const restore = Effect.fn("Git.tree.restore")(
|
const restore = Effect.fn("Git.tree.restore")(
|
||||||
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
||||||
locked(
|
locked(
|
||||||
@@ -738,142 +654,6 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) =>
|
|
||||||
locked(
|
|
||||||
input.repository,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree])
|
|
||||||
yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
|
|
||||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
|
||||||
const tracked = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (tracked.exitCode !== 0) {
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "capture",
|
|
||||||
directory: input.path,
|
|
||||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const untracked = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (untracked.exitCode !== 0) {
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "capture",
|
|
||||||
directory: input.path,
|
|
||||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
|
||||||
execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
Effect.flatMap((result) =>
|
|
||||||
// git diff --no-index returns 1 when differences were found.
|
|
||||||
result.exitCode === 0 || result.exitCode === 1
|
|
||||||
? Effect.succeed(result.text)
|
|
||||||
: Effect.fail(
|
|
||||||
new PatchError({
|
|
||||||
operation: "capture",
|
|
||||||
directory: input.path,
|
|
||||||
message:
|
|
||||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const apply = Effect.fn("Git.change.apply")(function* (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
changes: ChangeSet
|
|
||||||
}) {
|
|
||||||
const result = yield* proc
|
|
||||||
.run(
|
|
||||||
ChildProcess.make("git", ["apply", "-"], {
|
|
||||||
cwd: input.path,
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: Stream.make(new TextEncoder().encode(input.changes)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (result.exitCode === 0) return
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "apply",
|
|
||||||
directory: input.path,
|
|
||||||
message:
|
|
||||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const discard = Effect.fn("Git.change.discard")(function* (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
index: "preserve" | "reset"
|
|
||||||
untracked: "preserve" | "remove"
|
|
||||||
}) {
|
|
||||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
|
||||||
const restore = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (restore.exitCode !== 0) {
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "reset",
|
|
||||||
directory: input.path,
|
|
||||||
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (input.untracked === "preserve") return
|
|
||||||
const clean = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["clean", "-fd", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (clean.exitCode === 0) return
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "reset",
|
|
||||||
directory: input.path,
|
|
||||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const worktreeRun = Effect.fnUntraced(function* (
|
const worktreeRun = Effect.fnUntraced(function* (
|
||||||
operation: "create" | "remove" | "list",
|
operation: "create" | "remove" | "list",
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
@@ -949,7 +729,6 @@ const layer = Layer.effect(
|
|||||||
remote: { get: remote },
|
remote: { get: remote },
|
||||||
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
||||||
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
||||||
change: { capture, apply, discard },
|
|
||||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||||
index: { refresh, ignored },
|
index: { refresh, ignored },
|
||||||
tree: {
|
tree: {
|
||||||
@@ -957,9 +736,7 @@ const layer = Layer.effect(
|
|||||||
write: writeTree,
|
write: writeTree,
|
||||||
files: treeFiles,
|
files: treeFiles,
|
||||||
diff: treeDiff,
|
diff: treeDiff,
|
||||||
preview,
|
|
||||||
restore,
|
restore,
|
||||||
checkout: checkoutTree,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
export * as ShellSelect from "./select"
|
export * as ShellSelect from "./select"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { spawn, type ChildProcess } from "child_process"
|
|
||||||
import { readFile } from "fs/promises"
|
import { readFile } from "fs/promises"
|
||||||
import { statSync } from "fs"
|
import { statSync } from "fs"
|
||||||
import { setTimeout } from "node:timers/promises"
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { which } from "../util/which"
|
import { which } from "../util/which"
|
||||||
|
|
||||||
const SIGKILL_TIMEOUT_MS = 200
|
|
||||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||||
bash: { login: true, posix: true },
|
bash: { login: true, posix: true },
|
||||||
dash: { login: true, posix: true },
|
dash: { login: true, posix: true },
|
||||||
@@ -33,37 +30,6 @@ export const Options = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type Options = typeof Options.Type
|
export type Options = typeof Options.Type
|
||||||
|
|
||||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
|
||||||
const pid = proc.pid
|
|
||||||
if (!pid || opts?.exited?.()) return
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
|
||||||
stdio: "ignore",
|
|
||||||
windowsHide: true,
|
|
||||||
})
|
|
||||||
killer.once("exit", () => resolve())
|
|
||||||
killer.once("error", () => resolve())
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGTERM")
|
|
||||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
|
||||||
if (!opts?.exited?.()) {
|
|
||||||
process.kill(-pid, "SIGKILL")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
|
||||||
if (!opts?.exited?.()) {
|
|
||||||
proc.kill("SIGKILL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stat(file: string) {
|
function stat(file: string) {
|
||||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Hash } from "@opencode-ai/util/hash"
|
|||||||
export { ID }
|
export { ID }
|
||||||
|
|
||||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
operation: Schema.Literals(["capture", "files", "diff", "restore"]),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
cause: Schema.optional(Schema.Defect()),
|
cause: Schema.optional(Schema.Defect()),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -36,10 +36,6 @@ export interface RestoreInput {
|
|||||||
readonly files: ReadonlyMap<RelativePath, ID>
|
readonly files: ReadonlyMap<RelativePath, ID>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreviewInput extends RestoreInput {
|
|
||||||
readonly context?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/**
|
/**
|
||||||
* Capture the current Location-scoped filesystem state as a content-addressed
|
* Capture the current Location-scoped filesystem state as a content-addressed
|
||||||
@@ -60,25 +56,11 @@ export interface Interface {
|
|||||||
*/
|
*/
|
||||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview the filesystem result of a selective restore without modifying the
|
|
||||||
* worktree. Each project-relative path maps to the tree it would be restored
|
|
||||||
* from.
|
|
||||||
*/
|
|
||||||
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore selected project-relative paths from their associated trees. A path
|
* Restore selected project-relative paths from their associated trees. A path
|
||||||
* absent from its selected tree is removed; paths outside the map are untouched.
|
* absent from its selected tree is removed; paths outside the map are untouched.
|
||||||
*/
|
*/
|
||||||
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the snapshot index with a captured tree and check out all its entries.
|
|
||||||
* Files absent from the tree remain untouched. Prefer selective `restore` when
|
|
||||||
* only known paths should change.
|
|
||||||
*/
|
|
||||||
readonly checkout: (snapshot: ID) => Effect.Effect<void, Error>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||||
@@ -176,59 +158,26 @@ const layer = Layer.effect(
|
|||||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const plan = Effect.fnUntraced(function* (
|
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||||
operation: "preview" | "restore",
|
|
||||||
worktree: AbsolutePath,
|
|
||||||
input: RestoreInput,
|
|
||||||
) {
|
|
||||||
const files = new Map<RelativePath, Git.TreeID>()
|
const files = new Map<RelativePath, Git.TreeID>()
|
||||||
for (const [file, snapshot] of input.files) {
|
for (const [file, snapshot] of input.files) {
|
||||||
const absolute = path.resolve(worktree, file)
|
const absolute = path.resolve(worktree, file)
|
||||||
if (!FSUtil.contains(worktree, absolute))
|
if (!FSUtil.contains(worktree, absolute))
|
||||||
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
|
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
|
||||||
files.set(file, Git.TreeID.make(snapshot))
|
files.set(file, Git.TreeID.make(snapshot))
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
|
||||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
|
||||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
|
||||||
const files = yield* plan("preview", repo.worktree, input)
|
|
||||||
const current = yield* git.tree
|
|
||||||
.capture({
|
|
||||||
repository: repo.snapshotRepository,
|
|
||||||
scopes: Array.from(files.keys()),
|
|
||||||
ignores: repo.source,
|
|
||||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
|
||||||
})
|
|
||||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
|
||||||
return yield* git.tree
|
|
||||||
.preview({
|
|
||||||
repository: repo.snapshotRepository,
|
|
||||||
current,
|
|
||||||
files,
|
|
||||||
context: input.context,
|
|
||||||
})
|
|
||||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||||
yield* git.tree
|
yield* git.tree
|
||||||
.restore({ repository: repo.snapshotRepository, files: yield* plan("restore", repo.worktree, input) })
|
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
|
||||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
|
return Service.of({ capture, files, diff, restore })
|
||||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
|
||||||
yield* git.tree
|
|
||||||
.checkout({ repository: repo.snapshotRepository, tree: Git.TreeID.make(snapshot) })
|
|
||||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ capture, files, diff, preview, restore, checkout })
|
|
||||||
}).pipe(Effect.withSpan("Snapshot.boot")),
|
}).pipe(Effect.withSpan("Snapshot.boot")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,9 +193,7 @@ export const noopLayer = Layer.succeed(
|
|||||||
capture: () => Effect.succeed(undefined),
|
capture: () => Effect.succeed(undefined),
|
||||||
files: () => Effect.succeed([]),
|
files: () => Effect.succeed([]),
|
||||||
diff: () => Effect.succeed([]),
|
diff: () => Effect.succeed([]),
|
||||||
preview: () => Effect.succeed([]),
|
|
||||||
restore: () => Effect.void,
|
restore: () => Effect.void,
|
||||||
checkout: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1298,24 +1298,4 @@ describe("Bus", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const first = Session.ID.create()
|
|
||||||
const second = Session.ID.create()
|
|
||||||
yield* bus.publish(DurableMessage, durableData(first, "zero"))
|
|
||||||
yield* bus.publish(DurableMessage, durableData(first, "one"))
|
|
||||||
yield* bus.publish(DurableMessage, durableData(second, "zero"))
|
|
||||||
|
|
||||||
const sequences = yield* bus.sequences([first, second, Session.ID.create()])
|
|
||||||
|
|
||||||
expect(sequences).toEqual(
|
|
||||||
new Map([
|
|
||||||
[first, Event.Seq.make(1)],
|
|
||||||
[second, Event.Seq.make(0)],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
expect(yield* bus.sequences([])).toEqual(new Map())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,52 +56,22 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Formatter", () => {
|
describe("Formatter", () => {
|
||||||
it.live("status() returns empty list when no formatters are configured", () =>
|
it.live("does not run formatters marked as disabled in config", () =>
|
||||||
withTemp((directory) =>
|
withTemp((directory) =>
|
||||||
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
Effect.gen(function* () {
|
||||||
),
|
const file = path.join(directory, "test.disabled")
|
||||||
)
|
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||||
|
}).pipe(
|
||||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
Effect.provide(
|
||||||
withTemp((directory) =>
|
formatterLayer(directory, {
|
||||||
Formatter.Service.use((formatter) =>
|
disabled: {
|
||||||
Effect.gen(function* () {
|
disabled: true,
|
||||||
const statuses = yield* formatter.status()
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
extensions: [".disabled"],
|
||||||
expect(gofmt).toBeDefined()
|
},
|
||||||
expect(gofmt?.extensions).toContain(".go")
|
}),
|
||||||
}),
|
),
|
||||||
).pipe(Effect.provide(formatterLayer(directory, true))),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
|
||||||
withTemp((directory) =>
|
|
||||||
Formatter.Service.use((formatter) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const statuses = yield* formatter.status()
|
|
||||||
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
|
|
||||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
|
||||||
}),
|
|
||||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
|
||||||
withTemp((directory) =>
|
|
||||||
Formatter.Service.use((formatter) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const statuses = yield* formatter.status()
|
|
||||||
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
|
|
||||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
|
||||||
}),
|
|
||||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("service initializes without error", () =>
|
|
||||||
withTemp((directory) =>
|
|
||||||
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,22 +85,29 @@ describe("Formatter", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("status() initializes formatter state per directory", () =>
|
it.live("loads formatter state per directory", () =>
|
||||||
Effect.acquireUseRelease(
|
withTemp((off) =>
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
withTemp((on) =>
|
||||||
([off, on]) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
const offFile = path.join(off, "test.isolated")
|
||||||
Effect.provide(formatterLayer(off.path, false)),
|
const onFile = path.join(on, "test.isolated")
|
||||||
|
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
||||||
|
Effect.provide(formatterLayer(off, false)),
|
||||||
)
|
)
|
||||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
||||||
Effect.provide(formatterLayer(on.path, true)),
|
Effect.provide(
|
||||||
|
formatterLayer(on, {
|
||||||
|
isolated: {
|
||||||
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
|
extensions: [".isolated"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
expect(disabled).toEqual([])
|
expect(disabled).toBe(false)
|
||||||
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
expect(enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
(directories) =>
|
),
|
||||||
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,6 @@ describe("Git trees", () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
|
||||||
expect(preview).toHaveLength(1)
|
|
||||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
|
||||||
yield* git.tree.restore({ repository, files })
|
yield* git.tree.restore({ repository, files })
|
||||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||||
|
|||||||
@@ -41,17 +41,15 @@ describe("Session.log", () => {
|
|||||||
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const created = yield* session.create({ location })
|
const created = yield* session.create({ location })
|
||||||
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
||||||
|
|
||||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||||
const watermark = (yield* bus.sequences([created.id])).get(created.id)
|
|
||||||
|
|
||||||
// Session creation commits a non-public durable event, so the marker's
|
// Session creation commits a non-public durable event, so the marker's
|
||||||
// seq covers more of the aggregate than the public events emitted.
|
// seq covers more of the aggregate than the public events emitted.
|
||||||
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
||||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
|
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -117,9 +117,6 @@ describe("Snapshot", () => {
|
|||||||
RelativePath.make("scope/tracked.txt"),
|
RelativePath.make("scope/tracked.txt"),
|
||||||
])
|
])
|
||||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
|
||||||
expect(preview).toHaveLength(1)
|
|
||||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
|
||||||
yield* snapshot.restore({ files: plan })
|
yield* snapshot.restore({ files: plan })
|
||||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||||
@@ -185,36 +182,6 @@ describe("Snapshot", () => {
|
|||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const project = path.join(tmp.path, "project")
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(project)
|
|
||||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
|
||||||
await initGit(project)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const snapshot = yield* Snapshot.Service
|
|
||||||
const before = yield* snapshot.capture()
|
|
||||||
expect(before).toBeDefined()
|
|
||||||
if (!before) return
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
|
|
||||||
await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
|
|
||||||
})
|
|
||||||
yield* snapshot.checkout(before)
|
|
||||||
expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
|
|
||||||
expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
|
|
||||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
|
||||||
}),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function snapshotLayer(data: string, directory: string) {
|
function snapshotLayer(data: string, directory: string) {
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ export * as ServerAuth from "./auth"
|
|||||||
|
|
||||||
import { Context, Layer, Option, Redacted } from "effect"
|
import { Context, Layer, Option, Redacted } from "effect"
|
||||||
|
|
||||||
export type Credentials = {
|
|
||||||
password?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DecodedCredentials = {
|
export type DecodedCredentials = {
|
||||||
readonly username: string
|
readonly username: string
|
||||||
readonly password: Redacted.Redacted
|
readonly password: Redacted.Redacted
|
||||||
@@ -37,16 +33,3 @@ export function authorized(credentials: DecodedCredentials, config: Info) {
|
|||||||
Redacted.value(credentials.password) === config.password.value
|
Redacted.value(credentials.password) === config.password.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function header(credentials?: Credentials) {
|
|
||||||
const password = credentials?.password
|
|
||||||
if (!password) return undefined
|
|
||||||
|
|
||||||
return `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function headers(credentials?: Credentials) {
|
|
||||||
const authorization = header(credentials)
|
|
||||||
if (!authorization) return undefined
|
|
||||||
return { Authorization: authorization }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,3 @@ test("accepts only the fixed opencode username", () => {
|
|||||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
||||||
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("encodes the fixed opencode username", () => {
|
|
||||||
expect(ServerAuth.header({ password: "secret" })).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio
|
|||||||
import { stringWidth } from "../../util/string-width"
|
import { stringWidth } from "../../util/string-width"
|
||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
|
import { installSyntaxHighlightCache } from "../../util/syntax-highlight-cache"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ function use() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Session() {
|
export function Session() {
|
||||||
|
installSyntaxHighlightCache()
|
||||||
const setEpilogue = useEpilogue()
|
const setEpilogue = useEpilogue()
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
const writeExport = async (file: string, content: string) => {
|
const writeExport = async (file: string, content: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core"
|
||||||
|
|
||||||
|
const CACHE_SIZE = 500
|
||||||
|
const installed = new WeakSet<TreeSitterClient>()
|
||||||
|
|
||||||
|
export function installSyntaxHighlightCache() {
|
||||||
|
const client = getTreeSitterClient()
|
||||||
|
if (installed.has(client)) return
|
||||||
|
installed.add(client)
|
||||||
|
client.highlightOnce = cacheHighlights(client.highlightOnce.bind(client))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheHighlights(highlight: TreeSitterClient["highlightOnce"], capacity = CACHE_SIZE) {
|
||||||
|
const cache = new Map<string, ReturnType<TreeSitterClient["highlightOnce"]>>()
|
||||||
|
|
||||||
|
return (content: string, filetype: string) => {
|
||||||
|
const key = `${filetype}\0${content}`
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = highlight(content, filetype)
|
||||||
|
cache.set(key, result)
|
||||||
|
if (cache.size > capacity) cache.delete(cache.keys().next().value!)
|
||||||
|
|
||||||
|
void result
|
||||||
|
.then((value) => {
|
||||||
|
if (value.error && cache.get(key) === result) cache.delete(key)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cache.get(key) === result) cache.delete(key)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { cacheHighlights } from "../../src/util/syntax-highlight-cache"
|
||||||
|
|
||||||
|
describe("syntax highlight cache", () => {
|
||||||
|
test("reuses completed and in-flight highlights", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const highlight = cacheHighlights(async () => {
|
||||||
|
calls++
|
||||||
|
return { highlights: [[0, 5, "keyword"]] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = highlight("const", "typescript")
|
||||||
|
const second = highlight("const", "typescript")
|
||||||
|
|
||||||
|
expect(second).toBe(first)
|
||||||
|
expect(await second).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||||
|
expect(await highlight("const", "typescript")).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("evicts least recently used highlights", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const highlight = cacheHighlights(async () => {
|
||||||
|
calls++
|
||||||
|
return { highlights: [] }
|
||||||
|
}, 2)
|
||||||
|
|
||||||
|
await highlight("one", "text")
|
||||||
|
await highlight("two", "text")
|
||||||
|
await highlight("one", "text")
|
||||||
|
await highlight("three", "text")
|
||||||
|
await highlight("two", "text")
|
||||||
|
|
||||||
|
expect(calls).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retries failed highlights", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const highlight = cacheHighlights(async () => {
|
||||||
|
calls++
|
||||||
|
if (calls === 1) return { error: "parser unavailable" }
|
||||||
|
return { highlights: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
await highlight("const", "typescript")
|
||||||
|
await highlight("const", "typescript")
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an evicted failure does not delete its replacement", async () => {
|
||||||
|
const pending = Promise.withResolvers<{ highlights: [] }>()
|
||||||
|
let calls = 0
|
||||||
|
const highlight = cacheHighlights(() => {
|
||||||
|
calls++
|
||||||
|
if (calls === 1) return pending.promise
|
||||||
|
return Promise.resolve({ highlights: [] })
|
||||||
|
}, 1)
|
||||||
|
|
||||||
|
const stale = highlight("one", "text")
|
||||||
|
await highlight("two", "text")
|
||||||
|
const current = highlight("one", "text")
|
||||||
|
pending.reject(new Error("parser unavailable"))
|
||||||
|
|
||||||
|
await expect(stale).rejects.toThrow("parser unavailable")
|
||||||
|
expect(highlight("one", "text")).toBe(current)
|
||||||
|
expect(calls).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user