mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-16 10:54:52 +00:00
Compare commits
16 Commits
github-v1.
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8af8f96ce | ||
|
|
5011465c81 | ||
|
|
f6cc228684 | ||
|
|
9f4b73b6a3 | ||
|
|
bd29004831 | ||
|
|
8aa0f9fe95 | ||
|
|
c802695ee9 | ||
|
|
225a769411 | ||
|
|
0e20382396 | ||
|
|
509bc11f81 | ||
|
|
f24207844f | ||
|
|
1ca257e356 | ||
|
|
d4cfbd020d | ||
|
|
581d5208ca | ||
|
|
a427a28fa9 | ||
|
|
0beaf04df5 |
@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"categories": {
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-base-to-string": "warn",
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
@@ -33,10 +37,15 @@
|
||||
"no-new": "off",
|
||||
|
||||
// Type-aware: catch unhandled promises
|
||||
"typescript/no-floating-promises": "warn"
|
||||
"typescript/no-floating-promises": "warn",
|
||||
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
||||
"typescript/no-misused-spread": "warn"
|
||||
},
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
|
||||
let ws: WebSocket | undefined
|
||||
let term: Term | undefined
|
||||
let ghostty: Ghostty
|
||||
let _ghostty: Ghostty
|
||||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
@@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
ghostty = g
|
||||
_ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { dict as en } from "./en"
|
||||
|
||||
export const dict = {
|
||||
"command.category.suggested": "추천",
|
||||
"command.category.view": "보기",
|
||||
|
||||
@@ -432,7 +432,6 @@ export default function Page() {
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
|
||||
@@ -16,7 +16,10 @@ export function createSdkForServer({
|
||||
|
||||
return createOpencodeClient({
|
||||
...config,
|
||||
headers: { ...config.headers, ...auth },
|
||||
headers: {
|
||||
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
|
||||
...auth,
|
||||
},
|
||||
baseUrl: server.url,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
|
||||
const headers = new Headers(resp.headers)
|
||||
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
|
||||
|
||||
return new Response(resp.body, { ...resp, headers })
|
||||
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers })
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { github } from "~/lib/github"
|
||||
import { createMemo } from "solid-js"
|
||||
import { config } from "~/config"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
@@ -30,7 +29,7 @@ function CopyStatus() {
|
||||
export default function Home() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const githubData = createAsync(() => github())
|
||||
const _githubData = createAsync(() => github())
|
||||
const handleCopyClick = (event: Event) => {
|
||||
const button = event.currentTarget as HTMLButtonElement
|
||||
const text = button.textContent
|
||||
|
||||
@@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
|
||||
const setUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
|
||||
@@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const setMonthlyLimit = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const limit = form.get("limit")?.toString()
|
||||
const limit = form.get("limit") as string | null
|
||||
if (!limit) return { error: formError.limitRequired }
|
||||
const numericLimit = parseInt(limit)
|
||||
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz
|
||||
|
||||
const reload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||
revalidate: queryBillingInfo.key,
|
||||
@@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => {
|
||||
|
||||
const setReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const reloadValue = form.get("reload")?.toString() === "true"
|
||||
const amountStr = form.get("reloadAmount")?.toString()
|
||||
const triggerStr = form.get("reloadTrigger")?.toString()
|
||||
const reloadValue = (form.get("reload") as string | null) === "true"
|
||||
const amountStr = form.get("reloadAmount") as string | null
|
||||
const triggerStr = form.get("reloadTrigger") as string | null
|
||||
|
||||
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
|
||||
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
|
||||
@@ -91,8 +91,8 @@ export function ReloadSection() {
|
||||
const info = billingInfo()!
|
||||
setStore("show", true)
|
||||
setStore("reload", true)
|
||||
setStore("reloadAmount", info.reloadAmount.toString())
|
||||
setStore("reloadTrigger", info.reloadTrigger.toString())
|
||||
setStore("reloadAmount", String(info.reloadAmount))
|
||||
setStore("reloadTrigger", String(info.reloadTrigger))
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -152,11 +152,11 @@ export function ReloadSection() {
|
||||
data-component="input"
|
||||
name="reloadAmount"
|
||||
type="number"
|
||||
min={billingInfo()?.reloadAmountMin.toString()}
|
||||
min={String(billingInfo()?.reloadAmountMin ?? "")}
|
||||
step="1"
|
||||
value={store.reloadAmount}
|
||||
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
|
||||
placeholder={billingInfo()?.reloadAmount.toString()}
|
||||
placeholder={String(billingInfo()?.reloadAmount ?? "")}
|
||||
disabled={!store.reload}
|
||||
/>
|
||||
</div>
|
||||
@@ -166,11 +166,11 @@ export function ReloadSection() {
|
||||
data-component="input"
|
||||
name="reloadTrigger"
|
||||
type="number"
|
||||
min={billingInfo()?.reloadTriggerMin.toString()}
|
||||
min={String(billingInfo()?.reloadTriggerMin ?? "")}
|
||||
step="1"
|
||||
value={store.reloadTrigger}
|
||||
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
|
||||
placeholder={billingInfo()?.reloadTrigger.toString()}
|
||||
placeholder={String(billingInfo()?.reloadTrigger ?? "")}
|
||||
disabled={!store.reload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
|
||||
const setLiteUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
|
||||
@@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const removeKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id")?.toString()
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
|
||||
}, "key.remove")
|
||||
|
||||
const createKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = form.get("name")?.toString().trim()
|
||||
const name = (form.get("name") as string | null)?.trim()
|
||||
if (!name) return { error: formError.nameRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
|
||||
@@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => {
|
||||
|
||||
const inviteMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const email = form.get("email")?.toString().trim()
|
||||
const email = (form.get("email") as string | null)?.trim()
|
||||
if (!email) return { error: formError.emailRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit")?.toString()
|
||||
const limit = form.get("limit") as string | null
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
return json(
|
||||
@@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
|
||||
|
||||
const removeMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id")?.toString()
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
@@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => {
|
||||
const updateMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
|
||||
const id = form.get("id")?.toString()
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit")?.toString()
|
||||
const limit = form.get("limit") as string | null
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
|
||||
@@ -118,7 +118,7 @@ function MemberRow(props: {
|
||||
}
|
||||
setStore("editing", true)
|
||||
setStore("selectedRole", props.member.role)
|
||||
setStore("limit", props.member.monthlyLimit?.toString() ?? "")
|
||||
setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
|
||||
}
|
||||
|
||||
function hide() {
|
||||
|
||||
@@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
||||
|
||||
const updateModel = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const model = form.get("model")?.toString()
|
||||
const model = form.get("model") as string | null
|
||||
if (!model) return { error: formError.modelRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const enabled = form.get("enabled")?.toString() === "true"
|
||||
const enabled = (form.get("enabled") as string | null) === "true"
|
||||
return json(
|
||||
withActor(async () => {
|
||||
if (enabled) {
|
||||
@@ -163,7 +163,7 @@ export function ModelSection() {
|
||||
<form action={updateModel} method="post">
|
||||
<input type="hidden" name="model" value={id} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="enabled" value={isEnabled().toString()} />
|
||||
<input type="hidden" name="enabled" value={String(isEnabled())} />
|
||||
<label data-slot="model-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -21,9 +21,9 @@ function maskCredentials(credentials: string) {
|
||||
|
||||
const removeProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider")?.toString()
|
||||
const provider = form.get("provider") as string | null
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
|
||||
revalidate: listProviders.key,
|
||||
@@ -32,11 +32,11 @@ const removeProvider = action(async (form: FormData) => {
|
||||
|
||||
const saveProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider")?.toString()
|
||||
const credentials = form.get("credentials")?.toString()
|
||||
const provider = form.get("provider") as string | null
|
||||
const credentials = form.get("credentials") as string | null
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
if (!credentials) return { error: formError.apiKeyRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
@@ -59,10 +59,13 @@ function ProviderRow(props: { provider: Provider }) {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const providers = createAsync(() => listProviders(params.id!))
|
||||
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
|
||||
const saveSubmission = useSubmission(
|
||||
saveProvider,
|
||||
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||
)
|
||||
const removeSubmission = useSubmission(
|
||||
removeProvider,
|
||||
([fd]) => fd.get("provider")?.toString() === props.provider.key,
|
||||
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||
)
|
||||
const [store, setStore] = createStore({ editing: false })
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
|
||||
|
||||
const updateWorkspace = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = form.get("name")?.toString().trim()
|
||||
const name = (form.get("name") as string | null)?.trim()
|
||||
if (!name) return { error: formError.workspaceNameRequired }
|
||||
if (name.length > 255) return { error: formError.nameTooLong }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
|
||||
@@ -68,23 +68,6 @@ function findBinary() {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareBinDirectory(binaryName) {
|
||||
const binDir = path.join(__dirname, "bin")
|
||||
const targetPath = path.join(binDir, binaryName)
|
||||
|
||||
// Ensure bin directory exists
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Remove existing binary/symlink if it exists
|
||||
if (fs.existsSync(targetPath)) {
|
||||
fs.unlinkSync(targetPath)
|
||||
}
|
||||
|
||||
return { binDir, targetPath }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod"
|
||||
import { Config } from "../src/config"
|
||||
import { TuiConfig } from "../src/config/tui"
|
||||
import { TuiConfig } from "../src/config"
|
||||
|
||||
function generate(schema: z.ZodType) {
|
||||
const result = z.toJSONSchema(schema, {
|
||||
@@ -33,7 +33,7 @@ function generate(schema: z.ZodType) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
||||
schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
|
||||
@@ -442,3 +442,58 @@ Going forward:
|
||||
- If a file grows large enough that it's hard to navigate, split by concern
|
||||
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
|
||||
bundler handles that.
|
||||
|
||||
## Circular import rules
|
||||
|
||||
Barrel files (`index.ts` with `export * as`) introduce circular import risks.
|
||||
These cause `ReferenceError: Cannot access 'X' before initialization` at
|
||||
runtime — not caught by the type checker.
|
||||
|
||||
### Rule 1: Sibling files never import through their own barrel
|
||||
|
||||
Files in the same directory must import directly from the source file, never
|
||||
through `"."` or `"@/<own-dir>"`:
|
||||
|
||||
```ts
|
||||
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
|
||||
import { Sibling } from "."
|
||||
|
||||
// GOOD — direct, no cycle
|
||||
import * as Sibling from "./sibling"
|
||||
```
|
||||
|
||||
### Rule 2: Cross-directory imports must not form cycles through barrels
|
||||
|
||||
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
|
||||
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
|
||||
|
||||
```
|
||||
lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
|
||||
```
|
||||
|
||||
Fix by importing the specific file, breaking the cycle:
|
||||
|
||||
```ts
|
||||
// In config/config.ts — import directly, not through the lsp barrel
|
||||
import * as LSPServer from "../lsp/server"
|
||||
```
|
||||
|
||||
### Why the type checker doesn't catch this
|
||||
|
||||
TypeScript resolves types lazily — it doesn't evaluate module-scope
|
||||
expressions. The `ReferenceError` only happens at runtime when a module-scope
|
||||
`const` or function call accesses a value from a circular dependency that
|
||||
hasn't finished initializing. The SDK build step (`bun run --conditions=browser
|
||||
./src/index.ts generate`) is the reliable way to catch these because it
|
||||
evaluates all modules eagerly.
|
||||
|
||||
### How to verify
|
||||
|
||||
After any namespace conversion, run:
|
||||
|
||||
```bash
|
||||
cd packages/opencode
|
||||
bun run --conditions=browser ./src/index.ts generate
|
||||
```
|
||||
|
||||
If this completes without `ReferenceError`, the module graph is safe.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@/storage"
|
||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
||||
import { normalizeServerUrl } from "./url"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Provider } from "../provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncate"
|
||||
import { Truncate } from "../tool"
|
||||
import { Auth } from "../auth"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { spawn } from "child_process"
|
||||
import { Database } from "../../storage/db"
|
||||
import { Database } from "../../storage"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Database as BunDatabase } from "bun:sqlite"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { JsonMigration } from "../../storage"
|
||||
import { EOL } from "os"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Provider } from "../../../provider"
|
||||
import { Session } from "../../../session"
|
||||
import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { ToolRegistry } from "../../../tool"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EOL } from "os"
|
||||
import { Project } from "../../../project/project"
|
||||
import { Project } from "../../../project"
|
||||
import { Log } from "../../../util"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { cmd } from "./cmd"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SessionShare } from "@/share"
|
||||
import { Session } from "../../session"
|
||||
import type { SessionID } from "../../session/schema"
|
||||
import { MessageID, PartID } from "../../session/schema"
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Session } from "../../session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Database } from "../../storage/db"
|
||||
import { Database } from "../../storage"
|
||||
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { ShareNext } from "../../share/share-next"
|
||||
import { ShareNext } from "../../share"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||
import type { Argv } from "yargs"
|
||||
|
||||
import { ConfigPaths } from "../../config/paths"
|
||||
import { ConfigPaths } from "../../config"
|
||||
import { Global } from "../../global"
|
||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||
import { resolvePluginTarget } from "../../plugin/shared"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Permission } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { Tool } from "../../tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
import { ReadTool } from "../../tool/read"
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { Session } from "../../session"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Database } from "../../storage/db"
|
||||
import { Database } from "../../storage"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "../../project/project"
|
||||
import { Project } from "../../project"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { Terminal } from "@tui/util/terminal"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as Terminal from "@tui/util/terminal"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { TuiConfig } from "@/config"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cmd } from "../cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { TuiConfig } from "@/config"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import { Sound } from "@tui/util/sound"
|
||||
import * as Sound from "@tui/util/sound"
|
||||
import { logo } from "@/cli/logo"
|
||||
|
||||
// Shadow markers (rendered chars in parens):
|
||||
@@ -520,7 +520,7 @@ export function Logo() {
|
||||
const shadow = tint(theme.background, ink, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
|
||||
return [...line].map((char, i) => {
|
||||
return Array.from(line).map((char, i) => {
|
||||
const h = field(off + i, y, frame)
|
||||
const n = wave(off + i, y, frame, lit(char)) + h
|
||||
const s = wave(off + i, y, dusk, false) + h
|
||||
|
||||
@@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer, type JSX } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import * as Editor from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
import * as Clipboard from "../../util/clipboard"
|
||||
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { TuiEvent } from "../../event"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { Keybind } from "@/util"
|
||||
import { pipe, mapValues } from "remeda"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import type { TuiConfig } from "@/config"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { TuiConfig } from "@/config"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
|
||||
import type { useSync } from "@tui/context/sync"
|
||||
import type { useTheme } from "@tui/context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import type { TuiConfig } from "@/config"
|
||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||
import type { useKV } from "../context/kv"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
|
||||
@@ -14,7 +14,7 @@ import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Config } from "@/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { TuiConfig } from "@/config"
|
||||
import { Log } from "@/util"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { Locale } from "@/util"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
import type { Tool } from "@/tool"
|
||||
import type { ReadTool } from "@/tool/read"
|
||||
import type { WriteTool } from "@/tool/write"
|
||||
import { BashTool } from "@/tool/bash"
|
||||
@@ -66,10 +66,10 @@ import { SubagentFooter } from "./subagent-footer.tsx"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import parsers from "../../../../../../parsers-config.ts"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
import * as Clipboard from "../../util/clipboard"
|
||||
import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import { Editor } from "../../util/editor"
|
||||
import * as Editor from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Filesystem } from "@/util"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { TuiConfig } from "@/config"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
|
||||
@@ -22,171 +22,169 @@ function writeOsc52(text: string): void {
|
||||
process.stdout.write(sequence)
|
||||
}
|
||||
|
||||
export namespace Clipboard {
|
||||
export interface Content {
|
||||
data: string
|
||||
mime: string
|
||||
}
|
||||
export interface Content {
|
||||
data: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
// Checks clipboard for images first, then falls back to text.
|
||||
//
|
||||
// On Windows prompt/ can call this from multiple paste signals because
|
||||
// terminals surface image paste differently:
|
||||
// 1. A forwarded Ctrl+V keypress
|
||||
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
||||
// Terminal <1.25
|
||||
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
// Checks clipboard for images first, then falls back to text.
|
||||
//
|
||||
// On Windows prompt/ can call this from multiple paste signals because
|
||||
// terminals surface image paste differently:
|
||||
// 1. A forwarded Ctrl+V keypress
|
||||
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
||||
// Terminal <1.25
|
||||
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin") {
|
||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await Process.run(
|
||||
[
|
||||
"osascript",
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
const buffer = await Filesystem.readBytes(tmpfile)
|
||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
} finally {
|
||||
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Windows/WSL: probe clipboard for images via PowerShell.
|
||||
// Bracketed paste can't carry image data so we read it directly.
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (base64.text) {
|
||||
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
|
||||
if (wayland.stdout.byteLength > 0) {
|
||||
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (x11.stdout.byteLength > 0) {
|
||||
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
|
||||
const text = await clipboardy.read().catch(() => {})
|
||||
if (text) {
|
||||
return { data: text, mime: "text/plain" }
|
||||
if (os === "darwin") {
|
||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await Process.run(
|
||||
[
|
||||
"osascript",
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
const buffer = await Filesystem.readBytes(tmpfile)
|
||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
} finally {
|
||||
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const getCopyMethod = lazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && which("osascript")) {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
|
||||
// Windows/WSL: probe clipboard for images via PowerShell.
|
||||
// Bracketed paste can't carry image data so we read it directly.
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (base64.text) {
|
||||
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (os === "linux") {
|
||||
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
|
||||
if (wayland.stdout.byteLength > 0) {
|
||||
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (x11.stdout.byteLength > 0) {
|
||||
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "win32") {
|
||||
console.log("clipboard: using powershell")
|
||||
const text = await clipboardy.read().catch(() => {})
|
||||
if (text) {
|
||||
return { data: text, mime: "text/plain" }
|
||||
}
|
||||
}
|
||||
|
||||
const getCopyMethod = lazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && which("osascript")) {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Process.spawn(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
console.log("clipboard: no native support")
|
||||
return async (text: string) => {
|
||||
await clipboardy.write(text).catch(() => {})
|
||||
if (which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
writeOsc52(text)
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
|
||||
if (os === "win32") {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Process.spawn(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
console.log("clipboard: no native support")
|
||||
return async (text: string) => {
|
||||
await clipboardy.write(text).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
writeOsc52(text)
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
|
||||
@@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core"
|
||||
import { Filesystem } from "@/util"
|
||||
import { Process } from "@/util"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
||||
if (!editor) return
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
||||
if (!editor) return
|
||||
|
||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||
|
||||
await Filesystem.write(filepath, opts.value)
|
||||
opts.renderer.suspend()
|
||||
await Filesystem.write(filepath, opts.value)
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
try {
|
||||
const parts = editor.split(" ")
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
return content || undefined
|
||||
} finally {
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
try {
|
||||
const parts = editor.split(" ")
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
return content || undefined
|
||||
} finally {
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
}
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
}
|
||||
}
|
||||
|
||||
5
packages/opencode/src/cli/cmd/tui/util/index.ts
Normal file
5
packages/opencode/src/cli/cmd/tui/util/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * as Editor from "./editor"
|
||||
export * as Selection from "./selection"
|
||||
export * as Sound from "./sound"
|
||||
export * as Terminal from "./terminal"
|
||||
export * as Clipboard from "./clipboard"
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import type { TuiConfig } from "@/config"
|
||||
|
||||
export class CustomSpeedScroll implements ScrollAcceleration {
|
||||
constructor(private speed: number) {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clipboard } from "./clipboard"
|
||||
import * as Clipboard from "./clipboard"
|
||||
|
||||
type Toast = {
|
||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||
@@ -10,16 +10,14 @@ type Renderer = {
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export namespace Selection {
|
||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text) return false
|
||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text) return false
|
||||
|
||||
Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) {
|
||||
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
||||
}
|
||||
|
||||
export namespace Sound {
|
||||
let item: Player | null | undefined
|
||||
let kind: Kind | null | undefined
|
||||
let proc: Process.Child | undefined
|
||||
let tail: ReturnType<typeof setTimeout> | undefined
|
||||
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||
let seq = 0
|
||||
let shot = 0
|
||||
let item: Player | null | undefined
|
||||
let kind: Kind | null | undefined
|
||||
let proc: Process.Child | undefined
|
||||
let tail: ReturnType<typeof setTimeout> | undefined
|
||||
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||
let seq = 0
|
||||
let shot = 0
|
||||
|
||||
function load() {
|
||||
if (item !== undefined) return item
|
||||
try {
|
||||
item = new Player({ volume: 0.35 })
|
||||
} catch {
|
||||
item = null
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
async function file(path: string) {
|
||||
mkdirSync(DIR, { recursive: true })
|
||||
const next = join(DIR, basename(path))
|
||||
const out = Bun.file(next)
|
||||
if (await out.exists()) return next
|
||||
await Bun.write(out, Bun.file(path))
|
||||
return next
|
||||
}
|
||||
|
||||
function asset() {
|
||||
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
||||
return cache
|
||||
}
|
||||
|
||||
function pick() {
|
||||
if (kind !== undefined) return kind
|
||||
kind = LIST.find((item) => which(item)) ?? null
|
||||
return kind
|
||||
}
|
||||
|
||||
function run(file: string, volume: number) {
|
||||
const kind = pick()
|
||||
if (!kind) return
|
||||
return Process.spawn(args(kind, file, volume), {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!tail) return
|
||||
clearTimeout(tail)
|
||||
tail = undefined
|
||||
}
|
||||
|
||||
function play(file: string, volume: number) {
|
||||
const item = load()
|
||||
if (!item) return run(file, volume)?.exited
|
||||
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
||||
}
|
||||
|
||||
export function start() {
|
||||
stop()
|
||||
const id = ++seq
|
||||
void asset().then(({ hum }) => {
|
||||
if (id !== seq) return
|
||||
const next = run(hum, 0.24)
|
||||
if (!next) return
|
||||
proc = next
|
||||
void next.exited.then(
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function stop(delay = 0) {
|
||||
seq++
|
||||
clear()
|
||||
if (!proc) return
|
||||
const next = proc
|
||||
if (delay <= 0) {
|
||||
proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
tail = setTimeout(() => {
|
||||
tail = undefined
|
||||
if (proc === next) proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export function pulse(scale = 1) {
|
||||
stop(140)
|
||||
const index = shot++ % FILE.length
|
||||
void asset()
|
||||
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
stop()
|
||||
function load() {
|
||||
if (item !== undefined) return item
|
||||
try {
|
||||
item = new Player({ volume: 0.35 })
|
||||
} catch {
|
||||
item = null
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
async function file(path: string) {
|
||||
mkdirSync(DIR, { recursive: true })
|
||||
const next = join(DIR, basename(path))
|
||||
const out = Bun.file(next)
|
||||
if (await out.exists()) return next
|
||||
await Bun.write(out, Bun.file(path))
|
||||
return next
|
||||
}
|
||||
|
||||
function asset() {
|
||||
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
||||
return cache
|
||||
}
|
||||
|
||||
function pick() {
|
||||
if (kind !== undefined) return kind
|
||||
kind = LIST.find((item) => which(item)) ?? null
|
||||
return kind
|
||||
}
|
||||
|
||||
function run(file: string, volume: number) {
|
||||
const kind = pick()
|
||||
if (!kind) return
|
||||
return Process.spawn(args(kind, file, volume), {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!tail) return
|
||||
clearTimeout(tail)
|
||||
tail = undefined
|
||||
}
|
||||
|
||||
function play(file: string, volume: number) {
|
||||
const item = load()
|
||||
if (!item) return run(file, volume)?.exited
|
||||
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
||||
}
|
||||
|
||||
export function start() {
|
||||
stop()
|
||||
const id = ++seq
|
||||
void asset().then(({ hum }) => {
|
||||
if (id !== seq) return
|
||||
const next = run(hum, 0.24)
|
||||
if (!next) return
|
||||
proc = next
|
||||
void next.exited.then(
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function stop(delay = 0) {
|
||||
seq++
|
||||
clear()
|
||||
if (!proc) return
|
||||
const next = proc
|
||||
if (delay <= 0) {
|
||||
proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
tail = setTimeout(() => {
|
||||
tail = undefined
|
||||
if (proc === next) proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export function pulse(scale = 1) {
|
||||
stop(140)
|
||||
const index = shot++ % FILE.length
|
||||
void asset()
|
||||
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
stop()
|
||||
}
|
||||
|
||||
@@ -1,137 +1,135 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export namespace Terminal {
|
||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||
|
||||
function parse(color: string): RGBA | null {
|
||||
if (color.startsWith("rgb:")) {
|
||||
const parts = color.substring(4).split("/")
|
||||
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||
}
|
||||
if (color.startsWith("#")) {
|
||||
return RGBA.fromHex(color)
|
||||
}
|
||||
if (color.startsWith("rgb(")) {
|
||||
const parts = color.substring(4, color.length - 1).split(",")
|
||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||
}
|
||||
return null
|
||||
function parse(color: string): RGBA | null {
|
||||
if (color.startsWith("rgb:")) {
|
||||
const parts = color.substring(4).split("/")
|
||||
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA | null): "dark" | "light" {
|
||||
if (!bg) return "dark"
|
||||
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||
return luminance > 0.5 ? "light" : "dark"
|
||||
if (color.startsWith("#")) {
|
||||
return RGBA.fromHex(color)
|
||||
}
|
||||
if (color.startsWith("rgb(")) {
|
||||
const parts = color.substring(4, color.length - 1).split(",")
|
||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Query terminal colors including background, foreground, and palette (0-15).
|
||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||
*
|
||||
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
|
||||
* OSC 10/11 (foreground/background) typically work in most environments.
|
||||
*
|
||||
* Returns an object with background, foreground, and colors array.
|
||||
* Any query that fails will be null/empty.
|
||||
*/
|
||||
export async function colors(): Promise<{
|
||||
background: RGBA | null
|
||||
foreground: RGBA | null
|
||||
colors: RGBA[]
|
||||
}> {
|
||||
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
|
||||
function mode(bg: RGBA | null): "dark" | "light" {
|
||||
if (!bg) return "dark"
|
||||
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||
return luminance > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let background: RGBA | null = null
|
||||
let foreground: RGBA | null = null
|
||||
const paletteColors: RGBA[] = []
|
||||
let timeout: NodeJS.Timeout
|
||||
/**
|
||||
* Query terminal colors including background, foreground, and palette (0-15).
|
||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||
*
|
||||
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
|
||||
* OSC 10/11 (foreground/background) typically work in most environments.
|
||||
*
|
||||
* Returns an object with background, foreground, and colors array.
|
||||
* Any query that fails will be null/empty.
|
||||
*/
|
||||
export async function colors(): Promise<{
|
||||
background: RGBA | null
|
||||
foreground: RGBA | null
|
||||
colors: RGBA[]
|
||||
}> {
|
||||
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
return new Promise((resolve) => {
|
||||
let background: RGBA | null = null
|
||||
let foreground: RGBA | null = null
|
||||
const paletteColors: RGBA[] = []
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
|
||||
// Match OSC 11 (background color)
|
||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (bgMatch) {
|
||||
background = parse(bgMatch[1])
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
|
||||
// Match OSC 11 (background color)
|
||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (bgMatch) {
|
||||
background = parse(bgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 10 (foreground color)
|
||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||
if (fgMatch) {
|
||||
foreground = parse(fgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 4 (palette colors)
|
||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||
for (const match of paletteMatches) {
|
||||
const index = parseInt(match[1])
|
||||
const color = parse(match[2])
|
||||
if (color) paletteColors[index] = color
|
||||
}
|
||||
|
||||
// Return immediately if we have all 16 palette colors
|
||||
if (paletteColors.filter((c) => c !== undefined).length === 16) {
|
||||
cleanup()
|
||||
resolve({ background, foreground, colors: paletteColors })
|
||||
}
|
||||
// Match OSC 10 (foreground color)
|
||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||
if (fgMatch) {
|
||||
foreground = parse(fgMatch[1])
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
|
||||
// Query background (OSC 11)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
// Query foreground (OSC 10)
|
||||
process.stdout.write("\x1b]10;?\x07")
|
||||
// Query palette colors 0-15 (OSC 4)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
process.stdout.write(`\x1b]4;${i};?\x07`)
|
||||
// Match OSC 4 (palette colors)
|
||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||
for (const match of paletteMatches) {
|
||||
const index = parseInt(match[1])
|
||||
const color = parse(match[2])
|
||||
if (color) paletteColors[index] = color
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
// Return immediately if we have all 16 palette colors
|
||||
if (paletteColors.filter((c) => c !== undefined).length === 16) {
|
||||
cleanup()
|
||||
resolve({ background, foreground, colors: paletteColors })
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
||||
// needs OSC 11 and should resolve on the first background response instead of
|
||||
// waiting on the full palette query used by system theme generation.
|
||||
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
if (!process.stdin.isTTY) return "dark"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (!match) return
|
||||
cleanup()
|
||||
resolve(mode(parse(match[1])))
|
||||
}
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
// Query background (OSC 11)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
// Query foreground (OSC 10)
|
||||
process.stdout.write("\x1b]10;?\x07")
|
||||
// Query palette colors 0-15 (OSC 4)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
process.stdout.write(`\x1b]4;${i};?\x07`)
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve("dark")
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve({ background, foreground, colors: paletteColors })
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
||||
// needs OSC 11 and should resolve on the first background response instead of
|
||||
// waiting on the full palette query used by system theme generation.
|
||||
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
if (!process.stdin.isTTY) return "dark"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (!match) return
|
||||
cleanup()
|
||||
resolve(mode(parse(match[1])))
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve("dark")
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AccountServiceError, AccountTransportError } from "@/account"
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { ConfigMarkdown } from "@/config"
|
||||
import { errorFormat } from "@/util/error"
|
||||
import { Config } from "../config"
|
||||
import { MCP } from "../mcp"
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
printParseErrorCode,
|
||||
} from "jsonc-parser"
|
||||
import { Instance, type InstanceContext } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import * as LSPServer from "../lsp/server"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
@@ -29,7 +29,7 @@ import { Event } from "../server/event"
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
import { Account } from "@/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import * as ConfigPaths from "./paths"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { InstanceState } from "@/effect"
|
||||
@@ -1297,7 +1297,7 @@ export const layer: Layer.Layer<
|
||||
yield* Effect.promise(() => Npm.install(dir))
|
||||
})
|
||||
|
||||
const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, input?: InstallInput) {
|
||||
const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, _input?: InstallInput) {
|
||||
if (
|
||||
!(yield* fs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * as Config from "./config"
|
||||
export * as ConfigMarkdown from "./markdown"
|
||||
export * as ConfigPaths from "./paths"
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
@@ -3,97 +3,95 @@ import matter from "gray-matter"
|
||||
import { z } from "zod"
|
||||
import { Filesystem } from "../util"
|
||||
|
||||
export namespace ConfigMarkdown {
|
||||
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
export const SHELL_REGEX = /!`([^`]+)`/g
|
||||
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
export const SHELL_REGEX = /!`([^`]+)`/g
|
||||
|
||||
export function files(template: string) {
|
||||
return Array.from(template.matchAll(FILE_REGEX))
|
||||
}
|
||||
|
||||
export function shell(template: string) {
|
||||
return Array.from(template.matchAll(SHELL_REGEX))
|
||||
}
|
||||
|
||||
// other coding agents like claude code allow invalid yaml in their
|
||||
// frontmatter, we need to fallback to a more permissive parser for those cases
|
||||
export function fallbackSanitization(content: string): string {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!match) return content
|
||||
|
||||
const frontmatter = match[1]
|
||||
const lines = frontmatter.split(/\r?\n/)
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// skip comments and empty lines
|
||||
if (line.trim().startsWith("#") || line.trim() === "") {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// skip lines that are continuations (indented)
|
||||
if (line.match(/^\s+/)) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// match key: value pattern
|
||||
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
|
||||
if (!kvMatch) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const key = kvMatch[1]
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
// skip if value is empty, already quoted, or uses block scalar
|
||||
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// if value contains a colon, convert to block scalar
|
||||
if (value.includes(":")) {
|
||||
result.push(`${key}: |-`)
|
||||
result.push(` ${value}`)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
const processed = result.join("\n")
|
||||
return content.replace(frontmatter, () => processed)
|
||||
}
|
||||
|
||||
export async function parse(filePath: string) {
|
||||
const template = await Filesystem.readText(filePath)
|
||||
|
||||
try {
|
||||
const md = matter(template)
|
||||
return md
|
||||
} catch {
|
||||
try {
|
||||
return matter(fallbackSanitization(template))
|
||||
} catch (err) {
|
||||
throw new FrontmatterError(
|
||||
{
|
||||
path: filePath,
|
||||
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
{ cause: err },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FrontmatterError = NamedError.create(
|
||||
"ConfigFrontmatterError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
export function files(template: string) {
|
||||
return Array.from(template.matchAll(FILE_REGEX))
|
||||
}
|
||||
|
||||
export function shell(template: string) {
|
||||
return Array.from(template.matchAll(SHELL_REGEX))
|
||||
}
|
||||
|
||||
// other coding agents like claude code allow invalid yaml in their
|
||||
// frontmatter, we need to fallback to a more permissive parser for those cases
|
||||
export function fallbackSanitization(content: string): string {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!match) return content
|
||||
|
||||
const frontmatter = match[1]
|
||||
const lines = frontmatter.split(/\r?\n/)
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// skip comments and empty lines
|
||||
if (line.trim().startsWith("#") || line.trim() === "") {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// skip lines that are continuations (indented)
|
||||
if (line.match(/^\s+/)) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// match key: value pattern
|
||||
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
|
||||
if (!kvMatch) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const key = kvMatch[1]
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
// skip if value is empty, already quoted, or uses block scalar
|
||||
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// if value contains a colon, convert to block scalar
|
||||
if (value.includes(":")) {
|
||||
result.push(`${key}: |-`)
|
||||
result.push(` ${value}`)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
const processed = result.join("\n")
|
||||
return content.replace(frontmatter, () => processed)
|
||||
}
|
||||
|
||||
export async function parse(filePath: string) {
|
||||
const template = await Filesystem.readText(filePath)
|
||||
|
||||
try {
|
||||
const md = matter(template)
|
||||
return md
|
||||
} catch {
|
||||
try {
|
||||
return matter(fallbackSanitization(template))
|
||||
} catch (err) {
|
||||
throw new FrontmatterError(
|
||||
{
|
||||
path: filePath,
|
||||
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
{ cause: err },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FrontmatterError = NamedError.create(
|
||||
"ConfigFrontmatterError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,161 +7,159 @@ import { Filesystem } from "@/util"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace ConfigPaths {
|
||||
export async function projectFiles(name: string, directory: string, worktree: string) {
|
||||
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
||||
}
|
||||
|
||||
export async function directories(directory: string, worktree: string) {
|
||||
return [
|
||||
Global.Path.config,
|
||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
...(await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: Global.Path.home,
|
||||
stop: Global.Path.home,
|
||||
}),
|
||||
)),
|
||||
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
||||
]
|
||||
}
|
||||
|
||||
export function fileInDirectory(dir: string, name: string) {
|
||||
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
||||
}
|
||||
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"ConfigInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
||||
export async function readFile(filepath: string) {
|
||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
}
|
||||
|
||||
type ParseSource = string | { source: string; dir: string }
|
||||
|
||||
function source(input: ParseSource) {
|
||||
return typeof input === "string" ? input : input.source
|
||||
}
|
||||
|
||||
function dir(input: ParseSource) {
|
||||
return typeof input === "string" ? path.dirname(input) : input.dir
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = dir(input)
|
||||
const configSource = source(input)
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (missing === "empty") return ""
|
||||
|
||||
const errMsg = `bad file reference: "${token}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
|
||||
out += JSON.stringify(fileContent).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
|
||||
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
const configSource = source(input)
|
||||
text = await substitute(text, input, missing)
|
||||
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
const lines = text.split("\n")
|
||||
const errorDetails = errors
|
||||
.map((e) => {
|
||||
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||
const line = beforeOffset.length
|
||||
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||
const problemLine = lines[line - 1]
|
||||
|
||||
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||
if (!problemLine) return error
|
||||
|
||||
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: configSource,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
export async function projectFiles(name: string, directory: string, worktree: string) {
|
||||
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
||||
}
|
||||
|
||||
export async function directories(directory: string, worktree: string) {
|
||||
return [
|
||||
Global.Path.config,
|
||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
...(await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: Global.Path.home,
|
||||
stop: Global.Path.home,
|
||||
}),
|
||||
)),
|
||||
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
||||
]
|
||||
}
|
||||
|
||||
export function fileInDirectory(dir: string, name: string) {
|
||||
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
||||
}
|
||||
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"ConfigInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
||||
export async function readFile(filepath: string) {
|
||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
}
|
||||
|
||||
type ParseSource = string | { source: string; dir: string }
|
||||
|
||||
function source(input: ParseSource) {
|
||||
return typeof input === "string" ? input : input.source
|
||||
}
|
||||
|
||||
function dir(input: ParseSource) {
|
||||
return typeof input === "string" ? path.dirname(input) : input.dir
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = dir(input)
|
||||
const configSource = source(input)
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (missing === "empty") return ""
|
||||
|
||||
const errMsg = `bad file reference: "${token}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
|
||||
out += JSON.stringify(fileContent).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
|
||||
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
const configSource = source(input)
|
||||
text = await substitute(text, input, missing)
|
||||
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
const lines = text.split("\n")
|
||||
const errorDetails = errors
|
||||
.map((e) => {
|
||||
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||
const line = beforeOffset.length
|
||||
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||
const problemLine = lines[line - 1]
|
||||
|
||||
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||
if (!problemLine) return error
|
||||
|
||||
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: configSource,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path"
|
||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||
import { unique } from "remeda"
|
||||
import z from "zod"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import * as ConfigPaths from "./paths"
|
||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod"
|
||||
import { Config } from "."
|
||||
import * as Config from "./config"
|
||||
|
||||
const KeybindOverride = z
|
||||
.object(
|
||||
|
||||
@@ -2,8 +2,8 @@ import { existsSync } from "fs"
|
||||
import z from "zod"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { Config } from "."
|
||||
import { ConfigPaths } from "./paths"
|
||||
import * as Config from "./config"
|
||||
import * as ConfigPaths from "./paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { TuiInfo } from "./tui-schema"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -14,201 +14,199 @@ import { InstanceState } from "@/effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export const Info = TuiInfo
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
}
|
||||
type Acc = {
|
||||
result: Info
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: Config.PluginOrigin[]
|
||||
}
|
||||
export type Info = z.output<typeof Info> & {
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: Config.PluginOrigin[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
||||
}
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||
|
||||
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
||||
if (AppFileSystem.contains(ctx.directory, file)) return "local"
|
||||
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
||||
if (AppFileSystem.contains(ctx.directory, file)) return "local"
|
||||
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
return Flag.OPENCODE_TUI_CONFIG
|
||||
}
|
||||
function customPath() {
|
||||
return Flag.OPENCODE_TUI_CONFIG
|
||||
}
|
||||
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return data
|
||||
}
|
||||
|
||||
const tui = data.tui
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = Config.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
|
||||
const custom = customPath()
|
||||
const managed = Config.managedConfigDir()
|
||||
await migrateTuiConfig({ directories, custom, managed })
|
||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(acc, custom, ctx)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...acc.result.keybinds }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
||||
",",
|
||||
)
|
||||
}
|
||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("TuiConfig.state")(function* (ctx) {
|
||||
const data = yield* Effect.promise(() => loadState(ctx))
|
||||
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return { config: data.config, deps }
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
InstanceState.useEffect(state, (s) =>
|
||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ get, waitForDependencies })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!isRecord(raw)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = normalize(raw)
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (!parsed.success) {
|
||||
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
||||
return {}
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const tui = data.tui
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = Config.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
|
||||
const custom = customPath()
|
||||
const managed = Config.managedConfigDir()
|
||||
await migrateTuiConfig({ directories, custom, managed })
|
||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(acc, custom, ctx)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...acc.result.keybinds }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
||||
",",
|
||||
)
|
||||
}
|
||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("TuiConfig.state")(function* (ctx) {
|
||||
const data = yield* Effect.promise(() => loadState(ctx))
|
||||
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return { config: data.config, deps }
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
InstanceState.useEffect(state, (s) =>
|
||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ get, waitForDependencies })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!isRecord(raw)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = normalize(raw)
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (!parsed.success) {
|
||||
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
||||
return {}
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import z from "zod"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, asc, eq, inArray } from "@/storage/db"
|
||||
import { Project } from "@/project/project"
|
||||
import { Database, asc, eq, inArray } from "@/storage"
|
||||
import { Project } from "@/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { SyncEvent } from "@/sync"
|
||||
@@ -114,7 +114,7 @@ export namespace Workspace {
|
||||
|
||||
await adaptor.create(config)
|
||||
|
||||
void startSync(info)
|
||||
startSync(info)
|
||||
|
||||
await waitEvent({
|
||||
timeout: TIMEOUT,
|
||||
@@ -294,7 +294,7 @@ export namespace Workspace {
|
||||
)
|
||||
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
for (const space of spaces) void startSync(space)
|
||||
for (const space of spaces) startSync(space)
|
||||
return spaces
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ export namespace Workspace {
|
||||
export const get = fn(WorkspaceID.zod, async (id) => {
|
||||
const space = lookup(id)
|
||||
if (!space) return
|
||||
void startSync(space)
|
||||
startSync(space)
|
||||
return space
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { attach, memoMap } from "./run-service"
|
||||
import { Observability } from "./observability"
|
||||
import * as Observability from "./observability"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -12,7 +12,7 @@ import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Storage } from "@/storage"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider"
|
||||
@@ -37,16 +37,16 @@ import { LSP } from "@/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool"
|
||||
import { ToolRegistry } from "@/tool"
|
||||
import { Format } from "@/format"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Project } from "@/project"
|
||||
import { Vcs } from "@/project"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { ShareNext } from "@/share"
|
||||
import { SessionShare } from "@/share"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
AppFileSystem.defaultLayer,
|
||||
|
||||
@@ -5,12 +5,12 @@ import { Plugin } from "@/plugin"
|
||||
import { LSP } from "@/lsp"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { ShareNext } from "@/share"
|
||||
import { File } from "@/file"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Vcs } from "@/project"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Bus } from "@/bus"
|
||||
import { Observability } from "./observability"
|
||||
import * as Observability from "./observability"
|
||||
|
||||
export const BootstrapLayer = Layer.mergeAll(
|
||||
Plugin.defaultLayer,
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * as InstanceState from "./instance-state"
|
||||
export * as EffectBridge from "./bridge"
|
||||
export * as Runner from "./runner"
|
||||
export * as Observability from "./observability"
|
||||
export * as EffectLogger from "./logger"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import * as EffectLogger from "./logger"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { LocalContext } from "@/util"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { Cause, Effect, Logger, References } from "effect"
|
||||
import { Log } from "@/util"
|
||||
|
||||
export namespace EffectLogger {
|
||||
type Fields = Record<string, unknown>
|
||||
type Fields = Record<string, unknown>
|
||||
|
||||
export interface Handle {
|
||||
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly with: (extra: Fields) => Handle
|
||||
}
|
||||
|
||||
const clean = (input?: Fields): Fields =>
|
||||
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
|
||||
|
||||
const text = (input: unknown): string => {
|
||||
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
|
||||
return input === undefined ? "" : String(input)
|
||||
}
|
||||
|
||||
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
|
||||
const ann = clean({ ...base, ...extra })
|
||||
const fx = run(msg)
|
||||
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
|
||||
}
|
||||
|
||||
export const logger = Logger.make((opts) => {
|
||||
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
|
||||
const now = opts.date.getTime()
|
||||
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
|
||||
extra[`logSpan.${key}`] = `${now - start}ms`
|
||||
}
|
||||
if (opts.cause.reasons.length > 0) {
|
||||
extra.cause = Cause.pretty(opts.cause)
|
||||
}
|
||||
|
||||
const svc = typeof extra.service === "string" ? extra.service : undefined
|
||||
if (svc) delete extra.service
|
||||
const log = svc ? Log.create({ service: svc }) : Log.Default
|
||||
const msg = text(opts.message)
|
||||
|
||||
switch (opts.logLevel) {
|
||||
case "Trace":
|
||||
case "Debug":
|
||||
return log.debug(msg, extra)
|
||||
case "Warn":
|
||||
return log.warn(msg, extra)
|
||||
case "Error":
|
||||
case "Fatal":
|
||||
return log.error(msg, extra)
|
||||
default:
|
||||
return log.info(msg, extra)
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Logger.layer([logger], { mergeWithExisting: false })
|
||||
|
||||
export const create = (base: Fields = {}): Handle => ({
|
||||
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
|
||||
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
|
||||
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
|
||||
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
|
||||
with: (extra) => create({ ...base, ...extra }),
|
||||
})
|
||||
export interface Handle {
|
||||
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly with: (extra: Fields) => Handle
|
||||
}
|
||||
|
||||
const clean = (input?: Fields): Fields =>
|
||||
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
|
||||
|
||||
const text = (input: unknown): string => {
|
||||
// oxlint-disable-next-line no-base-to-string
|
||||
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
|
||||
// oxlint-disable-next-line no-base-to-string
|
||||
return input === undefined ? "" : String(input)
|
||||
}
|
||||
|
||||
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
|
||||
const ann = clean({ ...base, ...extra })
|
||||
const fx = run(msg)
|
||||
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
|
||||
}
|
||||
|
||||
export const logger = Logger.make((opts) => {
|
||||
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
|
||||
const now = opts.date.getTime()
|
||||
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
|
||||
extra[`logSpan.${key}`] = `${now - start}ms`
|
||||
}
|
||||
if (opts.cause.reasons.length > 0) {
|
||||
extra.cause = Cause.pretty(opts.cause)
|
||||
}
|
||||
|
||||
const svc = typeof extra.service === "string" ? extra.service : undefined
|
||||
if (svc) delete extra.service
|
||||
const log = svc ? Log.create({ service: svc }) : Log.Default
|
||||
const msg = text(opts.message)
|
||||
|
||||
switch (opts.logLevel) {
|
||||
case "Trace":
|
||||
case "Debug":
|
||||
return log.debug(msg, extra)
|
||||
case "Warn":
|
||||
return log.warn(msg, extra)
|
||||
case "Error":
|
||||
case "Fatal":
|
||||
return log.error(msg, extra)
|
||||
default:
|
||||
return log.info(msg, extra)
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Logger.layer([logger], { mergeWithExisting: false })
|
||||
|
||||
export const create = (base: Fields = {}): Handle => ({
|
||||
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
|
||||
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
|
||||
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
|
||||
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
|
||||
with: (extra) => create({ ...base, ...extra }),
|
||||
})
|
||||
|
||||
@@ -1,80 +1,78 @@
|
||||
import { Effect, Layer, Logger } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import * as EffectLogger from "./logger"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { CHANNEL, VERSION } from "@/installation/meta"
|
||||
|
||||
export namespace Observability {
|
||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
export const enabled = !!base
|
||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
export const enabled = !!base
|
||||
|
||||
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||
(acc, x) => {
|
||||
const [key, ...value] = x.split("=")
|
||||
acc[key] = value.join("=")
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined
|
||||
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||
(acc, x) => {
|
||||
const [key, ...value] = x.split("=")
|
||||
acc[key] = value.join("=")
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined
|
||||
|
||||
const resource = {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: VERSION,
|
||||
attributes: {
|
||||
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
}
|
||||
const resource = {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: VERSION,
|
||||
attributes: {
|
||||
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
}
|
||||
|
||||
const logs = Logger.layer(
|
||||
[
|
||||
EffectLogger.logger,
|
||||
OtlpLogger.make({
|
||||
url: `${base}/v1/logs`,
|
||||
resource,
|
||||
const logs = Logger.layer(
|
||||
[
|
||||
EffectLogger.logger,
|
||||
OtlpLogger.make({
|
||||
url: `${base}/v1/logs`,
|
||||
resource,
|
||||
headers,
|
||||
}),
|
||||
],
|
||||
{ mergeWithExisting: false },
|
||||
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
const traces = async () => {
|
||||
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
|
||||
const SdkBase = await import("@opentelemetry/sdk-trace-base")
|
||||
|
||||
// @effect/opentelemetry creates a NodeTracerProvider but never calls
|
||||
// register(), so the global @opentelemetry/api context manager stays
|
||||
// as the no-op default. Non-Effect code (like the AI SDK) that calls
|
||||
// tracer.startActiveSpan() relies on context.active() to find the
|
||||
// parent span — without a real context manager every span starts a
|
||||
// new trace. Registering AsyncLocalStorageContextManager fixes this.
|
||||
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
|
||||
const { context } = await import("@opentelemetry/api")
|
||||
const mgr = new AsyncLocalStorageContextManager()
|
||||
mgr.enable()
|
||||
context.setGlobalContextManager(mgr)
|
||||
|
||||
return NodeSdk.layer(() => ({
|
||||
resource,
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
url: `${base}/v1/traces`,
|
||||
headers,
|
||||
}),
|
||||
],
|
||||
{ mergeWithExisting: false },
|
||||
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
const traces = async () => {
|
||||
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
|
||||
const SdkBase = await import("@opentelemetry/sdk-trace-base")
|
||||
|
||||
// @effect/opentelemetry creates a NodeTracerProvider but never calls
|
||||
// register(), so the global @opentelemetry/api context manager stays
|
||||
// as the no-op default. Non-Effect code (like the AI SDK) that calls
|
||||
// tracer.startActiveSpan() relies on context.active() to find the
|
||||
// parent span — without a real context manager every span starts a
|
||||
// new trace. Registering AsyncLocalStorageContextManager fixes this.
|
||||
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
|
||||
const { context } = await import("@opentelemetry/api")
|
||||
const mgr = new AsyncLocalStorageContextManager()
|
||||
mgr.enable()
|
||||
context.setGlobalContextManager(mgr)
|
||||
|
||||
return NodeSdk.layer(() => ({
|
||||
resource,
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
url: `${base}/v1/traces`,
|
||||
headers,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export const layer = !base
|
||||
? EffectLogger.layer
|
||||
: Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const trace = yield* Effect.promise(traces)
|
||||
return Layer.mergeAll(trace, logs)
|
||||
}),
|
||||
)
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export const layer = !base
|
||||
? EffectLogger.layer
|
||||
: Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const trace = yield* Effect.promise(traces)
|
||||
return Layer.mergeAll(trace, logs)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as Context from "effect/Context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { LocalContext } from "@/util"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { Observability } from "./observability"
|
||||
import * as Observability from "./observability"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
|
||||
@@ -1,208 +1,206 @@
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
|
||||
|
||||
export interface Runner<A, E = never> {
|
||||
readonly state: Runner.State<A, E>
|
||||
readonly state: State<A, E>
|
||||
readonly busy: boolean
|
||||
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly cancel: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export namespace Runner {
|
||||
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
|
||||
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
|
||||
|
||||
interface RunHandle<A, E> {
|
||||
id: number
|
||||
done: Deferred.Deferred<A, E | Cancelled>
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
interface RunHandle<A, E> {
|
||||
id: number
|
||||
done: Deferred.Deferred<A, E | Cancelled>
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
}
|
||||
|
||||
interface ShellHandle<A, E> {
|
||||
id: number
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
}
|
||||
|
||||
interface PendingHandle<A, E> {
|
||||
id: number
|
||||
done: Deferred.Deferred<A, E | Cancelled>
|
||||
work: Effect.Effect<A, E>
|
||||
}
|
||||
|
||||
export type State<A, E> =
|
||||
| { readonly _tag: "Idle" }
|
||||
| { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
|
||||
| { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
|
||||
| { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
|
||||
|
||||
export const make = <A, E = never>(
|
||||
scope: Scope.Scope,
|
||||
opts?: {
|
||||
onIdle?: Effect.Effect<void>
|
||||
onBusy?: Effect.Effect<void>
|
||||
onInterrupt?: Effect.Effect<A, E>
|
||||
busy?: () => never
|
||||
},
|
||||
): Runner<A, E> => {
|
||||
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
|
||||
const idle = opts?.onIdle ?? Effect.void
|
||||
const busy = opts?.onBusy ?? Effect.void
|
||||
const onInterrupt = opts?.onInterrupt
|
||||
let ids = 0
|
||||
|
||||
const state = () => SynchronizedRef.getUnsafe(ref)
|
||||
const next = () => {
|
||||
ids += 1
|
||||
return ids
|
||||
}
|
||||
|
||||
interface ShellHandle<A, E> {
|
||||
id: number
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
}
|
||||
const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
|
||||
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
||||
? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
|
||||
: Deferred.done(done, exit).pipe(Effect.asVoid)
|
||||
|
||||
interface PendingHandle<A, E> {
|
||||
id: number
|
||||
done: Deferred.Deferred<A, E | Cancelled>
|
||||
work: Effect.Effect<A, E>
|
||||
}
|
||||
const idleIfCurrent = () =>
|
||||
SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
|
||||
|
||||
export type State<A, E> =
|
||||
| { readonly _tag: "Idle" }
|
||||
| { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
|
||||
| { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
|
||||
| { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
|
||||
const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
|
||||
SynchronizedRef.modify(
|
||||
ref,
|
||||
(st) =>
|
||||
[
|
||||
Effect.gen(function* () {
|
||||
if (st._tag === "Running" && st.run.id === id) yield* idle
|
||||
yield* complete(done, exit)
|
||||
}),
|
||||
st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
|
||||
] as const,
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
export const make = <A, E = never>(
|
||||
scope: Scope.Scope,
|
||||
opts?: {
|
||||
onIdle?: Effect.Effect<void>
|
||||
onBusy?: Effect.Effect<void>
|
||||
onInterrupt?: Effect.Effect<A, E>
|
||||
busy?: () => never
|
||||
},
|
||||
): Runner<A, E> => {
|
||||
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
|
||||
const idle = opts?.onIdle ?? Effect.void
|
||||
const busy = opts?.onBusy ?? Effect.void
|
||||
const onInterrupt = opts?.onInterrupt
|
||||
let ids = 0
|
||||
|
||||
const state = () => SynchronizedRef.getUnsafe(ref)
|
||||
const next = () => {
|
||||
ids += 1
|
||||
return ids
|
||||
}
|
||||
|
||||
const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
|
||||
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
||||
? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
|
||||
: Deferred.done(done, exit).pipe(Effect.asVoid)
|
||||
|
||||
const idleIfCurrent = () =>
|
||||
SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
|
||||
|
||||
const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
|
||||
SynchronizedRef.modify(
|
||||
ref,
|
||||
(st) =>
|
||||
[
|
||||
Effect.gen(function* () {
|
||||
if (st._tag === "Running" && st.run.id === id) yield* idle
|
||||
yield* complete(done, exit)
|
||||
}),
|
||||
st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
|
||||
] as const,
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
|
||||
Effect.gen(function* () {
|
||||
const id = next()
|
||||
const fiber = yield* work.pipe(
|
||||
Effect.onExit((exit) => finishRun(id, done, exit)),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
return { id, done, fiber } satisfies RunHandle<A, E>
|
||||
})
|
||||
|
||||
const finishShell = (id: number) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
|
||||
if (st._tag === "ShellThenRun" && st.shell.id === id) {
|
||||
const run = yield* startRun(st.run.work, st.run.done)
|
||||
return [Effect.void, { _tag: "Running", run }] as const
|
||||
}
|
||||
return [Effect.void, st] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
|
||||
|
||||
const ensureRunning = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
switch (st._tag) {
|
||||
case "Running":
|
||||
case "ShellThenRun":
|
||||
return [Deferred.await(st.run.done), st] as const
|
||||
case "Shell": {
|
||||
const run = {
|
||||
id: next(),
|
||||
done: yield* Deferred.make<A, E | Cancelled>(),
|
||||
work,
|
||||
} satisfies PendingHandle<A, E>
|
||||
return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
|
||||
}
|
||||
case "Idle": {
|
||||
const done = yield* Deferred.make<A, E | Cancelled>()
|
||||
const run = yield* startRun(work, done)
|
||||
return [Deferred.await(done), { _tag: "Running", run }] as const
|
||||
}
|
||||
}
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catch(
|
||||
(e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
|
||||
),
|
||||
const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
|
||||
Effect.gen(function* () {
|
||||
const id = next()
|
||||
const fiber = yield* work.pipe(
|
||||
Effect.onExit((exit) => finishRun(id, done, exit)),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
return { id, done, fiber } satisfies RunHandle<A, E>
|
||||
})
|
||||
|
||||
const startShell = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
if (st._tag !== "Idle") {
|
||||
return [
|
||||
Effect.sync(() => {
|
||||
if (opts?.busy) opts.busy()
|
||||
throw new Error("Runner is busy")
|
||||
}),
|
||||
st,
|
||||
] as const
|
||||
const finishShell = (id: number) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
|
||||
if (st._tag === "ShellThenRun" && st.shell.id === id) {
|
||||
const run = yield* startRun(st.run.work, st.run.done)
|
||||
return [Effect.void, { _tag: "Running", run }] as const
|
||||
}
|
||||
return [Effect.void, st] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
|
||||
|
||||
const ensureRunning = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
switch (st._tag) {
|
||||
case "Running":
|
||||
case "ShellThenRun":
|
||||
return [Deferred.await(st.run.done), st] as const
|
||||
case "Shell": {
|
||||
const run = {
|
||||
id: next(),
|
||||
done: yield* Deferred.make<A, E | Cancelled>(),
|
||||
work,
|
||||
} satisfies PendingHandle<A, E>
|
||||
return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
|
||||
}
|
||||
yield* busy
|
||||
const id = next()
|
||||
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
||||
const shell = { id, fiber } satisfies ShellHandle<A, E>
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}),
|
||||
{ _tag: "Shell", shell },
|
||||
] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
case "Idle": {
|
||||
const done = yield* Deferred.make<A, E | Cancelled>()
|
||||
const run = yield* startRun(work, done)
|
||||
return [Deferred.await(done), { _tag: "Running", run }] as const
|
||||
}
|
||||
}
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catch(
|
||||
(e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
|
||||
),
|
||||
)
|
||||
|
||||
const cancel = SynchronizedRef.modify(ref, (st) => {
|
||||
switch (st._tag) {
|
||||
case "Idle":
|
||||
return [Effect.void, st] as const
|
||||
case "Running":
|
||||
const startShell = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
if (st._tag !== "Idle") {
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Fiber.interrupt(st.run.fiber)
|
||||
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
|
||||
yield* idleIfCurrent()
|
||||
Effect.sync(() => {
|
||||
if (opts?.busy) opts.busy()
|
||||
throw new Error("Runner is busy")
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
st,
|
||||
] as const
|
||||
case "Shell":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* stopShell(st.shell)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
case "ShellThenRun":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
|
||||
yield* stopShell(st.shell)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
}
|
||||
}).pipe(Effect.flatten)
|
||||
}
|
||||
yield* busy
|
||||
const id = next()
|
||||
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
||||
const shell = { id, fiber } satisfies ShellHandle<A, E>
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}),
|
||||
{ _tag: "Shell", shell },
|
||||
] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state()
|
||||
},
|
||||
get busy() {
|
||||
return state()._tag !== "Idle"
|
||||
},
|
||||
ensureRunning,
|
||||
startShell,
|
||||
cancel,
|
||||
const cancel = SynchronizedRef.modify(ref, (st) => {
|
||||
switch (st._tag) {
|
||||
case "Idle":
|
||||
return [Effect.void, st] as const
|
||||
case "Running":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Fiber.interrupt(st.run.fiber)
|
||||
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
case "Shell":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* stopShell(st.shell)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
case "ShellThenRun":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
|
||||
yield* stopShell(st.shell)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
}
|
||||
}).pipe(Effect.flatten)
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state()
|
||||
},
|
||||
get busy() {
|
||||
return state()._tag !== "Idle"
|
||||
},
|
||||
ensureRunning,
|
||||
startShell,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ import { SessionCommand } from "./cli/cmd/session"
|
||||
import { DbCommand } from "./cli/cmd/db"
|
||||
import path from "path"
|
||||
import { Global } from "./global"
|
||||
import { JsonMigration } from "./storage/json-migration"
|
||||
import { Database } from "./storage/db"
|
||||
import { JsonMigration } from "./storage"
|
||||
import { Database } from "./storage"
|
||||
import { errorMessage } from "./util/error"
|
||||
import { PluginCommand } from "./cli/cmd/plug"
|
||||
import { Heap } from "./cli/heap"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Log } from "../util"
|
||||
import { Process } from "../util"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
import type * as LSPServer from "./server"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -16,237 +16,235 @@ import { Filesystem } from "../util"
|
||||
|
||||
const DIAGNOSTICS_DEBOUNCE_MS = 150
|
||||
|
||||
export namespace LSPClient {
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
|
||||
export const InitializeError = NamedError.create(
|
||||
"LSPInitializeError",
|
||||
export const InitializeError = NamedError.create(
|
||||
"LSPInitializeError",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: BusEvent.define(
|
||||
"lsp.client.diagnostics",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
|
||||
const l = log.clone().tag("serverID", input.serverID)
|
||||
l.info("starting client")
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(input.server.process.stdout as any),
|
||||
new StreamMessageWriter(input.server.process.stdin as any),
|
||||
)
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: BusEvent.define(
|
||||
"lsp.client.diagnostics",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
const diagnostics = new Map<string, Diagnostic[]>()
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
|
||||
l.info("textDocument/publishDiagnostics", {
|
||||
path: filePath,
|
||||
count: params.diagnostics.length,
|
||||
})
|
||||
const exists = diagnostics.has(filePath)
|
||||
diagnostics.set(filePath, params.diagnostics)
|
||||
if (!exists && input.serverID === "typescript") return
|
||||
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
})
|
||||
connection.onRequest("window/workDoneProgress/create", (params) => {
|
||||
l.info("window/workDoneProgress/create", params)
|
||||
return null
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async () => {
|
||||
// Return server initialization options
|
||||
return [input.server.initialization ?? {}]
|
||||
})
|
||||
connection.onRequest("client/registerCapability", async () => {})
|
||||
connection.onRequest("client/unregisterCapability", async () => {})
|
||||
connection.onRequest("workspace/workspaceFolders", async () => [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: pathToFileURL(input.root).href,
|
||||
},
|
||||
])
|
||||
connection.listen()
|
||||
|
||||
l.info("sending initialize")
|
||||
await withTimeout(
|
||||
connection.sendRequest("initialize", {
|
||||
rootUri: pathToFileURL(input.root).href,
|
||||
processId: input.server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: pathToFileURL(input.root).href,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...input.server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
window: {
|
||||
workDoneProgress: true,
|
||||
},
|
||||
workspace: {
|
||||
configuration: true,
|
||||
didChangeWatchedFiles: {
|
||||
dynamicRegistration: true,
|
||||
},
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
45_000,
|
||||
).catch((err) => {
|
||||
l.error("initialize error", { error: err })
|
||||
throw new InitializeError(
|
||||
{ serverID: input.serverID },
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await connection.sendNotification("initialized", {})
|
||||
|
||||
if (input.server.initialization) {
|
||||
await connection.sendNotification("workspace/didChangeConfiguration", {
|
||||
settings: input.server.initialization,
|
||||
})
|
||||
}
|
||||
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
|
||||
const l = log.clone().tag("serverID", input.serverID)
|
||||
l.info("starting client")
|
||||
const files: {
|
||||
[path: string]: number
|
||||
} = {}
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(input.server.process.stdout as any),
|
||||
new StreamMessageWriter(input.server.process.stdin as any),
|
||||
)
|
||||
|
||||
const diagnostics = new Map<string, Diagnostic[]>()
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
|
||||
l.info("textDocument/publishDiagnostics", {
|
||||
path: filePath,
|
||||
count: params.diagnostics.length,
|
||||
})
|
||||
const exists = diagnostics.has(filePath)
|
||||
diagnostics.set(filePath, params.diagnostics)
|
||||
if (!exists && input.serverID === "typescript") return
|
||||
void Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
})
|
||||
connection.onRequest("window/workDoneProgress/create", (params) => {
|
||||
l.info("window/workDoneProgress/create", params)
|
||||
return null
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async () => {
|
||||
// Return server initialization options
|
||||
return [input.server.initialization ?? {}]
|
||||
})
|
||||
connection.onRequest("client/registerCapability", async () => {})
|
||||
connection.onRequest("client/unregisterCapability", async () => {})
|
||||
connection.onRequest("workspace/workspaceFolders", async () => [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: pathToFileURL(input.root).href,
|
||||
},
|
||||
])
|
||||
connection.listen()
|
||||
|
||||
l.info("sending initialize")
|
||||
await withTimeout(
|
||||
connection.sendRequest("initialize", {
|
||||
rootUri: pathToFileURL(input.root).href,
|
||||
processId: input.server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: pathToFileURL(input.root).href,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...input.server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
window: {
|
||||
workDoneProgress: true,
|
||||
},
|
||||
workspace: {
|
||||
configuration: true,
|
||||
didChangeWatchedFiles: {
|
||||
dynamicRegistration: true,
|
||||
},
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
45_000,
|
||||
).catch((err) => {
|
||||
l.error("initialize error", { error: err })
|
||||
throw new InitializeError(
|
||||
{ serverID: input.serverID },
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await connection.sendNotification("initialized", {})
|
||||
|
||||
if (input.server.initialization) {
|
||||
await connection.sendNotification("workspace/didChangeConfiguration", {
|
||||
settings: input.server.initialization,
|
||||
})
|
||||
}
|
||||
|
||||
const files: {
|
||||
[path: string]: number
|
||||
} = {}
|
||||
|
||||
const result = {
|
||||
root: input.root,
|
||||
get serverID() {
|
||||
return input.serverID
|
||||
},
|
||||
get connection() {
|
||||
return connection
|
||||
},
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
||||
const text = await Filesystem.readText(input.path)
|
||||
const extension = path.extname(input.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
|
||||
const version = files[input.path]
|
||||
if (version !== undefined) {
|
||||
log.info("workspace/didChangeWatchedFiles", input)
|
||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: [
|
||||
{
|
||||
uri: pathToFileURL(input.path).href,
|
||||
type: 2, // Changed
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const next = version + 1
|
||||
files[input.path] = next
|
||||
log.info("textDocument/didChange", {
|
||||
path: input.path,
|
||||
version: next,
|
||||
})
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(input.path).href,
|
||||
version: next,
|
||||
},
|
||||
contentChanges: [{ text }],
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = {
|
||||
root: input.root,
|
||||
get serverID() {
|
||||
return input.serverID
|
||||
},
|
||||
get connection() {
|
||||
return connection
|
||||
},
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
||||
const text = await Filesystem.readText(input.path)
|
||||
const extension = path.extname(input.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
|
||||
const version = files[input.path]
|
||||
if (version !== undefined) {
|
||||
log.info("workspace/didChangeWatchedFiles", input)
|
||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: [
|
||||
{
|
||||
uri: pathToFileURL(input.path).href,
|
||||
type: 1, // Created
|
||||
type: 2, // Changed
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
log.info("textDocument/didOpen", input)
|
||||
diagnostics.delete(input.path)
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
const next = version + 1
|
||||
files[input.path] = next
|
||||
log.info("textDocument/didChange", {
|
||||
path: input.path,
|
||||
version: next,
|
||||
})
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(input.path).href,
|
||||
languageId,
|
||||
version: 0,
|
||||
text,
|
||||
version: next,
|
||||
},
|
||||
contentChanges: [{ text }],
|
||||
})
|
||||
files[input.path] = 0
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
log.info("workspace/didChangeWatchedFiles", input)
|
||||
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: [
|
||||
{
|
||||
uri: pathToFileURL(input.path).href,
|
||||
type: 1, // Created
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
log.info("textDocument/didOpen", input)
|
||||
diagnostics.delete(input.path)
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(input.path).href,
|
||||
languageId,
|
||||
version: 0,
|
||||
text,
|
||||
},
|
||||
})
|
||||
files[input.path] = 0
|
||||
return
|
||||
},
|
||||
get diagnostics() {
|
||||
return diagnostics
|
||||
},
|
||||
async waitForDiagnostics(input: { path: string }) {
|
||||
const normalizedPath = Filesystem.normalizePath(
|
||||
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
|
||||
)
|
||||
log.info("waiting for diagnostics", { path: normalizedPath })
|
||||
let unsub: () => void
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
return await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
|
||||
// Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
log.info("got diagnostics", { path: normalizedPath })
|
||||
unsub?.()
|
||||
resolve()
|
||||
}, DIAGNOSTICS_DEBOUNCE_MS)
|
||||
}
|
||||
})
|
||||
}),
|
||||
3000,
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
unsub?.()
|
||||
},
|
||||
get diagnostics() {
|
||||
return diagnostics
|
||||
},
|
||||
async waitForDiagnostics(input: { path: string }) {
|
||||
const normalizedPath = Filesystem.normalizePath(
|
||||
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
|
||||
)
|
||||
log.info("waiting for diagnostics", { path: normalizedPath })
|
||||
let unsub: () => void
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
return await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
|
||||
// Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
log.info("got diagnostics", { path: normalizedPath })
|
||||
unsub?.()
|
||||
resolve()
|
||||
}, DIAGNOSTICS_DEBOUNCE_MS)
|
||||
}
|
||||
})
|
||||
},
|
||||
async shutdown() {
|
||||
l.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
await Process.stop(input.server.process)
|
||||
l.info("shutdown")
|
||||
},
|
||||
}
|
||||
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
}),
|
||||
3000,
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
unsub?.()
|
||||
})
|
||||
},
|
||||
async shutdown() {
|
||||
l.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
await Process.stop(input.server.process)
|
||||
l.info("shutdown")
|
||||
},
|
||||
}
|
||||
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,537 +1,3 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Log } from "../util"
|
||||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { LSPServer } from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "../config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Process } from "../util"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("lsp.updated", z.object({})),
|
||||
}
|
||||
|
||||
export const Range = z
|
||||
.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Range",
|
||||
})
|
||||
export type Range = z.infer<typeof Range>
|
||||
|
||||
export const Symbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
kind: z.number(),
|
||||
location: z.object({
|
||||
uri: z.string(),
|
||||
range: Range,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Symbol",
|
||||
})
|
||||
export type Symbol = z.infer<typeof Symbol>
|
||||
|
||||
export const DocumentSymbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
detail: z.string().optional(),
|
||||
kind: z.number(),
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
.meta({
|
||||
ref: "DocumentSymbol",
|
||||
})
|
||||
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
root: z.string(),
|
||||
status: z.union([z.literal("connected"), z.literal("error")]),
|
||||
})
|
||||
.meta({
|
||||
ref: "LSPStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
enum SymbolKind {
|
||||
File = 1,
|
||||
Module = 2,
|
||||
Namespace = 3,
|
||||
Package = 4,
|
||||
Class = 5,
|
||||
Method = 6,
|
||||
Property = 7,
|
||||
Field = 8,
|
||||
Constructor = 9,
|
||||
Enum = 10,
|
||||
Interface = 11,
|
||||
Function = 12,
|
||||
Variable = 13,
|
||||
Constant = 14,
|
||||
String = 15,
|
||||
Number = 16,
|
||||
Boolean = 17,
|
||||
Array = 18,
|
||||
Object = 19,
|
||||
Key = 20,
|
||||
Null = 21,
|
||||
EnumMember = 22,
|
||||
Struct = 23,
|
||||
Event = 24,
|
||||
Operator = 25,
|
||||
TypeParameter = 26,
|
||||
}
|
||||
|
||||
const kinds = [
|
||||
SymbolKind.Class,
|
||||
SymbolKind.Function,
|
||||
SymbolKind.Method,
|
||||
SymbolKind.Interface,
|
||||
SymbolKind.Variable,
|
||||
SymbolKind.Constant,
|
||||
SymbolKind.Struct,
|
||||
SymbolKind.Enum,
|
||||
]
|
||||
|
||||
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
|
||||
if (servers["pyright"]) {
|
||||
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
|
||||
delete servers["pyright"]
|
||||
}
|
||||
} else {
|
||||
if (servers["ty"]) {
|
||||
delete servers["ty"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LocInput = { file: string; line: number; character: number }
|
||||
|
||||
interface State {
|
||||
clients: LSPClient.Info[]
|
||||
servers: Record<string, LSPServer.Info>
|
||||
broken: Set<string>
|
||||
spawning: Map<string, Promise<LSPClient.Info | undefined>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly hasClients: (file: string) => Effect.Effect<boolean>
|
||||
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
|
||||
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
|
||||
readonly hover: (input: LocInput) => Effect.Effect<any>
|
||||
readonly definition: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly references: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]>
|
||||
readonly workspaceSymbol: (query: string) => Effect.Effect<LSP.Symbol[]>
|
||||
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* () {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
if (cfg.lsp === false) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
|
||||
filterExperimentalServers(servers)
|
||||
|
||||
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async () => Instance.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => ({
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...item.env },
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
const s: State = {
|
||||
clients: [],
|
||||
servers,
|
||||
broken: new Set(),
|
||||
spawning: new Map(),
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
||||
}),
|
||||
)
|
||||
|
||||
return s
|
||||
}),
|
||||
)
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root)
|
||||
.then((value) => {
|
||||
if (!value) s.broken.add(key)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
s.broken.add(key)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id, root })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!client) return undefined
|
||||
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
s.clients.push(client)
|
||||
return client
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
const inflight = s.spawning.get(root + server.id)
|
||||
if (inflight) {
|
||||
const client = await inflight
|
||||
if (!client) continue
|
||||
result.push(client)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = schedule(server, root, root + server.id)
|
||||
s.spawning.set(root + server.id, task)
|
||||
|
||||
void task.finally(() => {
|
||||
if (s.spawning.get(root + server.id) === task) {
|
||||
s.spawning.delete(root + server.id)
|
||||
}
|
||||
})
|
||||
|
||||
const client = await task
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
void Bus.publish(Event.Updated, {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const clients = yield* getClients(file)
|
||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const init = Effect.fn("LSP.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("LSP.status")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const client of s.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: s.servers[client.serverID].id,
|
||||
root: path.relative(Instance.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = yield* getClients(input)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
}),
|
||||
).catch((err) => {
|
||||
log.error("failed to touch file", { err, file: input })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
const all = yield* runAll(async (client) => client.diagnostics)
|
||||
for (const result of all) {
|
||||
for (const [p, diags] of result.entries()) {
|
||||
const arr = results[p] || []
|
||||
arr.push(...diags)
|
||||
results[p] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||
return yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
})
|
||||
|
||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/implementation", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
||||
const file = fileURLToPath(uri)
|
||||
const results = yield* run(file, (client) =>
|
||||
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
|
||||
)
|
||||
return (results.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]).filter(Boolean)
|
||||
})
|
||||
|
||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||
const results = yield* runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", { query })
|
||||
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat() as LSP.Symbol[]
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const callHierarchyRequest = Effect.fnUntraced(function* (
|
||||
input: LocInput,
|
||||
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
||||
) {
|
||||
const results = yield* run(input.file, async (client) => {
|
||||
const items = (await client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [])) as any[]
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
|
||||
})
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
|
||||
})
|
||||
|
||||
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
status,
|
||||
hasClients,
|
||||
touchFile,
|
||||
diagnostics,
|
||||
hover,
|
||||
definition,
|
||||
references,
|
||||
implementation,
|
||||
documentSymbol,
|
||||
workspaceSymbol,
|
||||
prepareCallHierarchy,
|
||||
incomingCalls,
|
||||
outgoingCalls,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
export namespace Diagnostic {
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
const severityMap = {
|
||||
1: "ERROR",
|
||||
2: "WARN",
|
||||
3: "INFO",
|
||||
4: "HINT",
|
||||
}
|
||||
|
||||
const severity = severityMap[diagnostic.severity || 1]
|
||||
const line = diagnostic.range.start.line + 1
|
||||
const col = diagnostic.range.start.character + 1
|
||||
|
||||
return `${severity} [${line}:${col}] ${diagnostic.message}`
|
||||
}
|
||||
|
||||
export function report(file: string, issues: LSPClient.Diagnostic[]) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) return ""
|
||||
const limited = errors.slice(0, MAX_PER_FILE)
|
||||
const more = errors.length - MAX_PER_FILE
|
||||
const suffix = more > 0 ? `\n... and ${more} more` : ""
|
||||
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
}
|
||||
export * as LSP from "./lsp"
|
||||
export * as LSPClient from "./client"
|
||||
export * as LSPServer from "./server"
|
||||
|
||||
535
packages/opencode/src/lsp/lsp.ts
Normal file
535
packages/opencode/src/lsp/lsp.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Log } from "../util"
|
||||
import * as LSPClient from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import * as LSPServer from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "../config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Process } from "../util"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("lsp.updated", z.object({})),
|
||||
}
|
||||
|
||||
export const Range = z
|
||||
.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Range",
|
||||
})
|
||||
export type Range = z.infer<typeof Range>
|
||||
|
||||
export const Symbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
kind: z.number(),
|
||||
location: z.object({
|
||||
uri: z.string(),
|
||||
range: Range,
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
ref: "Symbol",
|
||||
})
|
||||
export type Symbol = z.infer<typeof Symbol>
|
||||
|
||||
export const DocumentSymbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
detail: z.string().optional(),
|
||||
kind: z.number(),
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
.meta({
|
||||
ref: "DocumentSymbol",
|
||||
})
|
||||
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
root: z.string(),
|
||||
status: z.union([z.literal("connected"), z.literal("error")]),
|
||||
})
|
||||
.meta({
|
||||
ref: "LSPStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
enum SymbolKind {
|
||||
File = 1,
|
||||
Module = 2,
|
||||
Namespace = 3,
|
||||
Package = 4,
|
||||
Class = 5,
|
||||
Method = 6,
|
||||
Property = 7,
|
||||
Field = 8,
|
||||
Constructor = 9,
|
||||
Enum = 10,
|
||||
Interface = 11,
|
||||
Function = 12,
|
||||
Variable = 13,
|
||||
Constant = 14,
|
||||
String = 15,
|
||||
Number = 16,
|
||||
Boolean = 17,
|
||||
Array = 18,
|
||||
Object = 19,
|
||||
Key = 20,
|
||||
Null = 21,
|
||||
EnumMember = 22,
|
||||
Struct = 23,
|
||||
Event = 24,
|
||||
Operator = 25,
|
||||
TypeParameter = 26,
|
||||
}
|
||||
|
||||
const kinds = [
|
||||
SymbolKind.Class,
|
||||
SymbolKind.Function,
|
||||
SymbolKind.Method,
|
||||
SymbolKind.Interface,
|
||||
SymbolKind.Variable,
|
||||
SymbolKind.Constant,
|
||||
SymbolKind.Struct,
|
||||
SymbolKind.Enum,
|
||||
]
|
||||
|
||||
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
|
||||
if (servers["pyright"]) {
|
||||
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
|
||||
delete servers["pyright"]
|
||||
}
|
||||
} else {
|
||||
if (servers["ty"]) {
|
||||
delete servers["ty"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LocInput = { file: string; line: number; character: number }
|
||||
|
||||
interface State {
|
||||
clients: LSPClient.Info[]
|
||||
servers: Record<string, LSPServer.Info>
|
||||
broken: Set<string>
|
||||
spawning: Map<string, Promise<LSPClient.Info | undefined>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly hasClients: (file: string) => Effect.Effect<boolean>
|
||||
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
|
||||
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
|
||||
readonly hover: (input: LocInput) => Effect.Effect<any>
|
||||
readonly definition: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly references: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>
|
||||
readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>
|
||||
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* () {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
if (cfg.lsp === false) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
|
||||
filterExperimentalServers(servers)
|
||||
|
||||
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async () => Instance.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => ({
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...item.env },
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
const s: State = {
|
||||
clients: [],
|
||||
servers,
|
||||
broken: new Set(),
|
||||
spawning: new Map(),
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
||||
}),
|
||||
)
|
||||
|
||||
return s
|
||||
}),
|
||||
)
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root)
|
||||
.then((value) => {
|
||||
if (!value) s.broken.add(key)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
s.broken.add(key)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id, root })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!client) return undefined
|
||||
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
s.clients.push(client)
|
||||
return client
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
const inflight = s.spawning.get(root + server.id)
|
||||
if (inflight) {
|
||||
const client = await inflight
|
||||
if (!client) continue
|
||||
result.push(client)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = schedule(server, root, root + server.id)
|
||||
s.spawning.set(root + server.id, task)
|
||||
|
||||
task.finally(() => {
|
||||
if (s.spawning.get(root + server.id) === task) {
|
||||
s.spawning.delete(root + server.id)
|
||||
}
|
||||
})
|
||||
|
||||
const client = await task
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
Bus.publish(Event.Updated, {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const clients = yield* getClients(file)
|
||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const init = Effect.fn("LSP.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("LSP.status")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const client of s.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: s.servers[client.serverID].id,
|
||||
root: path.relative(Instance.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = yield* getClients(input)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
}),
|
||||
).catch((err) => {
|
||||
log.error("failed to touch file", { err, file: input })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
const all = yield* runAll(async (client) => client.diagnostics)
|
||||
for (const result of all) {
|
||||
for (const [p, diags] of result.entries()) {
|
||||
const arr = results[p] || []
|
||||
arr.push(...diags)
|
||||
results[p] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||
return yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
})
|
||||
|
||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/implementation", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
||||
const file = fileURLToPath(uri)
|
||||
const results = yield* run(file, (client) =>
|
||||
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
|
||||
)
|
||||
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
|
||||
})
|
||||
|
||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||
const results = yield* runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", { query })
|
||||
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat() as Symbol[]
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const callHierarchyRequest = Effect.fnUntraced(function* (
|
||||
input: LocInput,
|
||||
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
||||
) {
|
||||
const results = yield* run(input.file, async (client) => {
|
||||
const items = (await client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [])) as any[]
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
|
||||
})
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
|
||||
})
|
||||
|
||||
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
status,
|
||||
hasClients,
|
||||
touchFile,
|
||||
diagnostics,
|
||||
hover,
|
||||
definition,
|
||||
references,
|
||||
implementation,
|
||||
documentSymbol,
|
||||
workspaceSymbol,
|
||||
prepareCallHierarchy,
|
||||
incomingCalls,
|
||||
outgoingCalls,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
export namespace Diagnostic {
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
const severityMap = {
|
||||
1: "ERROR",
|
||||
2: "WARN",
|
||||
3: "INFO",
|
||||
4: "HINT",
|
||||
}
|
||||
|
||||
const severity = severityMap[diagnostic.severity || 1]
|
||||
const line = diagnostic.range.start.line + 1
|
||||
const col = diagnostic.range.start.character + 1
|
||||
|
||||
return `${severity} [${line}:${col}] ${diagnostic.message}`
|
||||
}
|
||||
|
||||
export function report(file: string, issues: LSPClient.Diagnostic[]) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) return ""
|
||||
const limited = errors.slice(0, MAX_PER_FILE)
|
||||
const more = errors.length - MAX_PER_FILE
|
||||
const suffix = more > 0 ? `\n... and ${more} more` : ""
|
||||
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,5 +2,5 @@ export { Config } from "./config"
|
||||
export { Server } from "./server/server"
|
||||
export { bootstrap } from "./cli/bootstrap"
|
||||
export { Log } from "./util"
|
||||
export { Database } from "./storage/db"
|
||||
export { JsonMigration } from "./storage/json-migration"
|
||||
export { Database } from "./storage"
|
||||
export { JsonMigration } from "./storage"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { InstanceState } from "@/effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Database, eq } from "@/storage"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Log } from "@/util"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const info = await getAuth()
|
||||
if (info.type !== "oauth") return fetch(request, init)
|
||||
|
||||
const url = request instanceof URL ? request.href : request.toString()
|
||||
const url = request instanceof URL ? request.href : typeof request === "string" ? request : request.url
|
||||
const { isVision, isAgent } = iife(() => {
|
||||
try {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
printParseErrorCode,
|
||||
} from "jsonc-parser"
|
||||
|
||||
import { ConfigPaths } from "@/config/paths"
|
||||
import { ConfigPaths } from "@/config"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util"
|
||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||
|
||||
@@ -3,14 +3,14 @@ import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { File } from "../file"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Project } from "./project"
|
||||
import { Vcs } from "./vcs"
|
||||
import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { Log } from "@/util"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { ShareNext } from "@/share"
|
||||
import * as Effect from "effect/Effect"
|
||||
|
||||
export const InstanceBootstrap = Effect.gen(function* () {
|
||||
|
||||
2
packages/opencode/src/project/index.ts
Normal file
2
packages/opencode/src/project/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as Vcs from "./vcs"
|
||||
export * as Project from "./project"
|
||||
@@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util"
|
||||
import { LocalContext } from "../util"
|
||||
import { Project } from "./project"
|
||||
import * as Project from "./project"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
export interface InstanceContext {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod"
|
||||
import { and, Database, eq } from "../storage/db"
|
||||
import { and, Database, eq } from "../storage"
|
||||
import { ProjectTable } from "./project.sql"
|
||||
import { SessionTable } from "../session/session.sql"
|
||||
import { Log } from "../util"
|
||||
@@ -14,474 +14,469 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
worktree: z.string(),
|
||||
vcs: z.literal("git").optional(),
|
||||
name: z.string().optional(),
|
||||
icon: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
override: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
commands: z
|
||||
.object({
|
||||
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
|
||||
})
|
||||
.optional(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
updated: z.number(),
|
||||
initialized: z.number().optional(),
|
||||
}),
|
||||
sandboxes: z.array(z.string()),
|
||||
})
|
||||
.meta({
|
||||
ref: "Project",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("project.updated", Info),
|
||||
}
|
||||
|
||||
type Row = typeof ProjectTable.$inferSelect
|
||||
|
||||
export function fromRow(row: Row): Info {
|
||||
const icon =
|
||||
row.icon_url || row.icon_color
|
||||
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
|
||||
: undefined
|
||||
return {
|
||||
id: row.id,
|
||||
worktree: row.worktree,
|
||||
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
||||
name: row.name ?? undefined,
|
||||
icon,
|
||||
time: {
|
||||
created: row.time_created,
|
||||
updated: row.time_updated,
|
||||
initialized: row.time_initialized ?? undefined,
|
||||
},
|
||||
sandboxes: row.sandboxes,
|
||||
commands: row.commands ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const UpdateInput = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
export const Info = z
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
worktree: z.string(),
|
||||
vcs: z.literal("git").optional(),
|
||||
name: z.string().optional(),
|
||||
icon: Info.shape.icon.optional(),
|
||||
commands: Info.shape.commands.optional(),
|
||||
icon: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
override: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
commands: z
|
||||
.object({
|
||||
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
|
||||
})
|
||||
.optional(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
updated: z.number(),
|
||||
initialized: z.number().optional(),
|
||||
}),
|
||||
sandboxes: z.array(z.string()),
|
||||
})
|
||||
export type UpdateInput = z.infer<typeof UpdateInput>
|
||||
.meta({
|
||||
ref: "Project",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("project.updated", Info),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
||||
readonly discover: (input: Info) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info>
|
||||
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
|
||||
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
|
||||
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
|
||||
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||
type Row = typeof ProjectTable.$inferSelect
|
||||
|
||||
export function fromRow(row: Row): Info {
|
||||
const icon =
|
||||
row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined
|
||||
return {
|
||||
id: row.id,
|
||||
worktree: row.worktree,
|
||||
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
||||
name: row.name ?? undefined,
|
||||
icon,
|
||||
time: {
|
||||
created: row.time_created,
|
||||
updated: row.time_updated,
|
||||
initialized: row.time_initialized ?? undefined,
|
||||
},
|
||||
sandboxes: row.sandboxes,
|
||||
commands: row.commands ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
export const UpdateInput = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
name: z.string().optional(),
|
||||
icon: Info.shape.icon.optional(),
|
||||
commands: Info.shape.commands.optional(),
|
||||
})
|
||||
export type UpdateInput = z.infer<typeof UpdateInput>
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
export interface Interface {
|
||||
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
||||
readonly discover: (input: Info) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info>
|
||||
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
|
||||
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
|
||||
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
|
||||
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
||||
)
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
project: data.id,
|
||||
payload: { type: Event.Updated.type, properties: data },
|
||||
}),
|
||||
)
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
|
||||
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
project: data.id,
|
||||
payload: { type: Event.Updated.type, properties: data },
|
||||
}),
|
||||
)
|
||||
const resolveGitPath = (cwd: string, name: string) => {
|
||||
if (!name) return cwd
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
name = AppFileSystem.windowsPath(name)
|
||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||
return pathSvc.resolve(cwd, name)
|
||||
}
|
||||
|
||||
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const resolveGitPath = (cwd: string, name: string) => {
|
||||
if (!name) return cwd
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
name = AppFileSystem.windowsPath(name)
|
||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||
return pathSvc.resolve(cwd, name)
|
||||
}
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
// Phase 1: discover git info
|
||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
if (commonDir.code !== 0) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
const worktree = (() => {
|
||||
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
||||
return common === sandbox ? sandbox : pathSvc.dirname(common)
|
||||
})()
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted()
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
if (topLevel.code !== 0) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
// Phase 1: discover git info
|
||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
fs.exists(s).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((exists) => (exists ? s : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
if (commonDir.code !== 0) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
const worktree = (() => {
|
||||
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
||||
return common === sandbox ? sandbox : pathSvc.dirname(common)
|
||||
})()
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted()
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
if (topLevel.code !== 0) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
|
||||
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
fs.exists(s).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((exists) => (exists ? s : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
|
||||
yield* db((d) =>
|
||||
d
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: result.id,
|
||||
yield* db((d) =>
|
||||
d
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: result.id,
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_created: result.time.created,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: {
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_created: result.time.created,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: {
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
|
||||
if (data.id !== ProjectID.global) {
|
||||
yield* db((d) =>
|
||||
d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
|
||||
if (data.id !== ProjectID.global) {
|
||||
yield* db((d) =>
|
||||
d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
yield* emitUpdated(result)
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
})
|
||||
|
||||
yield* emitUpdated(result)
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
})
|
||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
|
||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
const matches = yield* fs
|
||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
|
||||
const matches = yield* fs
|
||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
yield* update({ projectID: input.id, icon: { url } })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
||||
})
|
||||
|
||||
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name,
|
||||
icon_url: input.icon?.url,
|
||||
icon_color: input.icon?.color,
|
||||
commands: input.commands,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
||||
const data = fromRow(result)
|
||||
yield* emitUpdated(data)
|
||||
return data
|
||||
})
|
||||
|
||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
yield* update({ projectID: input.id, icon: { url } })
|
||||
})
|
||||
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
|
||||
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
|
||||
}
|
||||
const { project } = yield* fromDirectory(input.directory)
|
||||
return project
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
||||
})
|
||||
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
|
||||
yield* db((d) =>
|
||||
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
const data = fromRow(row)
|
||||
return yield* Effect.forEach(
|
||||
data.sandboxes,
|
||||
(dir) =>
|
||||
fs.isDir(dir).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((ok) => (ok ? dir : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name,
|
||||
icon_url: input.icon?.url,
|
||||
icon_color: input.icon?.color,
|
||||
commands: input.commands,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
||||
const data = fromRow(result)
|
||||
yield* emitUpdated(data)
|
||||
return data
|
||||
})
|
||||
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = [...row.sandboxes]
|
||||
if (!sboxes.includes(directory)) sboxes.push(directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
|
||||
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
|
||||
}
|
||||
const { project } = yield* fromDirectory(input.directory)
|
||||
return project
|
||||
})
|
||||
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
|
||||
yield* db((d) =>
|
||||
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
})
|
||||
return Service.of({
|
||||
fromDirectory,
|
||||
discover,
|
||||
list,
|
||||
get,
|
||||
update,
|
||||
initGit,
|
||||
setInitialized,
|
||||
sandboxes,
|
||||
addSandbox,
|
||||
removeSandbox,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
const data = fromRow(row)
|
||||
return yield* Effect.forEach(
|
||||
data.sandboxes,
|
||||
(dir) =>
|
||||
fs.isDir(dir).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((ok) => (ok ? dir : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
})
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = [...row.sandboxes]
|
||||
if (!sboxes.includes(directory)) sboxes.push(directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
fromDirectory,
|
||||
discover,
|
||||
list,
|
||||
get,
|
||||
update,
|
||||
initGit,
|
||||
setInitialized,
|
||||
sandboxes,
|
||||
addSandbox,
|
||||
removeSandbox,
|
||||
})
|
||||
}),
|
||||
export function list() {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(ProjectTable)
|
||||
.all()
|
||||
.map((row) => fromRow(row)),
|
||||
)
|
||||
}
|
||||
|
||||
export function get(id: ProjectID): Info | undefined {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return undefined
|
||||
return fromRow(row)
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectID) {
|
||||
Database.use((db) =>
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
export function list() {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(ProjectTable)
|
||||
.all()
|
||||
.map((row) => fromRow(row)),
|
||||
)
|
||||
}
|
||||
|
||||
export function get(id: ProjectID): Info | undefined {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return undefined
|
||||
return fromRow(row)
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectID) {
|
||||
Database.use((db) =>
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,223 +11,221 @@ import { Log } from "@/util"
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
const merge = (...lists: Git.Item[][]) => {
|
||||
const out = new Map<string, Git.Item>()
|
||||
lists.flat().forEach((item) => {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
})
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
patch: patch(item.file, before, after),
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
)
|
||||
})
|
||||
|
||||
export const Mode = z.enum(["git", "branch"])
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
z.object({
|
||||
branch: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string().optional(),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
patch: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsFileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
root: Git.Base | undefined
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")(function* (ctx) {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Vcs.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||
const value = yield* InstanceState.get(state)
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(
|
||||
fs,
|
||||
git,
|
||||
Instance.directory,
|
||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* compare(fs, git, Instance.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
const merge = (...lists: Git.Item[][]) => {
|
||||
const out = new Map<string, Git.Item>()
|
||||
lists.flat().forEach((item) => {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
})
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
patch: patch(item.file, before, after),
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
)
|
||||
})
|
||||
|
||||
export const Mode = z.enum(["git", "branch"])
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
z.object({
|
||||
branch: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string().optional(),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
patch: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsFileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
root: Git.Base | undefined
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")(function* (ctx) {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Vcs.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||
const value = yield* InstanceState.get(state)
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(
|
||||
fs,
|
||||
git,
|
||||
Instance.directory,
|
||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* compare(fs, git, Instance.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ModelMessage } from "ai"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type { Provider } from "."
|
||||
import type * as Provider from "./provider"
|
||||
import type { ModelsDev } from "./models"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -274,7 +274,7 @@ export namespace ProviderTransform {
|
||||
|
||||
// Check for empty base64 image data
|
||||
if (part.type === "image") {
|
||||
const imageStr = part.image.toString()
|
||||
const imageStr = String(part.image)
|
||||
if (imageStr.startsWith("data:")) {
|
||||
const match = imageStr.match(/^data:([^;]+);base64,(.*)$/)
|
||||
if (match && (!match[2] || match[2].length === 0)) {
|
||||
@@ -286,7 +286,7 @@ export namespace ProviderTransform {
|
||||
}
|
||||
}
|
||||
|
||||
const mime = part.type === "image" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType
|
||||
const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType
|
||||
const filename = part.type === "file" ? part.filename : undefined
|
||||
const modality = mimeToModality(mime)
|
||||
if (!modality) return part
|
||||
|
||||
@@ -346,7 +346,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(String(message))
|
||||
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
|
||||
},
|
||||
onClose: () => {
|
||||
log.info("client disconnected from session", { id })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import { NotFoundError } from "../storage"
|
||||
|
||||
export const ERRORS = {
|
||||
400: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Database, inArray } from "@/storage/db"
|
||||
import { Database, inArray } from "@/storage"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { ProviderID, ModelID } from "../../provider/schema"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolRegistry } from "../../tool"
|
||||
import { Worktree } from "../../worktree"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { Project } from "../../project"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Session } from "../../session"
|
||||
import { Config } from "../../config"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unsta
|
||||
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Observability } from "@/effect/observability"
|
||||
import { Observability } from "@/effect"
|
||||
import { memoMap } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
|
||||
@@ -6,7 +6,7 @@ import z from "zod"
|
||||
import { Format } from "../../format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Vcs } from "../../project/vcs"
|
||||
import { Vcs } from "../../project"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Skill } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { Project } from "../../project"
|
||||
import z from "zod"
|
||||
import { ProjectID } from "../../project/schema"
|
||||
import { errors } from "../error"
|
||||
|
||||
@@ -6,7 +6,7 @@ import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { NotFoundError } from "../../storage"
|
||||
import { errors } from "../error"
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SessionPrompt } from "../../session/prompt"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionCompaction } from "../../session/compaction"
|
||||
import { SessionRevert } from "../../session/revert"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SessionShare } from "@/share"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "../../session/todo"
|
||||
|
||||
@@ -2,7 +2,7 @@ import z from "zod"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Database, asc, and, not, or, lte, eq } from "@/storage/db"
|
||||
import { Database, asc, and, not, or, lte, eq } from "@/storage"
|
||||
import { EventTable } from "@/sync/event.sql"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Log } from "@/util"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider } from "../provider"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import { NotFoundError } from "../storage"
|
||||
import { Session } from "../session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||
|
||||
@@ -3,7 +3,7 @@ import sessionProjectors from "../session/projectors"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "@/session"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Database, eq } from "@/storage"
|
||||
|
||||
export function initProjectors() {
|
||||
SyncEvent.init({
|
||||
|
||||
@@ -37,9 +37,9 @@ export const UIRoutes = (): Hono =>
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
raw: c.req.raw,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
...Object.fromEntries(c.req.raw.headers.entries()),
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "."
|
||||
import * as Session from "./session"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { Provider } from "../provider"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -11,7 +11,7 @@ import { SessionProcessor } from "./processor"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { NotFoundError } from "@/storage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
|
||||
@@ -277,11 +277,10 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
const id = PermissionID.ascending()
|
||||
let reply: Permission.Reply | undefined
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
|
||||
if (evt.properties.requestID === id) reply = evt.properties.reply
|
||||
if (evt.properties.requestID === id) void evt.properties.reply
|
||||
})
|
||||
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
|
||||
import { LSP } from "../lsp"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
|
||||
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage"
|
||||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -15,7 +15,7 @@ import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { EffectLogger } from "@/effect"
|
||||
|
||||
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
|
||||
interface FetchDecompressionError extends Error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user