Compare commits

..

6 Commits

Author SHA1 Message Date
Dax
186ff7409c fix: show helpful message when free usage limit is exceeded (#13005) 2026-02-10 18:58:29 +00:00
Adam
1e2f664410 fix(app): back to platform fetch for now 2026-02-10 12:40:26 -06:00
Adam
a3aad9c9bf fix(app): include basic auth 2026-02-10 12:37:28 -06:00
Frank
eb2587844b zen: retry on 429 2026-02-10 13:35:16 -05:00
Adam
d863a9cf4e fix(app): global event default fetch 2026-02-10 12:29:01 -06:00
Frank
7d5be1556a wip: zen 2026-02-10 13:07:08 -05:00
13 changed files with 130 additions and 254 deletions

View File

@@ -513,7 +513,7 @@
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.11",
"@pierre/diffs": "1.0.2",
"@playwright/test": "1.51.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
@@ -1409,7 +1409,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.11", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-he0zoLgAv6Hm/MwNa5X2Q5t552iLjTqaOopIx3eeF7UMYaJaZ/osg8L6jaZcgzMH7Oe5RAfP685km6vd8nbsfQ=="],
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -4387,9 +4387,13 @@
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@pierre/diffs/shiki": ["shiki@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -4969,10 +4973,24 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],

View File

@@ -35,7 +35,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.11",
"@pierre/diffs": "1.0.2",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",

View File

