mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:51:51 -04:00
Compare commits
4 Commits
v2
..
project-picker
| Author | SHA1 | Date | |
|---|---|---|---|
| e8fc72539a | |||
| 7f44669937 | |||
| d0eb91c69d | |||
| b75187ff59 |
@@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.map(normalizeProjectInfo)
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
|
||||
@@ -168,7 +168,6 @@ export function sanitizeProject(project: Project) {
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
return {
|
||||
...project,
|
||||
worktree: "canonical" in project ? project.canonical : project.worktree,
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
|
||||
project: { id: "", directory: directory(value) ?? "" },
|
||||
},
|
||||
data,
|
||||
})
|
||||
@@ -298,19 +298,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
return ((await legacy().project.list()).data ?? []).map((project) => ({
|
||||
...project,
|
||||
canonical: project.worktree,
|
||||
}))
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
},
|
||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||
const result = await legacy(value?.location).project.current()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return {
|
||||
id: result.data.id,
|
||||
directory: result.data.worktree,
|
||||
canonical: result.data.worktree,
|
||||
} satisfies ProjectCurrent
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
},
|
||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
|
||||
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||
|
||||
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
|
||||
return { directory, workspaceID, project: { id: "project", directory } }
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||
|
||||
@@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string }
|
||||
|
||||
export type ProjectTime = { created: number; updated: number; initialized?: number }
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
export type ProjectCurrent = { id: string; directory: string }
|
||||
|
||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
@@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
canonical: string
|
||||
worktree: string
|
||||
vcs?: ProjectVcs
|
||||
name?: string
|
||||
icon?: ProjectIcon
|
||||
@@ -2515,11 +2515,7 @@ export type LocationGetInput = {
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type LocationGetOutput = {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
project: { id: string; directory: string; canonical: string }
|
||||
}
|
||||
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: {
|
||||
@@ -2528,7 +2524,7 @@ export type AgentListInput = {
|
||||
}
|
||||
|
||||
export type AgentListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<AgentInfo>
|
||||
}
|
||||
|
||||
@@ -2540,7 +2536,7 @@ export type AgentGetInput = {
|
||||
}
|
||||
|
||||
export type AgentGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: AgentInfo
|
||||
}
|
||||
|
||||
@@ -2551,7 +2547,7 @@ export type PluginListInput = {
|
||||
}
|
||||
|
||||
export type PluginListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
@@ -3235,7 +3231,7 @@ export type ModelListInput = {
|
||||
}
|
||||
|
||||
export type ModelListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ModelInfo>
|
||||
}
|
||||
|
||||
@@ -3246,7 +3242,7 @@ export type ModelDefaultInput = {
|
||||
}
|
||||
|
||||
export type ModelDefaultOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ModelInfo | null
|
||||
}
|
||||
|
||||
@@ -3273,7 +3269,7 @@ export type ProviderListInput = {
|
||||
}
|
||||
|
||||
export type ProviderListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ProviderInfo>
|
||||
}
|
||||
|
||||
@@ -3285,7 +3281,7 @@ export type ProviderGetInput = {
|
||||
}
|
||||
|
||||
export type ProviderGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ProviderInfo
|
||||
}
|
||||
|
||||
@@ -3296,7 +3292,7 @@ export type IntegrationListInput = {
|
||||
}
|
||||
|
||||
export type IntegrationListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<IntegrationInfo>
|
||||
}
|
||||
|
||||
@@ -3308,7 +3304,7 @@ export type IntegrationGetInput = {
|
||||
}
|
||||
|
||||
export type IntegrationGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationInfo | null
|
||||
}
|
||||
|
||||
@@ -3355,7 +3351,7 @@ export type IntegrationOauthConnectInput = {
|
||||
}
|
||||
|
||||
export type IntegrationOauthConnectOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: {
|
||||
attemptID: string
|
||||
url: string
|
||||
@@ -3374,7 +3370,7 @@ export type IntegrationOauthStatusInput = {
|
||||
}
|
||||
|
||||
export type IntegrationOauthStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationAttemptStatus
|
||||
}
|
||||
|
||||
@@ -3409,7 +3405,7 @@ export type IntegrationCommandConnectInput = {
|
||||
}
|
||||
|
||||
export type IntegrationCommandConnectOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationCommandAttempt
|
||||
}
|
||||
|
||||
@@ -3422,7 +3418,7 @@ export type IntegrationCommandStatusInput = {
|
||||
}
|
||||
|
||||
export type IntegrationCommandStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationCommandAttemptStatus
|
||||
}
|
||||
|
||||
@@ -3443,7 +3439,7 @@ export type McpListInput = {
|
||||
}
|
||||
|
||||
export type McpListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<McpServer>
|
||||
}
|
||||
|
||||
@@ -3532,7 +3528,7 @@ export type McpResourceCatalogInput = {
|
||||
}
|
||||
|
||||
export type McpResourceCatalogOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: McpResourceCatalog
|
||||
}
|
||||
|
||||
@@ -3581,7 +3577,7 @@ export type FormRequestListInput = {
|
||||
}
|
||||
|
||||
export type FormRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FormInfo>
|
||||
}
|
||||
|
||||
@@ -4437,7 +4433,7 @@ export type PermissionRequestListInput = {
|
||||
}
|
||||
|
||||
export type PermissionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<PermissionRequest>
|
||||
}
|
||||
|
||||
@@ -4559,7 +4555,7 @@ export type FileListInput = {
|
||||
}
|
||||
|
||||
export type FileListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
@@ -4591,7 +4587,7 @@ export type FileFindInput = {
|
||||
}
|
||||
|
||||
export type FileFindOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
@@ -4602,7 +4598,7 @@ export type CommandListInput = {
|
||||
}
|
||||
|
||||
export type CommandListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<CommandInfo>
|
||||
}
|
||||
|
||||
@@ -4613,7 +4609,7 @@ export type SkillListInput = {
|
||||
}
|
||||
|
||||
export type SkillListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<SkillInfo>
|
||||
}
|
||||
|
||||
@@ -4626,7 +4622,7 @@ export type PtyListInput = {
|
||||
}
|
||||
|
||||
export type PtyListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<Pty>
|
||||
}
|
||||
|
||||
@@ -4672,7 +4668,7 @@ export type PtyCreateInput = {
|
||||
}
|
||||
|
||||
export type PtyCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
@@ -4684,7 +4680,7 @@ export type PtyGetInput = {
|
||||
}
|
||||
|
||||
export type PtyGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
@@ -4701,7 +4697,7 @@ export type PtyUpdateInput = {
|
||||
}
|
||||
|
||||
export type PtyUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
@@ -4721,7 +4717,7 @@ export type ShellListInput = {
|
||||
}
|
||||
|
||||
export type ShellListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ShellInfo1>
|
||||
}
|
||||
|
||||
@@ -4756,7 +4752,7 @@ export type ShellCreateInput = {
|
||||
}
|
||||
|
||||
export type ShellCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
@@ -4768,7 +4764,7 @@ export type ShellGetInput = {
|
||||
}
|
||||
|
||||
export type ShellGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
@@ -4781,7 +4777,7 @@ export type ShellTimeoutInput = {
|
||||
}
|
||||
|
||||
export type ShellTimeoutOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
@@ -4805,7 +4801,7 @@ export type ShellOutputInput = {
|
||||
}
|
||||
|
||||
export type ShellOutputOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
@@ -4825,7 +4821,7 @@ export type QuestionRequestListInput = {
|
||||
}
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
@@ -4855,7 +4851,7 @@ export type ReferenceListInput = {
|
||||
}
|
||||
|
||||
export type ReferenceListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
@@ -4898,7 +4894,7 @@ export type VcsStatusInput = {
|
||||
}
|
||||
|
||||
export type VcsStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<VcsFileStatus>
|
||||
}
|
||||
|
||||
@@ -4921,7 +4917,7 @@ export type VcsDiffInput = {
|
||||
}
|
||||
|
||||
export type VcsDiffOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FileDiffInfo>
|
||||
}
|
||||
|
||||
@@ -4942,7 +4938,7 @@ export type WebsearchProvidersInput = {
|
||||
}
|
||||
|
||||
export type WebsearchProvidersOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<WebSearchProvider>
|
||||
}
|
||||
|
||||
@@ -4955,6 +4951,6 @@ export type WebsearchQueryInput = {
|
||||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
export interface Target {
|
||||
readonly canonical: string
|
||||
@@ -109,13 +108,13 @@ const layer = Layer.effect(
|
||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const next = splitBom(input.content)
|
||||
const current = yield* fs
|
||||
.readFile(input.target.canonical)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.canonical,
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
@@ -173,6 +172,20 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function splitBom(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function hasUtf8Bom(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
export * as Formatter from "./formatter"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
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 {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
if (configured.ruff?.disabled || configured.uv?.disabled) {
|
||||
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
|
||||
}
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter.name)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter.name, 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) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) =>
|
||||
formatter.extensions.includes(path.extname(filepath)),
|
||||
)
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
if (enabled === false) continue
|
||||
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
|
||||
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
|
||||
const result = yield* processes
|
||||
.run(
|
||||
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||
cwd: location.directory,
|
||||
env: formatter.environment,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to format file", {
|
||||
file: filepath,
|
||||
command: cmd,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!result) continue
|
||||
if (result.exitCode === 0) return true
|
||||
yield* Effect.logError("formatter exited unsuccessfully", {
|
||||
file: filepath,
|
||||
command: cmd,
|
||||
exitCode: result.exitCode,
|
||||
})
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
})
|
||||
@@ -1,315 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly environment?: Record<string, string>
|
||||
readonly extensions: readonly string[]
|
||||
readonly enabled: Effect.Effect<string[] | false>
|
||||
}
|
||||
|
||||
export function make(input: {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly experimentalOxfmt?: boolean
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
input.processes
|
||||
.run(
|
||||
ChildProcess.make(command[0], command.slice(1), {
|
||||
cwd: input.directory,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.option)
|
||||
|
||||
const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const mix: Info = {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const prettier: Info = {
|
||||
name: "prettier",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".vue",
|
||||
".svelte",
|
||||
".json",
|
||||
".jsonc",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".xml",
|
||||
".md",
|
||||
".mdx",
|
||||
".graphql",
|
||||
".gql",
|
||||
],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("package.json")) {
|
||||
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
|
||||
const bin = yield* input.npm.which("prettier")
|
||||
if (bin) return [bin, "--write", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("package.json")) {
|
||||
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
|
||||
const bin = yield* input.npm.which("oxfmt")
|
||||
if (bin) return [bin, "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const biome: Info = {
|
||||
name: "biome",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".vue",
|
||||
".svelte",
|
||||
".json",
|
||||
".jsonc",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".xml",
|
||||
".md",
|
||||
".mdx",
|
||||
".graphql",
|
||||
".gql",
|
||||
],
|
||||
enabled: Effect.gen(function* () {
|
||||
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
|
||||
if (!found.some((items) => items.length > 0)) return disabled
|
||||
const bin = yield* input.npm.which("@biomejs/biome")
|
||||
return bin ? [bin, "format", "--write", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const zig: Info = {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const clang: Info = {
|
||||
name: "clang-format",
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = which("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const ruff: Info = {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!which("ruff")) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const air: Info = {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
const first = output.value.stdout.toString("utf8").split("\n")[0]
|
||||
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const uv: Info = {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0
|
||||
? [bin, "format", "--", "$FILE"]
|
||||
: disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = which("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
extensions: [".php"],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("composer.json")) {
|
||||
const json = yield* input.fs.readJson(file)
|
||||
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
mix,
|
||||
oxfmt,
|
||||
prettier,
|
||||
biome,
|
||||
zig,
|
||||
clang,
|
||||
ktlint,
|
||||
ruff,
|
||||
air,
|
||||
uv,
|
||||
rubocop,
|
||||
standardrb,
|
||||
htmlbeautifier,
|
||||
dart,
|
||||
ocamlformat,
|
||||
terraform,
|
||||
latexindent,
|
||||
gleam,
|
||||
shfmt,
|
||||
nixfmt,
|
||||
rustfmt,
|
||||
pint,
|
||||
ormolu,
|
||||
cljfmt,
|
||||
dfmt,
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function hasDependency(input: unknown, dependency: string) {
|
||||
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
|
||||
}
|
||||
|
||||
function hasRecordKey(input: unknown, field: string, key: string) {
|
||||
if (!isRecord(input)) return false
|
||||
return isRecord(input[field]) && key in input[field]
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return Boolean(input && typeof input === "object" && !Array.isArray(input))
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
@@ -74,7 +73,6 @@ const locationServiceNodes = [
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
|
||||
@@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
|
||||
project: { id: resolved.id, directory: resolved.directory },
|
||||
vcs: resolved.vcs,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { Form } from "../form"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -69,7 +68,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
@@ -100,7 +98,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
Context.make(FSUtil.Service, fs),
|
||||
Context.make(Global.Service, global),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
@@ -319,7 +318,6 @@ export const node = makeLocationNode({
|
||||
Config.node,
|
||||
Bus.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { Database } from "./database/database"
|
||||
@@ -40,7 +40,6 @@ export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly canonical: AbsolutePath
|
||||
readonly vcs?: Vcs
|
||||
}
|
||||
|
||||
@@ -84,7 +83,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
|
||||
: undefined
|
||||
return {
|
||||
id: row.id,
|
||||
canonical: row.worktree,
|
||||
worktree: row.worktree,
|
||||
vcs: row.vcs ?? undefined,
|
||||
name: row.name ?? undefined,
|
||||
icon,
|
||||
@@ -107,40 +106,6 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = project.vcs?.type
|
||||
yield* tx
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
if (!project.vcs) return
|
||||
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
|
||||
if (project.directory === project.canonical) return
|
||||
yield* projectDirectories.create(
|
||||
{
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return project
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
@@ -246,25 +211,17 @@ const layer = Layer.effect(
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
const canonical = yield* git.worktree
|
||||
.list(repo)
|
||||
.pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
)
|
||||
return yield* persist({
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
canonical,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hg = yield* hgDiscover(input)
|
||||
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
|
||||
const directory = AbsolutePath.make(path.parse(input).root)
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
if (hg) return hg
|
||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * from "./session/schema"
|
||||
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project"
|
||||
import { Workspace } from "./workspace"
|
||||
import { Model } from "./model"
|
||||
@@ -325,22 +325,6 @@ const layer = Layer.effect(
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
@@ -363,7 +347,12 @@ const layer = Layer.effect(
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* persistProject(project)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
@@ -462,7 +451,6 @@ const layer = Layer.effect(
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
@@ -744,7 +732,12 @@ const layer = Layer.effect(
|
||||
)
|
||||
return
|
||||
const project = yield* projects.resolve(directory)
|
||||
yield* persistProject(project)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if ((yield* execution.active).has(input.sessionID)) {
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
|
||||
@@ -9,14 +9,12 @@ export * as EditTool from "./edit"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
@@ -101,6 +99,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
}
|
||||
|
||||
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
@@ -110,7 +109,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -153,6 +151,14 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
const info = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
@@ -161,8 +167,9 @@ export const Plugin = {
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.canonical)
|
||||
const source = original.text
|
||||
const bytes = yield* fs.readFile(target.canonical)
|
||||
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
@@ -176,26 +183,6 @@ export const Plugin = {
|
||||
: findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
const replacements = matches.length
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) =>
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const preview =
|
||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||
? fileDiff(target.resource, source, replaced)
|
||||
: undefined
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||
@@ -206,17 +193,35 @@ export const Plugin = {
|
||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||
})
|
||||
}
|
||||
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) =>
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const counts = diffLines(source, replaced).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`,
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.canonical))
|
||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
|
||||
export function fileDiff(
|
||||
file: string,
|
||||
before: string,
|
||||
after: string,
|
||||
status: typeof FileDiff.Info.Type.status = "modified",
|
||||
): typeof FileDiff.Info.Type {
|
||||
const counts = diffLines(before, after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
return {
|
||||
file,
|
||||
patch: createTwoFilesPatch(file, file, before, after),
|
||||
status,
|
||||
...counts,
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -70,7 +68,6 @@ export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -132,16 +129,15 @@ export const Plugin = {
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`,
|
||||
).text,
|
||||
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`
|
||||
).replace(/^\uFEFF/, ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
const content = yield* fs.readFile(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -149,7 +145,8 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.canonical)
|
||||
@@ -169,17 +166,18 @@ export const Plugin = {
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
|
||||
yield* fs.readFile(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = Bom.split(original).text
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
@@ -219,7 +217,7 @@ export const Plugin = {
|
||||
)
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
@@ -297,31 +295,7 @@ export const Plugin = {
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const formatted = new Map<string, string>()
|
||||
yield* Effect.forEach(
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const files = yield* Effect.forEach(prepared, (change) => {
|
||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||
})
|
||||
return { applied, files }
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
@@ -363,15 +337,15 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
|
||||
)
|
||||
const counts =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
: diffLines(change.before, after).reduce(
|
||||
: diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
|
||||
@@ -13,19 +13,15 @@ export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||
[
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
|
||||
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
|
||||
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
|
||||
].join("\n")
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -35,8 +31,7 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
export const description = [
|
||||
"Spawns an agent in a child session to work on the specified task.",
|
||||
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
|
||||
@@ -9,20 +9,17 @@ export * as WriteTool from "./write"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
|
||||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description: "Path to the file to write to",
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
@@ -39,6 +36,7 @@ export const toModelOutput = (output: Output) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
/** Deferred write UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
@@ -48,8 +46,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -59,7 +55,7 @@ export const Plugin = {
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
@@ -78,29 +74,15 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(
|
||||
target.resource,
|
||||
current?.text ?? "",
|
||||
next.text,
|
||||
current ? "modified" : "added",
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
return result
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("node build", () => {
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
|
||||
project: { id: Project.ID.global, directory: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
@@ -79,7 +79,7 @@ describe("node build", () => {
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
commit: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -5,11 +5,10 @@ import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
const directory = input.projectDirectory ?? ref.directory
|
||||
return {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory, canonical: directory },
|
||||
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
||||
vcs: input.vcs,
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Config } from "../src/config"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Config.Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[
|
||||
Config.node,
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed(entries),
|
||||
changes: () => Stream.empty,
|
||||
}),
|
||||
),
|
||||
],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
],
|
||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||
])
|
||||
}
|
||||
|
||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => body(tmp.path),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
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))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("file() returns false when no formatter runs", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() initializes formatter state per directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([off, on]) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(off.path, false)),
|
||||
)
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(on.path, true)),
|
||||
)
|
||||
expect(disabled).toEqual([])
|
||||
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
||||
}),
|
||||
(directories) =>
|
||||
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stops after the first matching formatter succeeds", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.seq")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
first: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("tries the next matching formatter when the first fails", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.fallback")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
first: {
|
||||
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -18,7 +18,6 @@ const projectLayer = Layer.succeed(
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
directory: AbsolutePath.make("/repo"),
|
||||
canonical: AbsolutePath.make("/main/repo"),
|
||||
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
||||
}),
|
||||
commit: () => Effect.void,
|
||||
@@ -35,7 +34,6 @@ describe("Location", () => {
|
||||
expect(location.workspaceID).toBe(workspaceID)
|
||||
expect(location.project.id).toBe(Project.ID.make("project"))
|
||||
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
|
||||
expect(location.project.canonical).toBe(AbsolutePath.make("/main/repo"))
|
||||
expect(location.vcs).toEqual({
|
||||
type: "git",
|
||||
store: AbsolutePath.make("/repo/.git"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -87,6 +88,11 @@ describe("MoveSession", () => {
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
@@ -138,6 +144,11 @@ describe("MoveSession", () => {
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
@@ -193,6 +204,11 @@ describe("MoveSession", () => {
|
||||
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
|
||||
const sessionID = Session.ID.make("ses_move_project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
@@ -252,6 +268,11 @@ describe("MoveSession", () => {
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested_checkout")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
||||
@@ -121,11 +121,7 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
|
||||
? Effect.succeed({
|
||||
location: new Location.Info({
|
||||
directory: AbsolutePath.make("/"),
|
||||
project: {
|
||||
id: Project.ID.make("test"),
|
||||
directory: AbsolutePath.make("/"),
|
||||
canonical: AbsolutePath.make("/"),
|
||||
},
|
||||
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
|
||||
}),
|
||||
data: agentInfo(value),
|
||||
})
|
||||
@@ -167,11 +163,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
|
||||
Effect.map((data) => ({
|
||||
location: new Location.Info({
|
||||
directory: AbsolutePath.make("/"),
|
||||
project: {
|
||||
id: Project.ID.make("test"),
|
||||
directory: AbsolutePath.make("/"),
|
||||
canonical: AbsolutePath.make("/"),
|
||||
},
|
||||
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
|
||||
}),
|
||||
data: data.map(modelInfo),
|
||||
})),
|
||||
@@ -365,11 +357,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
|
||||
const location = Location.Info.make({
|
||||
directory: AbsolutePath.make("/tmp/websearch-test"),
|
||||
project: {
|
||||
id: Project.ID.make("websearch-test"),
|
||||
directory: AbsolutePath.make("/tmp/websearch-test"),
|
||||
canonical: AbsolutePath.make("/tmp/websearch-test"),
|
||||
},
|
||||
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
|
||||
})
|
||||
return {
|
||||
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
|
||||
|
||||
@@ -47,13 +47,13 @@ describe("Project.list", () => {
|
||||
expect(yield* project.list()).toEqual([
|
||||
{
|
||||
id: Project.ID.make("newer"),
|
||||
canonical: abs("/newer"),
|
||||
worktree: abs("/newer"),
|
||||
time: { created: 2, updated: 2, initialized: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: Project.ID.make("older"),
|
||||
canonical: abs("/older"),
|
||||
worktree: abs("/older"),
|
||||
vcs: "git",
|
||||
name: "Older",
|
||||
icon: { color: "#000000" },
|
||||
@@ -105,7 +105,6 @@ describe("Project.resolve", () => {
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs).toBeUndefined()
|
||||
}),
|
||||
@@ -124,7 +123,6 @@ describe("Project.resolve", () => {
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
@@ -329,46 +327,13 @@ describe("Project.resolve", () => {
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
|
||||
const project = yield* Project.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const id = remoteID("github.com/owner/repo")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id,
|
||||
worktree: abs("/stale-worktree"),
|
||||
vcs: "hg",
|
||||
name: "Preserved name",
|
||||
icon_color: "#123456",
|
||||
commands: { start: "bun dev" },
|
||||
sandboxes: [abs("/preserved-sandbox")],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
time_initialized: 2,
|
||||
})
|
||||
.run()
|
||||
|
||||
const result = yield* project.resolve(abs(worktree))
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.canonical).toBe(yield* real(tmp.path))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(id)
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({
|
||||
canonical: yield* real(tmp.path),
|
||||
vcs: "git",
|
||||
name: "Preserved name",
|
||||
icon: { color: "#123456" },
|
||||
commands: { start: "bun dev" },
|
||||
sandboxes: [abs("/preserved-sandbox")],
|
||||
time: { created: 1, initialized: 2 },
|
||||
})
|
||||
expect(
|
||||
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([
|
||||
{ directory: yield* real(tmp.path) },
|
||||
{ directory: yield* real(worktree), strategy: "git_worktree" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Model } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
@@ -32,7 +32,7 @@ const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
@@ -184,26 +184,6 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters project sessions by subpath", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
const root = yield* session.create({ location, title: "root" })
|
||||
const nested = yield* session.create({ location, title: "nested" })
|
||||
|
||||
yield* db.update(SessionTable).set({ path: "packages/tui" }).where(eq(SessionTable.id, nested.id)).run()
|
||||
|
||||
const page = yield* session.list({
|
||||
project: Project.ID.global,
|
||||
subpath: RelativePath.make("packages/tui"),
|
||||
parentID: null,
|
||||
})
|
||||
|
||||
expect(page.data.map((item) => item.id)).toEqual([nested.id])
|
||||
expect(page.data.map((item) => item.id)).not.toContain(root.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -190,6 +191,11 @@ const setup = Effect.gen(function* () {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
||||
@@ -54,7 +54,7 @@ const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -21,7 +21,7 @@ const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,7 @@ const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const projects = Layer.mock(Project.Service, {
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
})
|
||||
const skills = Layer.mock(Skill.Service, {
|
||||
list: () =>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect"
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const awaited: Session.ID[] = []
|
||||
const projects = Layer.mock(Project.Service, {
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
})
|
||||
const execution = Layer.mock(SessionExecution.Service, {
|
||||
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||
@@ -32,7 +31,6 @@ const writes: string[] = []
|
||||
let reads = 0
|
||||
let denyAction: string | undefined
|
||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
@@ -59,17 +57,12 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
reads = 0
|
||||
denyAction = undefined
|
||||
afterRead = () => Effect.void
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
@@ -116,7 +109,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
],
|
||||
),
|
||||
@@ -179,17 +171,6 @@ describe("EditTool", () => {
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
expect(assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
file: "hello.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
||||
}),
|
||||
),
|
||||
@@ -200,39 +181,6 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns the diff for final formatted content", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "formatted.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "formatted.txt", oldString: "before", newString: "after" }),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
|
||||
expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("accepts an absolute file path inside the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -354,7 +302,7 @@ describe("EditTool", () => {
|
||||
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(reads).toBe(1)
|
||||
expect(reads).toBe(0)
|
||||
expect(writes).toEqual([])
|
||||
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
|
||||
}),
|
||||
@@ -365,7 +313,7 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("denied edit does not disclose whether oldString matches", () =>
|
||||
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -391,7 +339,7 @@ describe("EditTool", () => {
|
||||
})
|
||||
expect(missing).toEqual(matching)
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
|
||||
expect(reads).toBe(2)
|
||||
expect(reads).toBe(0)
|
||||
expect(writes).toEqual([])
|
||||
}),
|
||||
),
|
||||
@@ -626,11 +574,6 @@ describe("EditTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "windows.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -22,7 +21,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -34,7 +33,6 @@ let failWriteTarget: string | undefined
|
||||
let readsBeforeEditApproval = 0
|
||||
let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
@@ -65,10 +63,6 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
@@ -78,7 +72,6 @@ const reset = () => {
|
||||
readsBeforeEditApproval = 0
|
||||
editApproved = false
|
||||
afterEditApproval = () => Effect.void
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
@@ -142,7 +135,6 @@ const withTool = <A, E, R>(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
]),
|
||||
),
|
||||
@@ -262,28 +254,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns file diffs for final formatted content", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "formatted.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
|
||||
return true
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
|
||||
expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves and updates a file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -582,11 +552,6 @@ describe("PatchTool", () => {
|
||||
const bom = "\uFEFF"
|
||||
const target = path.join(directory, "example.cs")
|
||||
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
|
||||
return true
|
||||
})
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -23,13 +22,12 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const writes: string[] = []
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
let denyAction: string | undefined
|
||||
|
||||
const permission = Layer.succeed(
|
||||
@@ -57,14 +55,9 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
formatFile = () => Effect.succeed(false)
|
||||
denyAction = undefined
|
||||
}
|
||||
|
||||
@@ -100,7 +93,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
],
|
||||
),
|
||||
@@ -140,17 +132,6 @@ describe("WriteTool", () => {
|
||||
"created",
|
||||
)
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
|
||||
expect(assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
|
||||
}),
|
||||
)
|
||||
@@ -159,30 +140,6 @@ describe("WriteTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("formats the committed file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "formatted.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
|
||||
return true
|
||||
})
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
|
||||
status: "completed",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -198,17 +155,6 @@ describe("WriteTool", () => {
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
file: "existing.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringMatching(/-before[\s\S]*\+after/),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||
"after",
|
||||
)
|
||||
@@ -228,11 +174,6 @@ describe("WriteTool", () => {
|
||||
reset()
|
||||
const preserved = path.join(tmp.path, "preserved.txt")
|
||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||
formatFile = (target) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() =>
|
||||
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
|
||||
).pipe(
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
OpenCodeEvent,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
@@ -78,10 +77,6 @@ export interface Data {
|
||||
}
|
||||
}
|
||||
readonly project: {
|
||||
list(): Project[]
|
||||
get(projectID: string): Project | undefined
|
||||
sync(): Promise<void>
|
||||
invalidate(): void
|
||||
readonly permission: {
|
||||
list(projectID: string): PermissionSavedInfo[] | undefined
|
||||
sync(projectID: string): Promise<void>
|
||||
|
||||
@@ -17,7 +17,6 @@ export class Info extends Schema.Class<Info>("Location.Info")({
|
||||
project: Schema.Struct({
|
||||
id: ProjectID,
|
||||
directory: AbsolutePath,
|
||||
canonical: AbsolutePath,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Projec
|
||||
export const Current = Schema.Struct({
|
||||
id: ID,
|
||||
directory: AbsolutePath,
|
||||
canonical: AbsolutePath,
|
||||
}).annotate({ identifier: "Project.Current" })
|
||||
export interface Current extends Schema.Schema.Type<typeof Current> {}
|
||||
export const Directory = Schema.Struct({
|
||||
@@ -47,7 +46,7 @@ export interface Time extends Schema.Schema.Type<typeof Time> {}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
canonical: AbsolutePath,
|
||||
worktree: Schema.String,
|
||||
vcs: optional(Vcs),
|
||||
name: optional(Schema.String),
|
||||
icon: optional(Icon),
|
||||
|
||||
@@ -9,11 +9,7 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
|
||||
.handle("project.list", () => Project.Service.use((project) => project.list()))
|
||||
.handle("project.current", () =>
|
||||
Location.Service.use((location) =>
|
||||
Effect.succeed({
|
||||
id: location.project.id,
|
||||
directory: location.project.directory,
|
||||
canonical: location.project.canonical,
|
||||
}),
|
||||
Effect.succeed({ id: location.project.id, directory: location.project.directory }),
|
||||
),
|
||||
)
|
||||
.handle("project.directories", (ctx) =>
|
||||
|
||||
@@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogProject } from "./component/dialog-project"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
@@ -648,6 +649,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project.switch",
|
||||
title: "Switch project",
|
||||
category: "Session",
|
||||
slash: { name: "projects", aliases: ["project"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogProject />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
|
||||
@@ -102,12 +102,12 @@ export function DialogIntegration(
|
||||
title="Connect a service"
|
||||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
options={options()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
@@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "path"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export function DialogProject() {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const paths = useTuiPaths()
|
||||
|
||||
const [projects] = createResource(() => client.api.project.list())
|
||||
const current = createMemo(() => data.location.info()?.project.directory ?? data.location.default().directory)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const list = [...(projects() ?? [])]
|
||||
list.sort((a, b) => {
|
||||
if (a.worktree === current()) return -1
|
||||
if (b.worktree === current()) return 1
|
||||
return (b.time.initialized ?? 0) - (a.time.initialized ?? 0)
|
||||
})
|
||||
return list
|
||||
.filter((project) => project.worktree !== "/")
|
||||
.filter((project, index, all) => all.findIndex((other) => other.worktree === project.worktree) === index)
|
||||
.map((project) => ({
|
||||
title: project.name ?? path.basename(project.worktree),
|
||||
description: abbreviateHome(project.worktree, paths.home),
|
||||
value: project.worktree,
|
||||
category: project.worktree === current() ? "Current" : "Projects",
|
||||
}))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Switch project"
|
||||
placeholder="Search projects…"
|
||||
options={options()}
|
||||
current={current()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text>{projects.loading ? "Loading projects…" : "No projects found"}</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
if (option.value === current()) return
|
||||
// Navigating while already home would remount the footer mid-animation.
|
||||
if (route.data.type !== "home") route.navigate({ type: "home" })
|
||||
void data.location
|
||||
.setDefault(option.value)
|
||||
.catch((error) =>
|
||||
toast.show({ variant: "error", title: "Failed to switch project", message: errorMessage(error) }),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
@@ -33,69 +32,52 @@ export function DialogSessionList() {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [allProjects, setAllProjects] = createSignal(false)
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
() => ({ query: search().trim(), allProjects: allProjects() }),
|
||||
async ({ query, allProjects }) => {
|
||||
try {
|
||||
if (!data.location.info()) await data.location.sync()
|
||||
const current = data.location.info()
|
||||
if (!current) throw new Error("Location unavailable")
|
||||
const response = await client.api.session.list({
|
||||
...(allProjects
|
||||
? {}
|
||||
: {
|
||||
project: current.project.id,
|
||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||
}),
|
||||
...(query ? { search: query } : {}),
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
})
|
||||
return { query, allProjects, sessions: response.data, error: undefined }
|
||||
} catch (error) {
|
||||
// A transient transport failure must degrade search, not crash the TUI
|
||||
// through the root ErrorBoundary when the errored resource is read.
|
||||
return { query, allProjects, sessions: [] as SessionInfo[], error }
|
||||
}
|
||||
},
|
||||
)
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query) return
|
||||
try {
|
||||
if (!data.location.info()) await data.location.sync()
|
||||
const current = data.location.info()
|
||||
if (!current) throw new Error("Location unavailable")
|
||||
const response = await client.api.session.list({
|
||||
project: current.project.id,
|
||||
search: query,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
})
|
||||
return { query, sessions: response.data, error: undefined }
|
||||
} catch (error) {
|
||||
// A transient transport failure must degrade search, not crash the TUI
|
||||
// through the root ErrorBoundary when the errored resource is read.
|
||||
return { query, sessions: [] as SessionInfo[], error }
|
||||
}
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const localSessions = createMemo(() => {
|
||||
const query = filter().trim().toLowerCase()
|
||||
const current = data.location.info()
|
||||
const sessions = data.session
|
||||
.list()
|
||||
.filter(
|
||||
(session) =>
|
||||
allProjects() ||
|
||||
(session.projectID === current?.project.id && session.location.directory === current.directory),
|
||||
)
|
||||
const sessions = data.session.list()
|
||||
if (!query) return sessions
|
||||
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
|
||||
})
|
||||
const sessions = createMemo(() => {
|
||||
const query = filter().trim()
|
||||
const query = filter()
|
||||
const local = localSessions()
|
||||
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
|
||||
if (!query) return local
|
||||
if (query !== search() || searchResults.loading) return local
|
||||
const result = searchResults()
|
||||
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
|
||||
if (result?.query !== query || result.error) return local
|
||||
return result.sessions
|
||||
})
|
||||
const searchState = createMemo(() => {
|
||||
const query = filter().trim()
|
||||
if (query !== search().trim() || searchResults.loading)
|
||||
return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
|
||||
const query = filter()
|
||||
if (!query) return { message: "No sessions available", error: false }
|
||||
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
|
||||
const result = searchResults()
|
||||
if (result?.query === query && result.error)
|
||||
return {
|
||||
message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.",
|
||||
error: true,
|
||||
}
|
||||
return { message: query ? "No sessions found" : "No sessions available", error: false }
|
||||
return { message: "Could not search sessions. Change the search to try again.", error: true }
|
||||
return { message: "No sessions found", error: false }
|
||||
})
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
@@ -109,13 +91,6 @@ export function DialogSessionList() {
|
||||
const hint = quickSwitchHint()
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
const currentProjectName = createMemo(() => {
|
||||
const current = data.location.info()
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
if (!project) return ""
|
||||
return project.name || path.basename(project.canonical)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
@@ -130,12 +105,8 @@ export function DialogSessionList() {
|
||||
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: directory !== data.location.info()?.project.directory
|
||||
? Locale.truncate(path.basename(directory), 20)
|
||||
: ""
|
||||
const footer =
|
||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
@@ -168,16 +139,6 @@ export function DialogSessionList() {
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
titleView={
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Sessions
|
||||
</text>
|
||||
<Show when={!allProjects() && currentProjectName()}>
|
||||
<text fg={theme.text.subdued}> for {currentProjectName()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
current={currentSessionID()}
|
||||
@@ -185,25 +146,13 @@ export function DialogSessionList() {
|
||||
setFilter(query)
|
||||
setSearch(query)
|
||||
}}
|
||||
bindings={[
|
||||
{
|
||||
bind: "ctrl+a",
|
||||
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
setAllProjects((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No sessions available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
@@ -229,34 +178,24 @@ export function DialogSessionList() {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
void client.api.session
|
||||
.remove({ sessionID: option.value })
|
||||
.then(() => {
|
||||
setSearchResults((result) =>
|
||||
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.rename",
|
||||
title: "rename",
|
||||
onTrigger: (option: { value: string; title: string }) =>
|
||||
DialogSessionRename.show(dialog, option.value, option.title),
|
||||
onTrigger: (option: { value: string }) =>
|
||||
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
|
||||
},
|
||||
]}
|
||||
footerHints={[
|
||||
...quickSwitchFooterHints(),
|
||||
{ title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" },
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
emptyView={
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
@@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
ModelInfo,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
SessionMessageInfo,
|
||||
@@ -81,7 +80,6 @@ type Store = {
|
||||
form: Record<string, FormWithLocation[]>
|
||||
}
|
||||
project: {
|
||||
info: Record<string, Project>
|
||||
permission: Record<string, PermissionSavedInfo[]>
|
||||
}
|
||||
location: Record<string, LocationData>
|
||||
@@ -141,7 +139,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
form: {},
|
||||
},
|
||||
project: {
|
||||
info: {},
|
||||
permission: {},
|
||||
},
|
||||
location: {},
|
||||
@@ -957,26 +954,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
},
|
||||
},
|
||||
sync(sessionID: string, options?: { children?: boolean }) {
|
||||
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
|
||||
const [info, children] = await Promise.all([
|
||||
client.api.session.get({ sessionID }),
|
||||
options?.children
|
||||
? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data)
|
||||
: [],
|
||||
])
|
||||
const sessions = [info, ...children]
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of sessions) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session:${sessionID}`, async () => {
|
||||
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
|
||||
registerSession(sessionID)
|
||||
})
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
@@ -1056,21 +1037,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
project: {
|
||||
list() {
|
||||
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
},
|
||||
get(projectID: string) {
|
||||
return store.project.info[projectID]
|
||||
},
|
||||
sync() {
|
||||
return sync.run("project", async () => {
|
||||
const projects = await client.api.project.list()
|
||||
setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))))
|
||||
})
|
||||
},
|
||||
invalidate() {
|
||||
sync.invalidate("project")
|
||||
},
|
||||
permission: {
|
||||
list(projectID: string) {
|
||||
return store.project.permission[projectID]
|
||||
@@ -1116,6 +1082,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
default() {
|
||||
return defaultLocation()
|
||||
},
|
||||
// Repoints the whole TUI at another project directory, like a shell `cd`.
|
||||
// The follow-up default sync lets the server canonicalize the directory.
|
||||
async setDefault(directory: string) {
|
||||
setDefaultLocation({ directory })
|
||||
await result.location.sync()
|
||||
},
|
||||
async sync(ref?: LocationRef) {
|
||||
const current = ref ?? defaultLocation()
|
||||
await sync.run(`location:${locationKey(current)}`, async () => {
|
||||
@@ -1352,9 +1324,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { ...store.location[key], info: location })
|
||||
return client.api.session.list({
|
||||
project: location.project.id,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
})
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
.then((response) => {
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of response.data) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of response.data) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload sessions", error))
|
||||
return
|
||||
}
|
||||
handleEvent(details)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
@@ -32,14 +31,10 @@ type PersistedState = {
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
const TAB_PREFETCH_DELAY = 300
|
||||
|
||||
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
||||
name: "SessionTabs",
|
||||
init: () => {
|
||||
const route = useRoute()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const config = useConfig().data
|
||||
@@ -134,45 +129,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
// Warm open tabs' session data so first switches render from cache instead of fetching inside
|
||||
// the switch gesture. Uses only existing sync methods (each dedupes internally), so reruns on
|
||||
// tab-set or connection changes are no-ops for already-warm sessions, and reconnects double as
|
||||
// a cache refresh after an SSE gap. The delay lets the current session's own mount syncs get
|
||||
// the first connection slots. The effect tracks only the id set: reorders, tab switches, and
|
||||
// title updates neither restart the timer nor an in-flight warm pass; the timer callback
|
||||
// itself runs untracked, where the current session is skipped.
|
||||
const openTabSessions = createMemo(() =>
|
||||
state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.sort()
|
||||
.join("\n"),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (client.connection.status() !== "connected") return
|
||||
if (openTabSessions() === "") return
|
||||
let stale = false
|
||||
const timer = setTimeout(async () => {
|
||||
const sessions = state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.filter((sessionID) => sessionID !== current())
|
||||
for (const sessionID of sessions) {
|
||||
if (stale) return
|
||||
await Promise.allSettled([
|
||||
data.session.sync(sessionID),
|
||||
data.session.message.sync(sessionID),
|
||||
data.session.pending.sync(sessionID),
|
||||
data.session.permission.sync(sessionID),
|
||||
data.session.form.sync(sessionID),
|
||||
])
|
||||
}
|
||||
}, TAB_PREFETCH_DELAY)
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { FadeFilePath } from "../../ui/fade-file-path"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const directory = createMemo(() =>
|
||||
@@ -10,9 +10,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
<FadeFilePath
|
||||
value={directory()}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={props.context.theme.text.subdued}
|
||||
bg={props.context.theme.background.default}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { createMemo } from "solid-js"
|
||||
import { FadeFilePath } from "../../ui/fade-file-path"
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
)
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
<FadeFilePath
|
||||
value={directory()}
|
||||
maxWidth={38}
|
||||
fg={props.context.theme.text.subdued}
|
||||
bg={props.context.theme.background.default}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,13 +98,6 @@ addDefaultParsers(parsers.parsers)
|
||||
// Exclude temporary bottom space when measuring the real transcript height.
|
||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
|
||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
||||
// in a few hundred milliseconds without a perceptible pause.
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -263,7 +256,7 @@ export function Session() {
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
data.session.sync(sessionID, { children: true }),
|
||||
data.session.sync(sessionID),
|
||||
data.session.permission.sync(sessionID),
|
||||
data.session.form.sync(sessionID),
|
||||
])
|
||||
@@ -303,49 +296,6 @@ export function Session() {
|
||||
r.set(route.prompt)
|
||||
}
|
||||
|
||||
/** Runs after layout has settled (two frames), unless the transcript was torn down. */
|
||||
const afterLayout = (continuation: () => void) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
continuation()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
|
||||
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
|
||||
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
|
||||
// the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
|
||||
// re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
|
||||
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||
createEffect(() => {
|
||||
const current = hidden()
|
||||
if (current === 0) return
|
||||
// Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
|
||||
// backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
|
||||
// cadence undisturbed by appends.
|
||||
const timer = setTimeout(() => {
|
||||
const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined
|
||||
const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height
|
||||
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
|
||||
if (before === undefined) return
|
||||
// Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
|
||||
// readers who have scrolled up.
|
||||
if (viewportBottom >= before - 1) return
|
||||
afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before))
|
||||
}, TRANSCRIPT_BACKFILL_DELAY)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
/** Message navigation needs the full transcript mounted before walking or jumping. */
|
||||
const ensureAllRows = (continuation: () => void) => {
|
||||
if (hidden() === 0) return continuation()
|
||||
setHiddenRows(0)
|
||||
afterLayout(continuation)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const current = prompt()
|
||||
if (sent || !current || !synced() || !local.model.ready) return
|
||||
@@ -372,36 +322,41 @@ export function Session() {
|
||||
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
|
||||
}),
|
||||
)
|
||||
afterLayout(() => {
|
||||
if (navigationMessage() !== messageID) return
|
||||
scroll.scrollTo(top)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scroll.isDestroyed || navigationMessage() !== messageID) return
|
||||
scroll.scrollTo(top)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) =>
|
||||
ensureAllRows(() => {
|
||||
const target = findMessageBoundary({
|
||||
direction,
|
||||
children: scroll.getChildren(),
|
||||
messages: messages(),
|
||||
scrollTop: scroll.scrollTop,
|
||||
viewportY: scroll.viewport.y,
|
||||
currentID: navigationMessage(),
|
||||
userOnly,
|
||||
})
|
||||
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
|
||||
const target = findMessageBoundary({
|
||||
direction,
|
||||
children: scroll.getChildren(),
|
||||
messages: messages(),
|
||||
scrollTop: scroll.scrollTop,
|
||||
viewportY: scroll.viewport.y,
|
||||
currentID: navigationMessage(),
|
||||
userOnly,
|
||||
})
|
||||
|
||||
if (target) alignMessage(target.id, target.top)
|
||||
if (!target) {
|
||||
dialog.clear()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const jumpToMessage = (messageID: string) =>
|
||||
ensureAllRows(() => {
|
||||
const child = scroll.getRenderable(messageID)
|
||||
if (!child) return
|
||||
const y = scroll.scrollTop + child.y - scroll.viewport.y
|
||||
const message = data.session.message.get(route.sessionID, messageID)
|
||||
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||
})
|
||||
alignMessage(target.id, target.top)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
const jumpToMessage = (messageID: string) => {
|
||||
const child = scroll.getRenderable(messageID)
|
||||
if (!child) return
|
||||
const y = scroll.scrollTop + child.y - scroll.viewport.y
|
||||
const message = data.session.message.get(route.sessionID, messageID)
|
||||
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||
}
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
@@ -977,12 +932,12 @@ export function Session() {
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={visibleRows()}>
|
||||
<For each={rows}>
|
||||
{(row, index) => (
|
||||
<SessionRowView
|
||||
row={row}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
boundaryID={boundaries()[index() + hidden()]}
|
||||
boundaryID={boundaries()[index()]}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -97,16 +97,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}),
|
||||
)
|
||||
|
||||
// Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer
|
||||
// their first run: the mount effect above has already reduced the same state.
|
||||
// Re-reduce when the revert boundary changes (stage/clear/commit).
|
||||
createEffect(
|
||||
on(
|
||||
revertBoundary,
|
||||
() => {
|
||||
setRows(reconcile(reduce()))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
on(revertBoundary, () => {
|
||||
setRows(reconcile(reduce()))
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
@@ -117,7 +112,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item) => item.id),
|
||||
() => setRows(reconcile(reduce())),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -143,11 +137,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
: [],
|
||||
),
|
||||
() => setRows(reconcile(reduce())),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true }))
|
||||
createEffect(
|
||||
on(turnTokens, () => setRows(reconcile(reduce()))),
|
||||
)
|
||||
|
||||
const appendMessage = (messageID: string) =>
|
||||
setRows(
|
||||
|
||||
@@ -615,14 +615,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
when={props.renderFilter !== false && store.filter.length > 0}
|
||||
fallback={
|
||||
props.emptyView ?? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No items available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.noMatchView ?? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No results found</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useConfig } from "../config"
|
||||
import { FilePath } from "./file-path"
|
||||
|
||||
const DURATION = 300
|
||||
|
||||
// FilePath that crossfades when its value changes: the old path fades to the
|
||||
// background, the text swaps at the midpoint, and the new path fades back in.
|
||||
// The initial value renders immediately; only subsequent changes animate.
|
||||
export function FadeFilePath(props: {
|
||||
value: string | undefined
|
||||
maxWidth: number
|
||||
fg: RGBA
|
||||
bg: RGBA
|
||||
basenameFg?: RGBA
|
||||
}) {
|
||||
const config = useConfig().data
|
||||
const [store, setStore] = createStore({
|
||||
text: props.value,
|
||||
previous: undefined as string | undefined,
|
||||
progress: 1,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
(text) => {
|
||||
// The source can flicker to undefined while a new location syncs;
|
||||
// retain the last path so the change animates as one transition.
|
||||
if (text === undefined || text === store.text) return
|
||||
if (store.text === undefined || !(config.animations ?? true)) {
|
||||
setStore({ text, previous: undefined, progress: 1 })
|
||||
return
|
||||
}
|
||||
setStore({ text, previous: store.text, progress: 0 })
|
||||
const started = performance.now()
|
||||
const timer = setInterval(() => {
|
||||
const progress = Math.min(1, (performance.now() - started) / DURATION)
|
||||
setStore("progress", progress)
|
||||
if (progress >= 1) {
|
||||
clearInterval(timer)
|
||||
setStore("previous", undefined)
|
||||
}
|
||||
}, 33)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const display = createMemo(() => (store.previous !== undefined && store.progress < 0.5 ? store.previous : store.text))
|
||||
const fg = createMemo(() => {
|
||||
if (store.previous === undefined) return props.fg
|
||||
const t = store.progress < 0.5 ? 1 - store.progress * 2 : store.progress * 2 - 1
|
||||
return mix(props.bg, props.fg, t)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={display() !== undefined}>
|
||||
<FilePath
|
||||
value={display() ?? ""}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={fg()}
|
||||
basenameFg={store.previous === undefined ? props.basenameFg : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function mix(from: RGBA, to: RGBA, t: number) {
|
||||
return RGBA.fromValues(
|
||||
from.r + (to.r - from.r) * t,
|
||||
from.g + (to.g - from.g) * t,
|
||||
from.b + (to.b - from.b) * t,
|
||||
from.a + (to.a - from.a) * t,
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
@@ -71,13 +71,11 @@ function durable(sessionID: string, seq = 0, version = 1) {
|
||||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
||||
test("does not preload session summaries into the data context", async () => {
|
||||
test("preloads root sessions before applying the session limit", async () => {
|
||||
const events = createEventStream()
|
||||
let location = false
|
||||
let sessions = false
|
||||
let request: URL | undefined
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/location") location = true
|
||||
if (url.pathname === "/api/session") sessions = true
|
||||
if (url.pathname === "/api/session") request = url
|
||||
return undefined
|
||||
}, events)
|
||||
|
||||
@@ -94,58 +92,10 @@ test("does not preload session summaries into the data context", async () => {
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => location)
|
||||
await Bun.sleep(20)
|
||||
expect(sessions).toBe(false)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("proactively syncs project metadata", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/project") return
|
||||
return json([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.project.get("proj_test") !== undefined)
|
||||
expect(data.project.list()).toEqual([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
await wait(() => request !== undefined)
|
||||
expect(request?.searchParams.get("project")).toBe("proj_test")
|
||||
expect(request?.searchParams.get("limit")).toBe("50")
|
||||
expect(request?.searchParams.get("parentID")).toBe("null")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -1448,29 +1398,6 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
{ type: "compaction-queued", inputID: "message-compaction-later" },
|
||||
])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
created: 2,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_text_started",
|
||||
created: 2,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 4),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
ordinal: 0,
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_text_ended",
|
||||
created: 2,
|
||||
@@ -1490,7 +1417,7 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
id: "evt_compaction_started",
|
||||
created: 2,
|
||||
type: "session.compaction.started",
|
||||
durable: durable(sessionID, 6),
|
||||
durable: durable(sessionID, 4),
|
||||
data: {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
@@ -1505,7 +1432,7 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
id: "evt_compaction_ended",
|
||||
created: 3,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable(sessionID, 7),
|
||||
durable: durable(sessionID, 5),
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
|
||||
})
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
|
||||
@@ -2669,18 +2596,8 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
|
||||
// the family-index tests below.
|
||||
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
return json({
|
||||
data: Object.entries(parents)
|
||||
.filter(([, parent]) => parent === parentID)
|
||||
.map(([id, parent]) => sessionInfo(id, parent, costs[id])),
|
||||
cursor: {},
|
||||
})
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (match && match[1] !== "active")
|
||||
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
@@ -2707,20 +2624,6 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
|
||||
return { data, app }
|
||||
}
|
||||
|
||||
test("syncs direct child session info with a navigated root", async () => {
|
||||
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
|
||||
try {
|
||||
await data.session.sync("root", { children: true })
|
||||
expect(data.session.get("root")?.id).toBe("root")
|
||||
expect(data.session.get("child")?.parentID).toBe("root")
|
||||
expect(data.session.get("sibling")?.parentID).toBe("root")
|
||||
expect(data.session.get("grandchild")).toBeUndefined()
|
||||
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("groups an orphan child under its missing parent until the root arrives", async () => {
|
||||
const { data, app } = await mountData({ child: "root" })
|
||||
try {
|
||||
|
||||
@@ -185,19 +185,6 @@ test("dialog actions run without options while row actions still require a selec
|
||||
}
|
||||
})
|
||||
|
||||
test("renders one gap before an empty state", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = await renderSelect(tmp.path, [], () => {}, () => {})
|
||||
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("No items available"))
|
||||
const lines = app.captureCharFrame().split("\n").map((line) => line.trim())
|
||||
expect(lines.indexOf("No items available") - lines.indexOf("Search")).toBe(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("footer actions run when filtering leaves no selected row", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let global = 0
|
||||
|
||||
@@ -93,26 +93,24 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
|
||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||
if (url.pathname === "/api/fs/list")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
||||
if (url.pathname === "/api/shell")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/mcp")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
data: { resources: [], templates: [] },
|
||||
})
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (url.pathname === "/api/permission/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/form/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
||||
@@ -122,13 +120,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
)
|
||||
)
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/reference")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
if (url.pathname === "/api/websearch/provider") {
|
||||
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
}
|
||||
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/session") return json([])
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("run interactive runtime", () => {
|
||||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
@@ -130,7 +130,7 @@ describe("run interactive runtime", () => {
|
||||
await refreshCatalog?.()
|
||||
expect(defaultModel).toHaveBeenCalledTimes(1)
|
||||
selected.resolve({
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
data: model,
|
||||
})
|
||||
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
||||
@@ -165,7 +165,7 @@ describe("run interactive runtime", () => {
|
||||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "model" },
|
||||
variant: undefined,
|
||||
@@ -261,7 +261,7 @@ describe("run interactive runtime", () => {
|
||||
return {
|
||||
sessionID: "ses-deferred",
|
||||
sessionTitle: "Deferred",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: undefined,
|
||||
@@ -349,7 +349,7 @@ describe("run interactive runtime", () => {
|
||||
target: async () => ({
|
||||
sessionID: "ses-resume",
|
||||
sessionTitle: "Resume",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
agent: "review",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
@@ -432,7 +432,7 @@ describe("run interactive runtime", () => {
|
||||
target: async () => ({
|
||||
sessionID: "ses-resume-abort",
|
||||
sessionTitle: "Cached title",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
@@ -490,7 +490,7 @@ describe("run interactive runtime", () => {
|
||||
location: {
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
project: { id: "pro-1", directory: "/session", canonical: "/session" },
|
||||
project: { id: "pro-1", directory: "/session" },
|
||||
},
|
||||
data: [{ path: "src/index.ts", type: "file" }],
|
||||
} as never)
|
||||
@@ -507,7 +507,7 @@ describe("run interactive runtime", () => {
|
||||
location: {
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
project: { id: "location-project", directory: "/session", canonical: "/session" },
|
||||
project: { id: "location-project", directory: "/session" },
|
||||
},
|
||||
agent: "review",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
|
||||
@@ -167,11 +167,7 @@ function sdk(input: {
|
||||
location: {
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
workspaceID: input.globalLocation?.workspaceID,
|
||||
project: {
|
||||
id: "proj_1",
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
canonical: input.globalLocation?.directory ?? "/tmp",
|
||||
},
|
||||
project: { id: "proj_1", directory: input.globalLocation?.directory ?? "/tmp" },
|
||||
},
|
||||
data: input.globals ?? [],
|
||||
}),
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export * as Bom from "./bom.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { FSUtil } from "./fs-util.js"
|
||||
|
||||
const code = 0xfeff
|
||||
const value = String.fromCharCode(code)
|
||||
|
||||
export function split(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
export function join(text: string, bom: boolean) {
|
||||
const stripped = split(text).text
|
||||
return bom ? value + stripped : stripped
|
||||
}
|
||||
|
||||
export function has(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
||||
return split(decode(yield* fs.readFile(filepath)))
|
||||
})
|
||||
|
||||
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
|
||||
const decoded = decode(yield* fs.readFile(filepath))
|
||||
const current = split(decoded)
|
||||
const canonical = join(current.text, bom)
|
||||
if (decoded === canonical) return current.text
|
||||
yield* fs.writeWithDirs(filepath, canonical)
|
||||
return current.text
|
||||
})
|
||||
|
||||
function decode(content: Uint8Array) {
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Patch from "./patch.js"
|
||||
|
||||
import { Result, Schema } from "effect"
|
||||
import { Bom } from "./bom.js"
|
||||
|
||||
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
|
||||
boundary: Schema.Literals(["first", "last"]),
|
||||
@@ -126,19 +125,20 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
}
|
||||
|
||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||
const source = Bom.split(original)
|
||||
const source = splitBom(original)
|
||||
const lines = source.text.split("\n")
|
||||
if (lines.at(-1) === "") lines.pop()
|
||||
const replacements = computeReplacements(lines, path, chunks)
|
||||
const updated = [...lines]
|
||||
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
||||
if (updated.at(-1) !== "") updated.push("")
|
||||
const next = Bom.split(updated.join("\n"))
|
||||
const next = splitBom(updated.join("\n"))
|
||||
return { content: next.text, bom: source.bom || next.bom }
|
||||
}
|
||||
|
||||
export function joinBom(text: string, bom: boolean) {
|
||||
return Bom.join(text, bom)
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function parseAdd(
|
||||
@@ -379,4 +379,6 @@ const normalize = (value: string) =>
|
||||
.replace(/[“”„‟]/g, '"')
|
||||
.replace(/[‐‑‒–—―−]/g, "-")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input
|
||||
|
||||
Reference in New Issue
Block a user