@@ -12,35 +12,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
// Prefer the WebView fetch implementation for streaming responses.
// @tauri-apps/plugin-http 2.5.x has known issues with streaming/cancellation that can
// retain native resources in the Rust process.
const base = platform.platform === "desktop" ? globalThis.fetch : (platform.fetch ?? globalThis.fetch)
const eventFetch = Object.assign(
(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const password = (globalThis as { __OPENCODE__?: { serverPassword?: string } }).__OPENCODE__?.serverPassword
const header = password ? `Basic ${btoa(`opencode:${password}`)}` : undefined
const headers = new Headers(input instanceof Request ? input.headers : undefined)
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => {
headers.set(key, value)
})
}
if (header) headers.set("Authorization", header)
return base(input, { ...(init ?? {}), headers })
},
{
preconnect: (...args: Parameters<typeof fetch.preconnect>) => (base as any).preconnect?.(...args),
},
) satisfies typeof fetch
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
fetch: eventFetch,
fetch: platform.fetch,
})
const emitter = createGlobalEmitter<{
[key: string]: Event

View File

@@ -1,13 +1,13 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class SubscriptionError extends Error {
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class FreeUsageLimitError extends Error {}
export class SubscriptionUsageLimitError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}

View File

@@ -18,10 +18,10 @@ import {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
RateLimitError,
FreeUsageLimitError,
SubscriptionUsageLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
@@ -52,7 +52,8 @@ export async function handler(
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
const MAX_RETRIES = 3
const MAX_FAILOVER_RETRIES = 3
const MAX_429_RETRIES = 3
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
@@ -111,7 +112,7 @@ export async function handler(
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const res = await fetch(reqUrl, {
const res = await fetchWith429Retry(reqUrl, {
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
@@ -304,9 +305,9 @@ export async function handler(
{ status: 401 },
)
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
const headers = new Headers()
if (error instanceof SubscriptionError && error.retryAfter) {
if (error instanceof SubscriptionUsageLimitError && error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
@@ -369,7 +370,7 @@ export async function handler(
if (provider) return provider
}
if (retry.retryCount === MAX_RETRIES) {
if (retry.retryCount === MAX_FAILOVER_RETRIES) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
}
@@ -520,7 +521,7 @@ export async function handler(
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
throw new SubscriptionUsageLimitError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
@@ -534,7 +535,7 @@ export async function handler(
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
throw new SubscriptionUsageLimitError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
@@ -597,6 +598,15 @@ export async function handler(
providerInfo.apiKey = authInfo.provider.credentials
}
async function fetchWith429Retry(url: string, options: RequestInit, retry = { count: 0 }) {
const res = await fetch(url, options)
if (res.status === 429 && retry.count < MAX_429_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, retry.count) * 500))
return fetchWith429Retry(url, options, { count: retry.count + 1 })
}
return res
}
async function trackUsage(
authInfo: AuthInfo,
modelInfo: ModelInfo,

View File

@@ -1,6 +1,6 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js"
@@ -34,7 +34,7 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
if (total >= limitValue) throw new FreeUsageLimitError(`Rate limit exceeded. Please try again later.`)
},
}
}

View File

@@ -64,6 +64,8 @@ export namespace SessionRetry {
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError"))
return `Free usage exceeded, add credits https://opencode.ai/zen`
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}

View File

@@ -314,7 +314,7 @@ export function Code<T>(props: CodeProps<T>) {
const needle = query.toLowerCase()
const out: Range[] = []
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
@@ -539,28 +539,17 @@ export function Code<T>(props: CodeProps<T>) {
node.removeAttribute("data-comment-selected")
}
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
for (let line = start; line <= end; line++) {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
for (const annotation of annotations) {
const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(line)) continue
if (line < start || line > end) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}

View File

@@ -1,9 +1,8 @@
import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { useWorkerPool } from "../context/worker-pool"
export type SSRDiffProps<T = {}> = DiffProps<T> & {
@@ -25,21 +24,10 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const workerPool = useWorkerPool(props.diffStyle)
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const cleanupFunctions: Array<() => void> = []
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const applyScheme = () => {
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
@@ -82,10 +70,10 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const root = getRoot()
if (!root) return
const diffs = root.querySelector("[data-diff]")
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const split = diffs.dataset.type === "split"
const start = rowIndex(root, split, range.start, range.side)
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
@@ -144,19 +132,15 @@ export function Diff<T>(props: SSRDiffProps<T>) {
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diff]")
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const split = diffs.dataset.type === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
const code = Array.from(diffs.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (code.length === 0) return
const lineIndex = (element: HTMLElement) => {
const raw = element.dataset.lineIndex
@@ -199,18 +183,19 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
for (const block of code) {
for (const element of Array.from(block.children)) {
if (!(element instanceof HTMLElement)) continue
const idx = lineIndex(element)
if (idx === undefined) continue
if (idx > last) break
if (idx < first) continue
element.setAttribute("data-comment-selected", "")
const next = element.nextSibling
if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) {
next.setAttribute("data-comment-selected", "")
}
}
}
}
}
@@ -227,27 +212,14 @@ export function Diff<T>(props: SSRDiffProps<T>) {
onCleanup(() => monitor.disconnect())
}
const virtualizer = getVirtualizer()
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...props.preloadedDiff,
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...props.preloadedDiff,
},
workerPool,
)
fileDiffInstance = new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...props.preloadedDiff,
},
workerPool,
)
// @ts-expect-error - fileContainer is private but needed for SSR hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate({
@@ -301,8 +273,6 @@ export function Diff<T>(props: SSRDiffProps<T>) {
// Clean up FileDiff event handlers and dispose SolidJS components
fileDiffInstance?.cleanUp()
cleanupFunctions.forEach((dispose) => dispose())
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (

View File

@@ -1,9 +1,8 @@
import { checksum } from "@opencode-ai/util/encode"
import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { FileDiff, type SelectedLineRange } from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
type SelectionSide = "additions" | "deletions"
@@ -53,7 +52,6 @@ function findSide(node: Node | null): SelectionSide | undefined {
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
let observer: MutationObserver | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
@@ -94,16 +92,6 @@ export function Diff<T>(props: DiffProps<T>) {
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
const [rendered, setRendered] = createSignal(0)
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const getRoot = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
@@ -159,10 +147,10 @@ export function Diff<T>(props: DiffProps<T>) {
const root = getRoot()
if (!root) return
const diffs = root.querySelector("[data-diff]")
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const split = diffs.dataset.type === "split"
const start = rowIndex(root, split, range.start, range.side)
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
@@ -273,19 +261,15 @@ export function Diff<T>(props: DiffProps<T>) {
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diff]")
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const split = diffs.dataset.type === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
const code = Array.from(diffs.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (code.length === 0) return
for (const range of ranges) {
const start = rowIndex(root, split, range.start, range.side)
@@ -301,18 +285,19 @@ export function Diff<T>(props: DiffProps<T>) {
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(split, row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
for (const block of code) {
for (const element of Array.from(block.children)) {
if (!(element instanceof HTMLElement)) continue
const idx = lineIndex(split, element)
if (idx === undefined) continue
if (idx > last) break
if (idx < first) continue
element.setAttribute("data-comment-selected", "")
const next = element.nextSibling
if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) {
next.setAttribute("data-comment-selected", "")
}
}
}
}
}
@@ -529,15 +514,12 @@ export function Diff<T>(props: DiffProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
instance?.cleanUp()
instance = virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool)
instance = new FileDiff<T>(opts, workerPool)
setCurrent(instance)
container.innerHTML = ""
@@ -624,8 +606,6 @@ export function Diff<T>(props: DiffProps<T>) {
instance?.cleanUp()
setCurrent(undefined)
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return <div data-component="diff" style={styleVariables} ref={container} />

View File

@@ -13,7 +13,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
}
const unsafeCSS = `
[data-diff] {
[data-diffs] {
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
@@ -44,7 +44,7 @@ const unsafeCSS = `
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
}
:host([data-color-scheme='dark']) [data-diff] {
:host([data-color-scheme='dark']) [data-diffs] {
--diffs-selection-number-fg: #fdfbfb;
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
--diffs-bg-selection-number: var(
@@ -53,7 +53,7 @@ const unsafeCSS = `
);
}
[data-diff] ::selection {
[data-diffs] ::selection {
background-color: var(--diffs-bg-selection-text);
}
@@ -65,46 +65,42 @@ const unsafeCSS = `
background-color: rgb(from var(--surface-warning-strong) r g b / 0.55);
}
[data-diff] [data-line][data-comment-selected]:not([data-selected-line]) {
[data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-content] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
[data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-number] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
[data-diff] [data-line][data-selected-line] {
[data-diffs] [data-selected-line] {
background-color: var(--diffs-bg-selection);
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
}
[data-diff] [data-column-number][data-selected-line] {
[data-diffs] [data-selected-line] [data-column-number] {
background-color: var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] {
[data-diffs] [data-line-type='context'][data-selected-line] [data-column-number],
[data-diffs] [data-line-type='context-expanded'][data-selected-line] [data-column-number],
[data-diffs] [data-line-type='change-addition'][data-selected-line] [data-column-number],
[data-diffs] [data-line-type='change-deletion'][data-selected-line] [data-column-number] {
color: var(--diffs-selection-number-fg);
}
/* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */
[data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] {
[data-diffs] [data-line-type='change-deletion'][data-selected-line] {
--diffs-bg-deletion-emphasis: light-dark(
rgb(from var(--diffs-deletion-base) r g b / 0.07),
rgb(from var(--diffs-deletion-base) r g b / 0.1)
);
}
[data-diff-header],
[data-diff] {
[data-diffs-header],
[data-diffs] {
[data-separator-wrapper] {
margin: 0 !important;
border-radius: 0 !important;
@@ -150,15 +146,28 @@ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"])
overflow: "wrap",
diffStyle: style ?? "unified",
diffIndicators: "bars",
lineHoverHighlight: "both",
disableBackground: false,
expansionLineCount: 20,
hunkSeparators: "line-info-basic",
lineDiffType: style === "split" ? "word-alt" : "none",
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
disableFileHeader: true,
unsafeCSS,
// hunkSeparators(hunkData: HunkData) {
// const fragment = document.createDocumentFragment()
// const numCol = document.createElement("div")
// numCol.innerHTML = `<svg data-slot="diff-hunk-separator-line-number-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.97978 14.0204L8.62623 13.6668L9.33334 12.9597L9.68689 13.3133L9.33333 13.6668L8.97978 14.0204ZM12 16.3335L12.3535 16.6871L12 17.0406L11.6464 16.687L12 16.3335ZM14.3131 13.3133L14.6667 12.9597L15.3738 13.6668L15.0202 14.0204L14.6667 13.6668L14.3131 13.3133ZM12.5 16.0002V16.5002H11.5V16.0002H12H12.5ZM9.33333 13.6668L9.68689 13.3133L12.3535 15.9799L12 16.3335L11.6464 16.687L8.97978 14.0204L9.33333 13.6668ZM12 16.3335L11.6464 15.9799L14.3131 13.3133L14.6667 13.6668L15.0202 14.0204L12.3535 16.6871L12 16.3335ZM6.5 8.00016V7.50016H8.5V8.00016V8.50016H6.5V8.00016ZM9.5 8.00016V7.50016H11.5V8.00016V8.50016H9.5V8.00016ZM12.5 8.00016V7.50016H14.5V8.00016V8.50016H12.5V8.00016ZM15.5 8.00016V7.50016H17.5V8.00016V8.50016H15.5V8.00016ZM12 10.5002H12.5V16.0002H12H11.5V10.5002H12Z" fill="currentColor"/></svg> `
// numCol.dataset["slot"] = "diff-hunk-separator-line-number"
// fragment.appendChild(numCol)
// const contentCol = document.createElement("div")
// contentCol.dataset["slot"] = "diff-hunk-separator-content"
// const span = document.createElement("span")
// span.dataset["slot"] = "diff-hunk-separator-content-span"
// span.textContent = `${hunkData.lines} unmodified lines`
// contentCol.appendChild(span)
// fragment.appendChild(contentCol)
// return fragment
// },
} as const
}

View File

@@ -1,76 +0,0 @@
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
type Target = {
key: Document | HTMLElement
root: Document | HTMLElement
content: HTMLElement | undefined
}
type Entry = {
virtualizer: Virtualizer
refs: number
}
const cache = new WeakMap<Document | HTMLElement, Entry>()
export const virtualMetrics: Partial<VirtualFileMetrics> = {
lineHeight: 24,
hunkSeparatorHeight: 24,
fileGap: 0,
}
function target(container: HTMLElement): Target | undefined {
if (typeof document === "undefined") return
const root = container.closest("[data-component='session-review']")
if (root instanceof HTMLElement) {
const content = root.querySelector("[data-slot='session-review-container']")
return {
key: root,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
return {
key: document,
root: document,
content: undefined,
}
}
export function acquireVirtualizer(container: HTMLElement) {
const resolved = target(container)
if (!resolved) return
let entry = cache.get(resolved.key)
if (!entry) {
const virtualizer = new Virtualizer()
virtualizer.setup(resolved.root, resolved.content)
entry = {
virtualizer,
refs: 0,
}
cache.set(resolved.key, entry)
}
entry.refs += 1
let done = false
return {
virtualizer: entry.virtualizer,
release() {
if (done) return
done = true
const current = cache.get(resolved.key)
if (!current) return
current.refs -= 1
if (current.refs > 0) return
current.virtualizer.cleanUp()
cache.delete(resolved.key)
},
}
}

View File

@@ -21,7 +21,6 @@ function createPool(lineDiffType: "none" | "word-alt") {
{
theme: "OpenCode",
lineDiffType,
preferredHighlighter: "shiki-wasm",
},
)