Compare commits

...

35 Commits

Author SHA1 Message Date
Shoubhit Dash
7e2b9ac161 refactor: remove type cast 2026-03-25 20:46:05 +05:30
Shoubhit Dash
18d8a1365c wip: merge origin/dev into opencode/quiet-falcon 2026-03-25 20:43:42 +05:30
Aiden Cline
ad40b65b0b chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064) 2026-03-25 10:00:39 -05:00
opencode-agent[bot]
971383661a chore: generate 2026-03-25 14:48:44 +00:00
James Long
b0017bf1b9 feat(core): initial implementation of syncing (#17814) 2026-03-25 10:47:40 -04:00
Adam
0c0c6f3bdb chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam
b480a38d31 chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam
4167e25c7e fix(app): opencode web server url 2026-03-25 06:41:00 -05:00
Adam
1041ae91d1 Reapply "fix(app): startup efficiency"
This reverts commit 898456a25c.
2026-03-25 06:25:57 -05:00
Adam
898456a25c Revert "fix(app): startup efficiency" 2026-03-25 06:25:05 -05:00
Adam
53d0b58ebf fix(app): hash inline script for csp 2026-03-25 05:59:06 -05:00
Adam
2b0baf97bd Reapply "fix(app): more startup efficiency (#18985)"
This reverts commit cbe1337f24.
2026-03-25 05:59:06 -05:00
Adam
0dbfefa080 Reapply "fix(app): startup efficiency (#18854)"
This reverts commit a379eb3867.
2026-03-25 05:59:05 -05:00
Shoubhit Dash
d1c49ba210 fix(app): move message navigation off cmd+arrow (#18728) 2026-03-25 05:24:55 -05:00
Brendan Allan
3ea72aec21 app: pre-warm project globalSync state when navigate project via keybind (#19088) 2026-03-25 17:32:49 +08:00
Brendan Allan
9717383823 electron: remove file extension from electron-store wrapper (#19082) 2026-03-25 07:57:27 +00:00
Brendan Allan
5d9e780029 electron: add createDirectory to open directory picker (#19071) 2026-03-25 06:25:51 +00:00
Luke Parker
aa11fa865d fix: unblock beta conflict recovery (#19068) 2026-03-25 06:14:38 +00:00
Luke Parker
9a64bdb539 fix: beta resolver typecheck + build smoke check (#19060) 2026-03-25 05:45:30 +00:00
Aiden Cline
71693cc24b tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) 2026-03-25 00:31:29 -05:00
Shoubhit Dash
7f7dc3b909 server: keep gitlab workflow sessions working after provider updates 2026-03-25 05:19:56 +05:30
Shoubhit Dash
724fd59314 wip: merge origin/dev into opencode/quiet-falcon 2026-03-25 05:17:27 +05:30
Shoubhit Dash
b679209e15 desktop: keep review diffs fresh after branch switches and external edits 2026-03-25 05:12:34 +05:30
Shoubhit Dash
1344a57153 server: reset worktrees against each repo's default branch 2026-03-25 05:12:29 +05:30
Shoubhit Dash
73de13072a Merge remote-tracking branch 'origin/dev' into opencode/quiet-falcon
# Conflicts:
#	packages/app/src/context/global-sync/bootstrap.ts
2026-03-24 20:33:57 +05:30
Shoubhit Dash
6cd76bca68 app: keep new sessions on review without crashing live diff updates 2026-03-24 20:27:51 +05:30
Shoubhit Dash
ec81cb9d46 app: keep empty sessions from auto-opening review 2026-03-24 20:18:38 +05:30
Shoubhit Dash
62f70a9660 Merge branch 'dev' into opencode/quiet-falcon 2026-03-24 16:40:15 +05:30
Shoubhit Dash
dc11d7b89f Revert "docs: clarify that documentation covers both configuration and usage, helping users understand what to expect"
This reverts commit 050c8610c6.
2026-03-24 13:50:38 +05:30
Shoubhit Dash
050c8610c6 docs: clarify that documentation covers both configuration and usage, helping users understand what to expect 2026-03-24 13:44:12 +05:30
Shoubhit Dash
f01ba6132d core: ensure VCS state is cached even when branch is undefined to support detached HEAD states 2026-03-24 13:43:53 +05:30
Shoubhit Dash
eb03997065 tui: allow users to switch between uncommitted changes and full branch diff in review panel 2026-03-24 13:43:47 +05:30
Shoubhit Dash
01d786ea76 core: add /vcs/diff endpoint to retrieve git diff for working tree or default branch comparison 2026-03-24 13:43:45 +05:30
Shoubhit Dash
614ae4895e core: add VCS diff tracking with branch comparison and file statistics so agents can analyze changes against default branch 2026-03-24 13:43:42 +05:30
Shoubhit Dash
324a39833d core: refactor git utilities from util/git.ts to dedicated Git service module with proper Effect patterns 2026-03-24 13:43:40 +05:30
101 changed files with 8792 additions and 2710 deletions

View File

@@ -329,7 +329,7 @@
"@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -1325,7 +1325,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -2889,7 +2889,7 @@
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
@@ -5129,6 +5129,8 @@
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
@@ -6311,6 +6313,8 @@
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],

View File

@@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
@@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
export function AppBaseProviders(props: ParentProps) {
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
<Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider>
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>

View File

@@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const methods = createMemo(
() =>
globalSync.data.provider_auth[props.provider] ?? [
{
type: "api",
label: language.t("provider.connect.method.apiKey"),
},
],
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
@@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
onMount(() => {
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
selectMethod(0)
}
})
@@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>

View File

@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent()
const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))

View File

@@ -1,27 +1,41 @@
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined
}
const playDemoSound = (src: string | undefined) => {
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!src) return
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
@@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
@@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo(() =>
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
@@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
] as const
const fontOptionsList = [...fontOptions]
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = (
@@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.src)
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
@@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
playDemoSound(option.src)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
@@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
void loadFont().then((x) => x.ensureMonoFont(option?.value))
}}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"

View File

@@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -54,11 +53,15 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
@@ -162,6 +165,12 @@ export function StatusPopover() {
const navigate = useNavigate()
const [shown, setShown] = createSignal(false)
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -169,7 +178,7 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers)
const health = useServerHealth(servers, shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -300,7 +309,13 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>

View File

@@ -1,4 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

View File

@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"

View File

@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
})
test("matchKeybind supports bracket keys", () => {
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
const prev = keybinds[0]
const next = keybinds[1]
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
),
).toBe(true)
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")

View File

@@ -9,17 +9,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
getOwner,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
} from "solid-js"
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
@@ -80,6 +70,8 @@ function createGlobalSync() {
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
onCleanup(() => {
active = false
@@ -258,6 +250,11 @@ function createGlobalSync() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
global: {
config: globalStore.config,
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
store: child[0],
setStore: child[1],
@@ -278,15 +275,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: queue.refresh,
refresh: () => {
if (recent) return
queue.refresh()
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
@@ -325,17 +327,19 @@ function createGlobalSync() {
})
async function bootstrap() {
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootingRoot = true
try {
await bootstrapGlobal({
globalSDK: globalSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootedAt = Date.now()
} finally {
bootingRoot = false
}
}
onMount(() => {
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return (
<Switch>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {

View File

@@ -31,73 +31,102 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
})
})
}
function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
}
function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const health = await input.globalSDK.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
showToast({
variant: "error",
title: input.connectErrorTitle,
description: input.connectErrorDescription,
})
input.setGlobalStore("ready", true)
return
}
const tasks = [
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
]
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) {
const message = formatServerError(errors[0], input.translate)
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
showToast({
variant: "error",
title: input.requestFailedTitle,
description: message + more,
})
}
const slow = [
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
]
showErrors({
errors: errors(await runAll(fast)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
await waitForPaint()
showErrors({
errors: errors(await runAll(slow)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
input.setGlobalStore("ready", true)
}
@@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
}) {
if (input.store.status !== "complete") input.setStore("status", "loading")
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
global: {
config: Config
project: Project[]
provider: ProviderListResponse
}
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
if (seededProject) input.setStore("project", seededProject)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
}
if (loading) input.setStore("status", "partial")
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const fast = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
]
const slow = [
() =>
retry(() =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
description: formatServerError(errs[0], input.translate),
})
input.setStore("status", "partial")
return
}
if (input.store.status !== "complete") input.setStore("status", "partial")
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
}

View File

@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
})
test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
},
})
expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test" })
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
})
test("routes disposal and lsp events to side-effect handlers", () => {

View File

@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
const props = event.properties as { branch?: string }
if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
const next = { ...input.store.vcs, branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break

View File

@@ -1,42 +1,10 @@
import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
import { dict as ko } from "@/i18n/ko"
import { dict as de } from "@/i18n/de"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as da } from "@/i18n/da"
import { dict as ja } from "@/i18n/ja"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale =
| "en"
@@ -59,6 +27,7 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
}
const base = i18n.flatten({ ...en, ...uiEn })
const DICT: Record<Locale, Dictionary> = {
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
const dicts = new Map<Locale, Dictionary>([["en", base]])
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
}
function loadDict(locale: Locale) {
const hit = dicts.get(locale)
if (hit) return Promise.resolve(hit)
if (locale === "en") return Promise.resolve(base)
const load = loaders[locale]
return load().then((next: Dictionary) => {
dicts.set(locale, next)
return next
})
}
export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
tr,
}
void PARITY_CHECK
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
return "en"
}
function normalizeLocale(value: string): Locale {
export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
} catch {
return
}
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
init: () => {
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
locale: detectLocale() as Locale,
locale: initial,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])
const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(dict, i18n.resolveTemplate)
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const label = (value: Locale) => t(LABEL_KEY[value])

View File

@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
import { playSoundById } from "@/utils/sound"
type NotificationBase = {
directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent()))
void playSoundById(settings.sounds.agent())
}
append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors()))
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined

View File

@@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
@@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
const id = store.appearance?.font ?? defaultSettings.appearance.font
if (id !== defaultSettings.appearance.font) {
void loadFont().then((x) => x.ensureMonoFont(id))
}
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
})
return {

View File

@@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 200
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? messagePageSize
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
@@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]

View File

@@ -1,45 +1,18 @@
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const template = "Terminal {{number}}"
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
const numbered = [
template,
"محطة طرفية {{number}}",
"Терминал {{number}}",
"ターミナル {{number}}",
"터미널 {{number}}",
"เทอร์มินัล {{number}}",
"终端 {{number}}",
"終端機 {{number}}",
]
export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number))
return template.replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {

View File

@@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
return projectStore.provider
if (projectStore.provider.all.length > 0) return projectStore.provider
}
return globalSync.data.provider
}

View File

@@ -535,6 +535,8 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",

View File

@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
return (
<DataProvider
data={sync.data}
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const location = useLocation()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const navigate = useNavigate()
let invalid = ""
const [resolved] = createResource(
() => {
if (params.dir) return [location.pathname, params.dir] as const
},
async ([pathname, b64Dir]) => {
const directory = decode64(b64Dir)
const resolved = createMemo(() => {
if (!params.dir) return ""
return decode64(params.dir) ?? ""
})
if (!directory) {
if (invalid === params.dir) return
invalid = b64Dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
return await globalSDK
.createClient({
directory,
throwOnError: true,
})
.path.get()
.then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (resolved()) {
invalid = ""
return
}
if (invalid === dir) return
invalid = dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
})
return (
<Show when={resolved()} keyed>

View File

@@ -113,6 +113,14 @@ export default function Home() {
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />

View File

@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
let dialogRun = 0
let dialogDead = false
const params = useParams()
const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
})
onCleanup(() => {
dialogDead = true
dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
@@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({
title: language.t("toast.theme.title"),
description: nextTheme?.name ?? nextThemeId,
description: theme.name(nextThemeId),
})
}
@@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions()))
void playSoundById(settings.sounds.permissions())
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
@@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) {
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
// warm up child store to prevent flicker
globalSync.child(target.worktree)
openProject(target.worktree)
}
@@ -1152,10 +1152,10 @@ export default function Layout(props: ParentProps) {
},
]
for (const [id, definition] of availableThemeEntries()) {
for (const [id] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
title: language.t("command.theme.set", { theme: definition.name ?? id }),
title: language.t("command.theme.set", { theme: theme.name(id) }),
category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(),
onHighlight: () => {
@@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) {
})
function connectProvider() {
dialog.show(() => <DialogSelectProvider />)
const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
})
}
function openServer() {
dialog.show(() => <DialogSelectServer />)
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
})
}
function openSettings() {
dialog.show(() => <DialogSettings />)
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
}
function projectRoot(directory: string) {
@@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
const showEditProjectDialog = (project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
async function chooseProject() {
function resolve(result: string | string[] | null) {
@@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
})
}
}

View File

@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
@@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
const emptyFollowups: (FollowupDraft & { id: string })[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
@@ -424,15 +427,16 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!params.id)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -455,6 +459,12 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -508,11 +518,22 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
changes: "git" as ChangeMode,
newSessionWorktree: "main",
deferRender: false,
})
const [vcs, setVcs] = createStore({
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
failed: {} as Record<string, string | undefined>,
@@ -539,6 +560,68 @@ export default function Page() {
let refreshTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -554,7 +637,42 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("session", "turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -620,13 +738,7 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -748,13 +860,46 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setStore("changes", "git")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect(
on(
() => params.dir,
@@ -877,6 +1022,40 @@ export default function Page() {
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -923,21 +1102,23 @@ export default function Page() {
loadFile: file.load,
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => {
if (!hasReview()) {
if (!canReview()) {
return null
}
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Select
options={changesOptionsList}
options={changesOptions()}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
label={label}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -946,20 +1127,34 @@ export default function Page() {
)
}
const emptyTurn = () => (
const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
</div>
)
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
})
if (hasReview() && !diffsReady()) {
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
@@ -979,7 +1174,7 @@ export default function Page() {
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div>
)
}
@@ -1083,7 +1278,7 @@ export default function Page() {
const pending = tree.pendingDiff
if (!pending) return
if (!tree.reviewScroll) return
if (!diffsReady()) return
if (!reviewReady()) return
const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
@@ -1124,10 +1319,7 @@ export default function Page() {
const id = params.id
if (!id) return
const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (!wantsReview()) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
@@ -1136,13 +1328,7 @@ export default function Page() {
createEffect(
on(
() =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
() => [sessionKey(), wantsReview()] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1184,8 +1370,6 @@ export default function Page() {
on(
() => sdk.directory,
() => {
void file.tree.list("")
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -1640,6 +1824,9 @@ export default function Page() {
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
@@ -1711,7 +1898,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={lastUserMessage()}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
@@ -1827,6 +2014,12 @@ export default function Page() {
</div>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}

View File

@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
</Show>
<div
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
data-slot="session-turn-list"
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,

View File

@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { params, sessionKey, tabs, view } = useSessionLayout()
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
for (const diff of props.diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: props.canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
@@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
@@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={diffsReady()}
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
/>
</Show>
</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
<Match when={true}>{empty(props.empty())}</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">

View File

@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync.session.get(id)
}
const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
@@ -333,7 +329,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
@@ -341,7 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),

View File

@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
queue(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"

View File

@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
const defaultTimeoutMs = 3000
const defaultRetryCount = 2
const defaultRetryDelayMs = 100
const cacheMs = 750
const healthCache = new Map<
string,
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
return (http: ServerConnection.HttpBase) => {
const key = cacheKey(http)
const hit = healthCache.get(key)
const now = Date.now()
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
const promise = checkServerHealth(http, fetcher).finally(() => {
const next = healthCache.get(key)
if (!next || next.promise !== promise) return
next.done = true
next.at = Date.now()
})
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
return promise
}
}

View File

@@ -1,106 +1,89 @@
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
let files: Record<string, () => Promise<string>> | undefined
let loads: Record<SoundID, () => Promise<string>> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
string,
() => Promise<string>
>
return files
}
export const SOUND_OPTIONS = [
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
{ id: "alert-01", label: "sound.option.alert01" },
{ id: "alert-02", label: "sound.option.alert02" },
{ id: "alert-03", label: "sound.option.alert03" },
{ id: "alert-04", label: "sound.option.alert04" },
{ id: "alert-05", label: "sound.option.alert05" },
{ id: "alert-06", label: "sound.option.alert06" },
{ id: "alert-07", label: "sound.option.alert07" },
{ id: "alert-08", label: "sound.option.alert08" },
{ id: "alert-09", label: "sound.option.alert09" },
{ id: "alert-10", label: "sound.option.alert10" },
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
{ id: "staplebops-01", label: "sound.option.staplebops01" },
{ id: "staplebops-02", label: "sound.option.staplebops02" },
{ id: "staplebops-03", label: "sound.option.staplebops03" },
{ id: "staplebops-04", label: "sound.option.staplebops04" },
{ id: "staplebops-05", label: "sound.option.staplebops05" },
{ id: "staplebops-06", label: "sound.option.staplebops06" },
{ id: "staplebops-07", label: "sound.option.staplebops07" },
{ id: "nope-01", label: "sound.option.nope01" },
{ id: "nope-02", label: "sound.option.nope02" },
{ id: "nope-03", label: "sound.option.nope03" },
{ id: "nope-04", label: "sound.option.nope04" },
{ id: "nope-05", label: "sound.option.nope05" },
{ id: "nope-06", label: "sound.option.nope06" },
{ id: "nope-07", label: "sound.option.nope07" },
{ id: "nope-08", label: "sound.option.nope08" },
{ id: "nope-09", label: "sound.option.nope09" },
{ id: "nope-10", label: "sound.option.nope10" },
{ id: "nope-11", label: "sound.option.nope11" },
{ id: "nope-12", label: "sound.option.nope12" },
{ id: "yup-01", label: "sound.option.yup01" },
{ id: "yup-02", label: "sound.option.yup02" },
{ id: "yup-03", label: "sound.option.yup03" },
{ id: "yup-04", label: "sound.option.yup04" },
{ id: "yup-05", label: "sound.option.yup05" },
{ id: "yup-06", label: "sound.option.yup06" },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
function getLoads() {
if (loads) return loads
loads = Object.fromEntries(
Object.entries(getFiles()).flatMap(([path, load]) => {
const file = path.split("/").at(-1)
if (!file) return []
return [[file.replace(/\.aac$/, ""), load] as const]
}),
) as Record<SoundID, () => Promise<string>>
return loads
}
const cache = new Map<SoundID, Promise<string | undefined>>()
export function soundSrc(id: string | undefined) {
if (!id) return
if (!(id in soundById)) return
return soundById[id as SoundID]
const loads = getLoads()
if (!id || !(id in loads)) return Promise.resolve(undefined)
const key = id as SoundID
const hit = cache.get(key)
if (hit) return hit
const next = loads[key]().catch(() => undefined)
cache.set(key, next)
return next
}
export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
if (!src) return
const audio = new Audio(src)
audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}

View File

@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
/**
* @type {import("vite").PluginOption}
*/
@@ -21,6 +24,15 @@ export default [
}
},
},
{
name: "opencode-desktop:theme-preload",
transformIndexHtml(html) {
return html.replace(
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
)
},
},
tailwindcss(),
solidPlugin(),
]

View File

@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
"open-directory-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? "Choose a folder",
defaultPath: opts?.defaultPath,
})

View File

@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
const next = new Store({ name })
const next = new Store({ name, fileExtension: "" })
cache.set(name, next)
return next
}

View File

@@ -6,6 +6,9 @@ import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
@@ -246,6 +249,17 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount())
@@ -257,6 +271,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
@@ -309,15 +324,14 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
disableHealthCheck={(windowCount() ?? 0) > 1}
>
<Inner />
</AppInterface>

View File

@@ -6,6 +6,9 @@ import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
@@ -414,6 +417,17 @@ void listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
// Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@@ -423,6 +437,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
// Build the sidecar server connection once credentials arrive
const servers = () => {
@@ -465,8 +480,8 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
{(_) => {
return (
<AppInterface

View File

@@ -0,0 +1,13 @@
CREATE TABLE `event_sequence` (
`aggregate_id` text PRIMARY KEY,
`seq` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `event` (
`id` text PRIMARY KEY,
`aggregate_id` text NOT NULL,
`seq` integer NOT NULL,
`type` text NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
);

File diff suppressed because it is too large Load Diff

View File

@@ -92,7 +92,7 @@
"@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -133,9 +133,9 @@
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View File

@@ -8,6 +8,7 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
export type AccountRow = (typeof AccountTable)["$inferSelect"]
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1
@@ -42,13 +43,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: (db: DbClient) => A) =>
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const tx = <A>(f: (db: DbClient) => A) =>
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),

View File

@@ -1,10 +1,7 @@
import z from "zod"
import type { ZodType } from "zod"
import { Log } from "../util/log"
export namespace BusEvent {
const log = Log.create({ service: "event" })
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>()

View File

@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = {
login: string
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -890,7 +890,7 @@ export const GithubRunCommand = cmd({
}
let text = ""
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
if (evt.properties.part.sessionID !== session.id) return
//if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part

View File

@@ -1,8 +1,8 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({
command: "pr <number>",
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}

View File

@@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
sdk.event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
@@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
})
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
sdk.event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {

View File

@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { git } from "@/util/git"
import { Git } from "@/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
@@ -432,7 +432,7 @@ export namespace File {
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
@@ -452,7 +452,7 @@ export namespace File {
}
const untrackedOutput = (
await git(
await Git.run(
[
"-c",
"core.fsmonitor=false",
@@ -485,7 +485,7 @@ export namespace File {
}
const deletedOutput = (
await git(
await Git.run(
[
"-c",
"core.fsmonitor=false",
@@ -576,17 +576,17 @@ export namespace File {
if (Instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,

View File

@@ -10,8 +10,8 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { git } from "@/util/git"
import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { FileIgnore } from "./ignore"
@@ -130,7 +130,7 @@ export namespace FileWatcher {
if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], {
Git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
}),
)

View File

@@ -0,0 +1,307 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { makeRunPromise } from "@/effect/run-service"
export namespace Git {
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
const out = (result: { text(): string }) => result.text().trim()
const nuls = (text: string) => text.split("\0").filter(Boolean)
const fail = (err: unknown) =>
({
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}) satisfies Result
export type Kind = "added" | "deleted" | "modified"
export type Base = {
readonly name: string
readonly ref: string
}
export type Item = {
readonly file: string
readonly code: string
readonly status: Kind
}
export type Stat = {
readonly file: string
readonly additions: number
readonly deletions: number
}
export interface Result {
readonly exitCode: number
readonly text: () => string
readonly stdout: Buffer
readonly stderr: Buffer
}
export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
}
export interface Interface {
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
readonly prefix: (cwd: string) => Effect.Effect<string>
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
readonly status: (cwd: string) => Effect.Effect<Item[]>
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
}
const kind = (code: string): Kind => {
if (code === "??") return "added"
if (code.includes("U")) return "modified"
if (code.includes("A") && !code.includes("D")) return "added"
if (code.includes("D") && !code.includes("A")) return "deleted"
return "modified"
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const run = Effect.fn("Git.run")(
function* (args: string[], opts: Options) {
const proc = ChildProcess.make("git", [...cfg, ...args], {
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
return {
exitCode: yield* handle.exitCode,
text: () => stdout,
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
} satisfies Result
},
Effect.scoped,
Effect.catch((err) => Effect.succeed(fail(err))),
)
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
return (yield* run(args, opts)).text()
})
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
return (yield* text(args, opts))
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
})
const refs = Effect.fnUntraced(function* (cwd: string) {
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
})
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
const result = yield* run(["config", "init.defaultBranch"], { cwd })
const name = out(result)
if (!name || !list.includes(name)) return
return { name, ref: name } satisfies Base
})
const primary = Effect.fnUntraced(function* (cwd: string) {
const list = yield* lines(["remote"], { cwd })
if (list.includes("origin")) return "origin"
if (list.length === 1) return list[0]
if (list.includes("upstream")) return "upstream"
return list[0]
})
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
})
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
if (result.exitCode !== 0) return ""
return out(result)
})
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
const remote = yield* primary(cwd)
if (remote) {
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
if (head.exitCode === 0) {
const ref = out(head).replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
}
}
const list = yield* refs(cwd)
const next = yield* configured(cwd, list)
if (next) return next
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
})
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
return result.exitCode === 0
})
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
const result = yield* run(["merge-base", base, head], { cwd })
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
})
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
const target = prefix ? `${prefix}${file}` : file
const result = yield* run(["show", `${ref}:${target}`], { cwd })
if (result.exitCode !== 0) return ""
if (result.stdout.includes(0)) return ""
return result.text()
})
const status = Effect.fn("Git.status")(function* (cwd: string) {
return nuls(
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
cwd,
}),
).flatMap((item) => {
const file = item.slice(3)
if (!file) return []
const code = item.slice(0, 2)
return [{ file, code, status: kind(code) } satisfies Item]
})
})
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
const list = nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
)
return list.flatMap((code, idx) => {
if (idx % 2 !== 0) return []
const file = list[idx + 1]
if (!code || !file) return []
return [{ file, code, status: kind(code) } satisfies Item]
})
})
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
return nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
).flatMap((item) => {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
if (a === -1 || b === -1) return []
const file = item.slice(b + 1)
if (!file) return []
const adds = item.slice(0, a)
const dels = item.slice(a + 1, b)
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
return [
{
file,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
} satisfies Stat,
]
})
})
return Service.of({
run,
branch,
prefix,
defaultBranch,
hasHead,
mergeBase,
show,
status,
diff,
stats,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
export function run(args: string[], opts: Options) {
return runPromise((git) => git.run(args, opts))
}
export function branch(cwd: string) {
return runPromise((git) => git.branch(cwd))
}
export function prefix(cwd: string) {
return runPromise((git) => git.prefix(cwd))
}
export function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
export function hasHead(cwd: string) {
return runPromise((git) => git.hasHead(cwd))
}
export function mergeBase(cwd: string, base: string, head?: string) {
return runPromise((git) => git.mergeBase(cwd, base, head))
}
export function show(cwd: string, ref: string, file: string, prefix?: string) {
return runPromise((git) => git.show(cwd, ref, file, prefix))
}
export function status(cwd: string) {
return runPromise((git) => git.status(cwd))
}
export function diff(cwd: string, ref: string) {
return runPromise((git) => git.diff(cwd, ref))
}
export function stats(cwd: string, ref: string) {
return runPromise((git) => git.stats(cwd, ref))
}
}

View File

@@ -3,6 +3,7 @@ import { randomBytes } from "crypto"
export namespace Identifier {
const prefixes = {
event: "evt",
session: "ses",
message: "msg",
permission: "per",

View File

@@ -177,6 +177,12 @@ export namespace LSP {
async function getClients(file: string) {
const s = await state()
// Only spawn LSP clients for files within the instance directory
if (!Instance.containsPath(file)) {
return []
}
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []

View File

@@ -3,7 +3,6 @@ import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
@@ -58,6 +57,8 @@ export namespace Plugin {
const hooks: Hooks[] = []
yield* Effect.promise(async () => {
const { Server } = await import("../server/server")
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,

View File

@@ -1,17 +1,111 @@
import { Effect, Layer, ServiceMap } from "effect"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import { git } from "@/util/git"
import { Instance } from "./instance"
import z from "zod"
export namespace 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 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,
before,
after,
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.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",
@@ -23,7 +117,8 @@ export namespace Vcs {
export const Info = z
.object({
branch: z.string(),
branch: z.string().optional(),
default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
@@ -33,37 +128,35 @@ export namespace Vcs {
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<Snapshot.FileDiff[]>
}
interface State {
current: string | undefined
root: Git.Base | undefined
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect(
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () {
if (ctx.project.vcs !== "git") {
return { current: undefined }
return { current: undefined, root: undefined }
}
const getCurrentBranch = async () => {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: ctx.worktree,
})
if (result.exitCode !== 0) return undefined
const text = result.text().trim()
return text || undefined
}
const value = {
current: yield* Effect.promise(() => getCurrentBranch()),
}
log.info("initialized", { branch: value.current })
const get = () => Effect.runPromise(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* Effect.acquireRelease(
Effect.sync(() =>
@@ -71,12 +164,11 @@ export namespace Vcs {
FileWatcher.Event.Updated,
Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
const next = await getCurrentBranch()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
const next = await get()
if (next === value.current) return
log.info("branch changed", { from: value.current, to: next })
value.current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}),
),
),
@@ -95,11 +187,34 @@ export namespace Vcs {
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)
}),
})
}),
)
const runPromise = makeRunPromise(Service, layer)
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const runPromise = makeRunPromise(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
@@ -108,4 +223,12 @@ export namespace Vcs {
export function branch() {
return runPromise((svc) => svc.branch())
}
export function defaultBranch() {
return runPromise((svc) => svc.defaultBranch())
}
export function diff(mode: Mode) {
return runPromise((svc) => svc.diff(mode))
}
}

View File

@@ -0,0 +1,28 @@
import z from "zod"
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"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
initProjectors()

View File

@@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { lazy } from "../../util/lazy"
import { AsyncQueue } from "../../util/queue"
import { Instance } from "@/project/instance"
const log = Log.create({ service: "server" })
@@ -53,13 +52,6 @@ export const EventRoutes = lazy(() =>
)
}, 10_000)
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
const stop = () => {
if (done) return
done = true
@@ -69,6 +61,13 @@ export const EventRoutes = lazy(() =>
log.info("event disconnected")
}
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
stream.onAbort(stop)
try {

View File

@@ -1,9 +1,9 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import z from "zod"
import { Bus } from "../../bus"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
@@ -17,6 +17,56 @@ const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
unsub()
q.push(null)
log.info("global event disconnected")
}
const unsub = subscribe(q)
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
}
export const GlobalRoutes = lazy(() =>
new Hono()
.get(
@@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() =>
log.info("global event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
return streamEvents(c, (q) => {
async function handler(event: any) {
q.push(JSON.stringify(event))
}
GlobalBus.on("event", handler)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
GlobalBus.off("event", handler)
q.push(null)
log.info("event disconnected")
}
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
return () => GlobalBus.off("event", handler)
})
},
)
.get(
"/sync-event",
describeRoute({
summary: "Subscribe to global sync events",
description: "Get global sync events",
operationId: "global.sync-event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
payload: SyncEvent.payloads(),
})
.meta({
ref: "SyncEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global sync event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamEvents(c, (q) => {
return SyncEvent.subscribeAll(({ def, event }) => {
// TODO: don't pass def, just pass the type (and it should
// be versioned)
q.push(
JSON.stringify({
payload: {
...event,
type: SyncEvent.versionedType(def.type, def.version),
},
}),
)
})
})
},
)

View File

@@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
let session = await Session.get(sessionID)
if (updates.title !== undefined) {
session = await Session.setTitle({ sessionID, title: updates.title })
await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.time?.archived !== undefined) {
session = await Session.setArchived({ sessionID, time: updates.time.archived })
await Session.setArchived({ sessionID, time: updates.time.archived })
}
const session = await Session.get(sessionID)
return c.json(session)
},
)

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
@@ -38,15 +39,22 @@ import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { initProjectors } from "./projectors"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
initProjectors()
export namespace Server {
const log = Log.create({ service: "server" })
@@ -330,12 +338,40 @@ export namespace Server {
},
}),
async (c) => {
const branch = await Vcs.branch()
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
return c.json({
branch,
default_branch,
})
},
)
.get(
"/vcs/diff",
describeRoute({
summary: "Get VCS diff",
description: "Retrieve the current git diff for the working tree or against the default branch.",
operationId: "vcs.diff",
responses: {
200: {
description: "VCS diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
},
}),
validator(
"query",
z.object({
mode: Vcs.Mode,
}),
),
async (c) => {
return c.json(await Vcs.diff(c.req.valid("query").mode))
},
)
.get(
"/command",
describeRoute({
@@ -506,10 +542,13 @@ export namespace Server {
host: "app.opencode.ai",
},
})
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
)
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
})
}

View File

@@ -9,12 +9,14 @@ import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { SyncEvent } from "../sync"
import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt"
@@ -182,24 +184,40 @@ export namespace Session {
export type GlobalInfo = z.output<typeof GlobalInfo>
export const Event = {
Created: BusEvent.define(
"session.created",
z.object({
Created: SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
),
Updated: BusEvent.define(
"session.updated",
z.object({
}),
Updated: SyncEvent.define({
type: "session.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: updateSchema(Info).extend({
share: updateSchema(Info.shape.share.unwrap()).optional(),
time: updateSchema(Info.shape.time).optional(),
}),
}),
busSchema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
),
Deleted: BusEvent.define(
"session.deleted",
z.object({
}),
Deleted: SyncEvent.define({
type: "session.deleted",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
),
}),
Diff: BusEvent.define(
"session.diff",
z.object({
@@ -280,18 +298,8 @@ export namespace Session {
)
export const touch = fn(SessionID.zod, async (sessionID) => {
const now = Date.now()
Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_updated: now })
.where(eq(SessionTable.id, sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
})
const time = Date.now()
SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } })
})
export async function createNext(input: {
@@ -318,22 +326,25 @@ export namespace Session {
},
}
log.info("created", result)
Database.use((db) => {
db.insert(SessionTable).values(toRow(result)).run()
Database.effect(() =>
Bus.publish(Event.Created, {
info: result,
}),
)
})
SyncEvent.run(Event.Created, { sessionID: result.id, info: result })
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
share(result.id).catch(() => {
// Silently ignore sharing errors during session creation
})
Bus.publish(Event.Updated, {
info: result,
})
}
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
Bus.publish(Event.Updated, {
sessionID: result.id,
info: result,
})
}
return result
}
@@ -357,12 +368,9 @@ export namespace Session {
}
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
Database.use((db) => {
const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
})
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } })
return share
})
@@ -370,12 +378,8 @@ export namespace Session {
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
Database.use((db) => {
const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
})
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
})
export const setTitle = fn(
@@ -384,18 +388,7 @@ export namespace Session {
title: z.string(),
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ title: input.title })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } })
},
)
@@ -405,18 +398,7 @@ export namespace Session {
time: z.number().optional(),
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_archived: input.time })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } })
},
)
@@ -426,17 +408,9 @@ export namespace Session {
permission: Permission.Ruleset,
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ permission: input.permission, time_updated: Date.now() })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: { permission: input.permission, time: { updated: Date.now() } },
})
},
)
@@ -448,42 +422,24 @@ export namespace Session {
summary: Info.shape.summary,
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
revert: input.revert ?? null,
summary_additions: input.summary?.additions,
summary_deletions: input.summary?.deletions,
summary_files: input.summary?.files,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: {
summary: input.summary,
time: { updated: Date.now() },
revert: input.revert,
},
})
},
)
export const clearRevert = fn(SessionID.zod, async (sessionID) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
revert: null,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
SyncEvent.run(Event.Updated, {
sessionID,
info: {
time: { updated: Date.now() },
revert: null,
},
})
})
@@ -493,22 +449,12 @@ export namespace Session {
summary: Info.shape.summary,
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
summary_additions: input.summary?.additions,
summary_deletions: input.summary?.deletions,
summary_files: input.summary?.files,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: {
time: { updated: Date.now() },
summary: input.summary,
},
})
},
)
@@ -662,46 +608,28 @@ export namespace Session {
})
export const remove = fn(SessionID.zod, async (sessionID) => {
const project = Instance.project
try {
const session = await get(sessionID)
for (const child of await children(sessionID)) {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
// CASCADE delete handles messages and parts automatically
Database.use((db) => {
db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
Database.effect(() =>
Bus.publish(Event.Deleted, {
info: session,
}),
)
})
SyncEvent.run(Event.Deleted, { sessionID, info: session })
// Eagerly remove event sourcing data to free up space
SyncEvent.remove(sessionID)
} catch (e) {
log.error(e)
}
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
const time_created = msg.time.created
const { id, sessionID, ...data } = msg
Database.use((db) => {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Updated, {
info: msg,
}),
)
SyncEvent.run(MessageV2.Event.Updated, {
sessionID: msg.sessionID,
info: msg,
})
return msg
})
@@ -711,17 +639,9 @@ export namespace Session {
messageID: MessageID.zod,
}),
async (input) => {
// CASCADE delete handles parts automatically
Database.use((db) => {
db.delete(MessageTable)
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
}),
)
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
})
return input.messageID
},
@@ -734,17 +654,10 @@ export namespace Session {
partID: PartID.zod,
}),
async (input) => {
Database.use((db) => {
db.delete(PartTable)
.where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID)))
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
}),
)
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
})
return input.partID
},
@@ -753,24 +666,10 @@ export namespace Session {
const UpdatePartInput = MessageV2.Part
export const updatePart = fn(UpdatePartInput, async (part) => {
const { id, messageID, sessionID, ...data } = part
const time = Date.now()
Database.use((db) => {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: time,
data,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, {
part: structuredClone(part),
}),
)
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
})
return part
})

View File

@@ -6,11 +6,9 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { SyncEvent } from "../sync"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import type { SystemError } from "bun"
@@ -449,25 +447,34 @@ export namespace MessageV2 {
export type Info = z.infer<typeof Info>
export const Event = {
Updated: BusEvent.define(
"message.updated",
z.object({
Updated: SyncEvent.define({
type: "message.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
),
Removed: BusEvent.define(
"message.removed",
z.object({
}),
Removed: SyncEvent.define({
type: "message.removed",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
PartUpdated: BusEvent.define(
"message.part.updated",
z.object({
}),
PartUpdated: SyncEvent.define({
type: "message.part.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
part: Part,
time: z.number(),
}),
),
}),
PartDelta: BusEvent.define(
"message.part.delta",
z.object({
@@ -478,14 +485,16 @@ export namespace MessageV2 {
delta: z.string(),
}),
),
PartRemoved: BusEvent.define(
"message.part.removed",
z.object({
PartRemoved: SyncEvent.define({
type: "message.part.removed",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
),
}),
}
export const WithParts = z.object({

View File

@@ -0,0 +1,116 @@
import { NotFoundError, eq, and } from "../storage/db"
import { SyncEvent } from "@/sync"
import { Session } from "./index"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
function grab<T extends object, K1 extends keyof T, X>(
obj: T,
field1: K1,
cb?: (val: NonNullable<T[K1]>) => X,
): X | undefined {
if (obj == undefined || !(field1 in obj)) return undefined
const val = obj[field1]
if (val && typeof val === "object" && cb) {
return cb(val)
}
if (val === undefined) {
throw new Error(
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
)
}
return val as X | undefined
}
export function toPartialRow(info: DeepPartial<Session.Info>) {
const obj = {
id: grab(info, "id"),
project_id: grab(info, "projectID"),
workspace_id: grab(info, "workspaceID"),
parent_id: grab(info, "parentID"),
slug: grab(info, "slug"),
directory: grab(info, "directory"),
title: grab(info, "title"),
version: grab(info, "version"),
share_url: grab(info, "share", (v) => grab(v, "url")),
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
revert: grab(info, "revert"),
permission: grab(info, "permission"),
time_created: grab(info, "time", (v) => grab(v, "created")),
time_updated: grab(info, "time", (v) => grab(v, "updated")),
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
time_archived: grab(info, "time", (v) => grab(v, "archived")),
}
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
}
export default [
SyncEvent.project(Session.Event.Created, (db, data) => {
db.insert(SessionTable).values(Session.toRow(data.info)).run()
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {
const info = data.info
const row = db
.update(SessionTable)
.set(toPartialRow(info))
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
}),
SyncEvent.project(Session.Event.Deleted, (db, data) => {
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
}),
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
const time_created = data.info.time.created
const { id, sessionID, ...rest } = data.info
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data: rest,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
.run()
}),
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
db.delete(MessageTable)
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
db.delete(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
const { id, messageID, sessionID, ...rest } = data.part
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: data.time,
data: rest,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
.run()
}),
]

View File

@@ -4,8 +4,7 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2"
import { Session } from "."
import { Log } from "../util/log"
import { Database, eq } from "../storage/db"
import { MessageTable, PartTable } from "./session.sql"
import { SyncEvent } from "../sync"
import { Storage } from "@/storage/storage"
import { Bus } from "../bus"
import { SessionPrompt } from "./prompt"
@@ -113,8 +112,10 @@ export namespace SessionRevert {
remove.push(msg)
}
for (const msg of remove) {
Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: sessionID,
messageID: msg.info.id,
})
}
if (session.revert.partID && target) {
const partID = session.revert.partID
@@ -124,8 +125,7 @@ export namespace SessionRevert {
const removeParts = target.parts.slice(removeStart)
target.parts = preserveParts
for (const part of removeParts) {
Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
await Bus.publish(MessageV2.Event.PartRemoved, {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: sessionID,
messageID: target.info.id,
partID: part.id,

View File

@@ -66,29 +66,28 @@ export namespace ShareNext {
export async function init() {
if (disabled) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync(evt.properties.info.id, [
const session = await Session.get(evt.properties.sessionID)
await sync(session.id, [
{
type: "session",
data: evt.properties.info,
data: session,
},
])
})
Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
await sync(evt.properties.info.sessionID, [
const info = evt.properties.info
await sync(info.sessionID, [
{
type: "message",
data: evt.properties.info,
},
])
if (evt.properties.info.role === "user") {
await sync(evt.properties.info.sessionID, [
if (info.role === "user") {
await sync(info.sessionID, [
{
type: "model",
data: [
await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
(m) => m,
),
],
data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)],
},
])
}

View File

@@ -27,16 +27,20 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export namespace Database {
export const Path = iife(() => {
if (Flag.OPENCODE_DB) {
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
export function getChannelPath() {
const channel = Installation.CHANNEL
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
}
export const Path = iife(() => {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
return getChannelPath()
})
export type Transaction = SQLiteTransaction<"sync", void>
@@ -145,17 +149,27 @@ export namespace Database {
}
}
export function transaction<T>(callback: (tx: TxOrDb) => T): T {
type NotPromise<T> = T extends Promise<any> ? never : T
export function transaction<T>(
callback: (tx: TxOrDb) => NotPromise<T>,
options?: {
behavior?: "deferred" | "immediate" | "exclusive"
},
): NotPromise<T> {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = (Client().transaction as any)((tx: TxOrDb) => {
return ctx.provide({ tx, effects }, () => callback(tx))
})
const result = Client().transaction(
(tx: TxOrDb) => {
return ctx.provide({ tx, effects }, () => callback(tx))
},
{ behavior: options?.behavior },
)
for (const effect of effects) effect()
return result
return result as NotPromise<T>
}
throw err
}

View File

@@ -7,8 +7,8 @@ import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Git } from "@/git"
import { Glob } from "../util/glob"
import { git } from "@/util/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -49,7 +49,7 @@ export namespace Storage {
}
if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue
const result = await git(["rev-list", "--max-parents=0", "--all"], {
const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const [id] = result

View File

@@ -0,0 +1,179 @@
tl;dr All of these APIs work, are properly type-checked, and are sync events are backwards compatible with `Bus`:
```ts
// The schema from `Updated` typechecks the object correctly
SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
// `subscribeAll` passes a generic sync event
SyncEvent.subscribeAll((event) => {
// These will be type-checked correctly
event.id
event.seq
// This will be unknown because we are listening for all events,
// and this API is only used to record them
event.data
})
// This works, but you shouldn't publish sync event like this (should fail in the future)
Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
// Update event is fully type-checked
Bus.subscribe(Updated, (event) => event.properties.info.title)
// Update event is fully type-checked
client.subscribe("session.updated", (evt) => evt.properties.info.title)
```
# Goal
## Syncing with only one writer
This system defines a basic event sourcing system for session replayability. The goal is to allow for one device to control and modify the session, and allow multiple other devices to "sync" session data. The sync works by getting a log of events to replay and replaying them locally.
Because only one device is allowed to write, we don't need any kind of sophisticated distributed system clocks or causal ordering. We implement total ordering with a simple sequence id (a number) and increment it by one every time we generate an event.
## Bus event integration and backwards compatibility
This initial implementation aims to be fully backwards compatible. We should be able to land this without any visible changes to the user.
An existing `Bus` abstraction to send events already exists. We already send events like `session.created` through the system. We should not duplicate this.
The difference in event sourcing is events are sent _before_ the mutation happens, and "projectors" handle the effects and perform the mutations. This difference is subtle, and a necessary change for syncing to work.
So the goal is:
- Introduce a new syncing abstraction to handle event sourcing and projectors
- Seamlessly integrate these new events into the same existing `Bus` abstraction
- Maintain full backwards compatibility to reduce risk
## My approach
This directory introduces a new abstraction: `SyncEvent`. This handles all of the event sourcing.
There are now "sync events" which are different than "bus events". Bus events are defined like this:
```ts
const Diff = BusEvent.define(
"session.diff",
z.object({
sessionID: SessionID.zod,
diff: Snapshot.FileDiff.array(),
}),
)
```
You can do `Bus.publish(Diff, { ... })` to push these events, and `Bus.subscribe(Diff, handler)` to listen to them.
Sync events are a lower-level abstraction which are similar, but also handle the requirements for recording and replaying. Defining them looks like this:
```ts
const Created = SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
})
```
Not too different, except they track a version and an "aggregate" field (will explain that later).
You do this to run an event, which is kind of like `Bus.publish` except that it runs through the event sourcing system:
```
SyncEvent.run(Created, { ... })
```
The data passed as the second argument is properly type-checked based on the schema defined in `Created`.
Importantly, **sync events automatically re-publish as bus events**. This makes them backwards compatible, and allows the `Bus` to still be the single abstraction that the system uses to listen for individual events.
**We have upgraded many of the session events to be sync events** (all of the ones that mutate the db). Sync and bus events are largely compatible. Here are the differences:
### Event shape
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types.
### Event flow
There is no way to subscribe to individual sync events in `SyncEvent`. You can use `subscribeAll` to receive _all_ of the events, which is needed for clients that want to record them.
To listen for individual events, use `Bus.subscribe`. You can pass in a sync event definition to it: `Bus.subscribe(Created, handler)`. This is fully supported.
You should never "publish" a sync event however: `Bus.publish(Created, ...)`. I would like to force this to be a type error in the future. You should never be touching the db directly, and should not be manually handling these events.
### Backwards compatibility
The system install projectors in `server/projectors.js`. It calls `SyncEvent.init` to do this. It also installs a hook for dynamically converting an event at runtime (`convertEvent`).
This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
```ts
// The schema from `Updated` typechecks the object correctly
SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
// `subscribeAll` passes a generic sync event
SyncEvent.subscribeAll((event) => {
// These will be type-checked correctly
event.id
event.seq
// This will be unknown because we are listening for all events,
// and this API is only used to record them
event.data
})
// This works, but you shouldn't publish sync event like this (should fail in the future)
Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
// Update event is fully type-checked
Bus.subscribe(Updated, (event) => event.properties.info.title)
// Update event is fully type-checked
client.subscribe("session.updated", (evt) => evt.properties.info.title)
```
The last two examples look similar to `SyncEvent.run`, but they were the cause of a lot of grief. Those are existing APIs that we can't break, but we are passing in the new sync event definitions to these APIs, which sometimes have a different event shape.
I previously mentioned the runtime conversion of events, but we still need to the types to work! To do that, the `define` API supports an optional `busSchema` prop to give it the schema for backwards compatibility. For example this is the full definition of `Session.Update`:
```ts
const Update = SyncEvent.define({
type: "session.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: partialSchema(Info),
}),
busSchema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
})
```
_Important_: the conversion done in `convertEvent` is not automatically type-checked with `busSchema`. It's very important they match, but because we need this at type-checking time this needs to live here.
Internally, the way this works is `busSchema` is stored on a `properties` field which is what the bus system expects. Doing this made everything with `Bus` "just work". This is why you can pass a sync event to the bus APIs.
_Alternatives_
These are some other paths I explored:
- Providing a way to subscribe to individual sync events, and change all the instances of `Bus.subscribe` in our code to it. Then you are directly only working with sync events always.
- Two big problems. First, `Bus` is instance-scoped, and we'd need to make the sync event system instance-scoped too for backwards compat. If we didn't, those listeners would get calls for events they weren't expecting.
- Second, we can't change consumers of our SDK. So they still have to use the old events, and we might as well stick with them for consistency
- Directly add sync event support to bus system
- I explored adding sync events to the bus, but due to backwards compat, it only made it more complicated (still need to support both shapes)
- I explored a `convertSchema` function to convert the event schema at runtime so we didn't need `busSchema`
- Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of `Bus.subscribe` calls
I explored many other permutations of the above solutions. What we have today I think is the best balance of backwards compatibility while opening a path forward for the new events.

View File

@@ -0,0 +1,16 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(),
seq: integer().notNull(),
})
export const EventTable = sqliteTable("event", {
id: text().primaryKey(),
aggregate_id: text()
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(),
type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
})

View File

@@ -0,0 +1,263 @@
import z from "zod"
import type { ZodObject } from "zod"
import { EventEmitter } from "events"
import { Database, eq } from "@/storage/db"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { EventSequenceTable, EventTable } from "./event.sql"
import { EventID } from "./schema"
import { Flag } from "@/flag/flag"
export namespace SyncEvent {
export type Definition = {
type: string
version: number
aggregate: string
schema: z.ZodObject
// This is temporary and only exists for compatibility with bus
// event definitions
properties: z.ZodObject
}
export type Event<Def extends Definition = Definition> = {
id: string
seq: number
aggregateID: string
data: z.infer<Def["schema"]>
}
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
export const registry = new Map<string, Definition>()
let projectors: Map<Definition, ProjectorFunc> | undefined
const versions = new Map<string, number>()
let frozen = false
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
export function reset() {
frozen = false
projectors = undefined
convertEvent = (_, data) => data
}
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
projectors = new Map(input.projectors)
// Install all the latest event defs to the bus. We only ever emit
// latest versions from code, and keep around old versions for
// replaying. Replaying does not go through the bus, and it
// simplifies the bus to only use unversioned latest events
for (let [type, version] of versions.entries()) {
let def = registry.get(versionedType(type, version))!
BusEvent.define(def.type, def.properties || def.schema)
}
// Freeze the system so it clearly errors if events are defined
// after `init` which would cause bugs
frozen = true
convertEvent = input.convertEvent || ((_, data) => data)
}
export function versionedType<A extends string>(type: A): A
export function versionedType<A extends string, B extends number>(type: A, version: B): `${A}/${B}`
export function versionedType(type: string, version?: number) {
return version ? `${type}.${version}` : type
}
export function define<
Type extends string,
Agg extends string,
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
BusSchema extends ZodObject = Schema,
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
if (frozen) {
throw new Error("Error defining sync event: sync system has been frozen")
}
const def = {
type: input.type,
version: input.version,
aggregate: input.aggregate,
schema: input.schema,
properties: input.busSchema ? input.busSchema : input.schema,
}
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
registry.set(versionedType(def.type, def.version), def)
return def
}
export function project<Def extends Definition>(
def: Def,
func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
): [Definition, ProjectorFunc] {
return [def, func as ProjectorFunc]
}
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
}
const projector = projectors.get(def)
if (!projector) {
throw new Error(`Projector not found for event: ${def.type}`)
}
// idempotent: need to ignore any events already logged
Database.transaction((tx) => {
projector(tx, event.data)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
tx.insert(EventSequenceTable)
.values({
aggregate_id: event.aggregateID,
seq: event.seq,
})
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: event.seq },
})
.run()
tx.insert(EventTable)
.values({
id: event.id,
seq: event.seq,
aggregate_id: event.aggregateID,
type: versionedType(def.type, def.version),
data: event.data as Record<string, unknown>,
})
.run()
}
Database.effect(() => {
Bus.emit("event", {
def,
event,
})
if (options?.publish) {
const result = convertEvent(def.type, event.data)
if (result instanceof Promise) {
result.then((data) => {
ProjectBus.publish({ type: def.type, properties: def.schema }, data)
})
} else {
ProjectBus.publish({ type: def.type, properties: def.schema }, result)
}
}
})
})
}
// TODO:
//
// * Support applying multiple events at one time. One transaction,
// and it validets all the sequence ids
// * when loading events from db, apply zod validation to ensure shape
export function replay(event: SerializedEvent, options?: { republish: boolean }) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
}
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
)
const latest = row?.seq ?? -1
if (event.seq <= latest) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
}
process(def, event, { publish: !!options?.republish })
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in
// the definition
if (agg == null) {
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
}
if (def.version !== versions.get(def.type)) {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
Database.transaction(
(tx) => {
const id = EventID.ascending()
const row = tx
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, agg))
.get()
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish: true })
},
{
behavior: "immediate",
},
)
}
export function remove(aggregateID: string) {
Database.transaction((tx) => {
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
}
export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
Bus.on("event", handler)
return () => Bus.off("event", handler)
}
export function payloads() {
return z
.union(
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
aggregate: z.literal(def.aggregate),
data: def.schema,
})
.meta({
ref: "SyncEvent" + "." + def.type,
})
})
.toArray() as any,
)
.meta({
ref: "SyncEvent",
})
}
}

View File

@@ -0,0 +1,14 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
export const EventID = Schema.String.pipe(
Schema.brand("EventID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("event", id)),
zod: Identifier.schema("event").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
})),
)

View File

@@ -1,35 +0,0 @@
import { Process } from "./process"
export interface GitResult {
exitCode: number
text(): string
stdout: Buffer
stderr: Buffer
}
/**
* Run a git command.
*
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
* issues in embedded/client environments.
*/
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
return Process.run(["git", ...args], {
cwd: opts.cwd,
env: opts.env,
stdin: "ignore",
nothrow: true,
})
.then((result) => ({
exitCode: result.code,
text: () => result.stdout.toString(),
stdout: result.stdout,
stderr: result.stderr,
}))
.catch((error) => ({
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
}))
}

View File

@@ -0,0 +1,13 @@
import z from "zod"
export function updateSchema<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
const next = {} as {
[K in keyof T]: z.ZodOptional<z.ZodNullable<T[K]>>
}
for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) {
next[k] = v.nullable() as unknown as (typeof next)[typeof k]
}
return z.object(next)
}

View File

@@ -11,6 +11,7 @@ import { Log } from "../util/log"
import { Slug } from "@opencode-ai/util/slug"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Git } from "@/git"
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
@@ -504,56 +505,24 @@ export namespace Worktree {
const worktreePath = entry.path
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
if (remoteList.code !== 0) {
throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
}
const remotes = remoteList.text
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const remoteHead = remote
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { code: 1, text: "", stderr: "" }
const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch =
remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const [mainCheck, masterCheck] = yield* Effect.all(
[
git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
],
{ concurrency: 2 },
)
const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
if (!base) {
throw new ResetFailedError({ message: "Default branch not found" })
}
if (remoteBranch) {
const sep = base.ref.indexOf("/")
if (base.ref !== base.name && sep > 0) {
const remote = base.ref.slice(0, sep)
const branch = base.ref.slice(sep + 1)
yield* gitExpect(
["fetch", remote, remoteBranch],
["fetch", remote, branch],
{ cwd: Instance.worktree },
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
)
}
yield* gitExpect(
["reset", "--hard", target],
["reset", "--hard", base.ref],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
)

View File

@@ -60,6 +60,8 @@ function toolEvent(
const payload: EventMessagePartUpdated = {
type: "message.part.updated",
properties: {
sessionID: sessionId,
time: Date.now(),
part: {
id: `part_${opts.callID}`,
sessionID: sessionId,

View File

@@ -0,0 +1,128 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { ManagedRuntime } from "effect"
import { Git } from "../../src/git"
import { tmpdir } from "../fixture/fixture"
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
const rt = ManagedRuntime.make(Git.defaultLayer)
try {
return await body(rt)
} finally {
await rt.dispose()
}
}
describe("Git", () => {
test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true })
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
})
test("branch() returns undefined for non-git directories", async () => {
await using tmp = await tmpdir()
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
expect(branch).toBeUndefined()
})
})
test("branch() returns undefined for detached HEAD", async () => {
await using tmp = await tmpdir({ git: true })
const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
expect(branch).toBeUndefined()
})
})
test("defaultBranch() uses init.defaultBranch when available", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M trunk`.cwd(tmp.path).quiet()
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
expect(branch?.name).toBe("trunk")
expect(branch?.ref).toBe("trunk")
})
})
test("status() handles special filenames", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
await withGit(async (rt) => {
const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
expect(status).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: weird,
status: "added",
}),
]),
)
})
})
test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
await withGit(async (rt) => {
const [base, diff, stats] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
])
expect(base).toBeTruthy()
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: weird,
status: "modified",
}),
]),
)
expect(stats).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: weird,
additions: 1,
deletions: 1,
}),
]),
)
})
})
test("show() returns empty text for binary blobs", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
await withGit(async (rt) => {
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
expect(text).toBe("")
})
})
})

View File

@@ -0,0 +1,55 @@
import { describe, expect, spyOn, test } from "bun:test"
import path from "path"
import * as Lsp from "../../src/lsp/index"
import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("lsp.spawn", () => {
test("does not spawn builtin LSP for files outside instance", async () => {
await using tmp = await tmpdir()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
await Lsp.LSP.hover({
file: path.join(tmp.path, "..", "hover.ts"),
line: 0,
character: 0,
})
},
})
expect(spy).toHaveBeenCalledTimes(0)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
test("would spawn builtin LSP for files inside instance", async () => {
await using tmp = await tmpdir()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Lsp.LSP.hover({
file: path.join(tmp.path, "src", "inside.ts"),
line: 0,
character: 0,
})
},
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
})

View File

@@ -74,11 +74,17 @@ delete process.env["SAMBANOVA_API_KEY"]
delete process.env["OPENCODE_SERVER_PASSWORD"]
delete process.env["OPENCODE_SERVER_USERNAME"]
// Use in-memory sqlite
process.env["OPENCODE_DB"] = ":memory:"
// Now safe to import from src/
const { Log } = await import("../src/util/log")
const { initProjectors } = await import("../src/server/projectors")
Log.init({
print: false,
dev: true,
level: "DEBUG",
})
initProjectors()

View File

@@ -23,7 +23,7 @@ function withVcs(
) {
return withServices(
directory,
Layer.merge(FileWatcher.layer, Vcs.layer),
Layer.merge(FileWatcher.layer, Vcs.defaultLayer),
async (rt) => {
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await rt.runPromise(Vcs.Service.use((s) => s.init()))
@@ -34,7 +34,15 @@ function withVcs(
)
}
function withVcsOnly(
directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<Vcs.Service, never>) => Promise<void>,
) {
return withServices(directory, Vcs.defaultLayer, body)
}
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
function nextBranchUpdate(directory: string, timeout = 10_000) {
@@ -123,3 +131,105 @@ describeVcs("Vcs", () => {
})
})
})
describe("Vcs diff", () => {
afterEach(async () => {
await Instance.disposeAll()
})
test("defaultBranch() falls back to main", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch()))
expect(branch).toBe("main")
})
})
test("defaultBranch() uses init.defaultBranch when available", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M trunk`.cwd(tmp.path).quiet()
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch()))
expect(branch).toBe("trunk")
})
})
test("detects current branch from the active worktree", async () => {
await using tmp = await tmpdir({ git: true })
await using wt = await tmpdir()
await $`git branch -M main`.cwd(tmp.path).quiet()
const dir = path.join(wt.path, "feature")
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
await withVcsOnly(dir, async (rt) => {
const [branch, base] = await Promise.all([
rt.runPromise(Vcs.Service.use((s) => s.branch())),
rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())),
])
expect(branch).toBe("feature/test")
expect(base).toBe("main")
})
})
test("diff('git') returns uncommitted changes", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "file.txt",
status: "modified",
}),
]),
)
})
})
test("diff('git') handles special filenames", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: weird,
status: "added",
}),
]),
)
})
})
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("branch")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "branch.txt",
status: "added",
}),
]),
)
})
})
})

View File

@@ -10,8 +10,8 @@ import { MessageID, PartID } from "../../src/session/schema"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
describe("session.started event", () => {
test("should emit session.started event when session is created", async () => {
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
@@ -41,14 +41,14 @@ describe("session.started event", () => {
})
})
test("session.started event should be emitted before session.updated", async () => {
test("session.created event should be emitted before session.updated", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const events: string[] = []
const unsubStarted = Bus.subscribe(Session.Event.Created, () => {
events.push("started")
const unsubCreated = Bus.subscribe(Session.Event.Created, () => {
events.push("created")
})
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
@@ -59,12 +59,12 @@ describe("session.started event", () => {
await new Promise((resolve) => setTimeout(resolve, 100))
unsubStarted()
unsubCreated()
unsubUpdated()
expect(events).toContain("started")
expect(events).toContain("created")
expect(events).toContain("updated")
expect(events.indexOf("started")).toBeLessThan(events.indexOf("updated"))
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
await Session.remove(session.id)
},

View File

@@ -6,14 +6,9 @@ import { Database } from "../../src/storage/db"
describe("Database.Path", () => {
test("returns database path for the current channel", () => {
const db = process.env["OPENCODE_DB"]
const expected = db
? path.isAbsolute(db)
? db
: path.join(Global.Path.data, db)
: ["latest", "beta"].includes(Installation.CHANNEL)
? path.join(Global.Path.data, "opencode.db")
: path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
expect(Database.Path).toBe(expected)
const expected = ["latest", "beta"].includes(Installation.CHANNEL)
? path.join(Global.Path.data, "opencode.db")
: path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
expect(Database.getChannelPath()).toBe(expected)
})
})

View File

@@ -0,0 +1,187 @@
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import z from "zod"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync"
import { Database } from "../../src/storage/db"
import { EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id"
import { Flag } from "../../src/flag/flag"
import { initProjectors } from "../../src/server/projectors"
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
beforeEach(() => {
Database.close()
// @ts-expect-error don't do this normally, but it works
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(() => {
// @ts-expect-error don't do this normally, but it works
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
})
function withInstance(fn: () => void | Promise<void>) {
return async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
},
})
}
}
describe("SyncEvent", () => {
function setup() {
SyncEvent.reset()
const Created = SyncEvent.define({
type: "item.created",
version: 1,
aggregate: "id",
schema: z.object({ id: z.string(), name: z.string() }),
})
const Sent = SyncEvent.define({
type: "item.sent",
version: 1,
aggregate: "item_id",
schema: z.object({ item_id: z.string(), to: z.string() }),
})
SyncEvent.init({
projectors: [SyncEvent.project(Created, () => {}), SyncEvent.project(Sent, () => {})],
})
return { Created, Sent }
}
afterAll(() => {
SyncEvent.reset()
initProjectors()
})
describe("run", () => {
test(
"inserts event row",
withInstance(() => {
const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1")
}),
)
test(
"increments seq per aggregate",
withInstance(() => {
const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" })
SyncEvent.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1)
}),
)
test(
"uses custom aggregate field from agg()",
withInstance(() => {
const { Sent } = setup()
SyncEvent.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1")
}),
)
test(
"emits events",
withInstance(async () => {
const { Created } = setup()
const events: Array<{
type: string
properties: { id: string; name: string }
}> = []
const unsub = Bus.subscribeAll((event) => events.push(event))
SyncEvent.run(Created, { id: "evt_1", name: "test" })
expect(events).toHaveLength(1)
expect(events[0]).toEqual({
type: "item.created",
properties: {
id: "evt_1",
name: "test",
},
})
unsub()
}),
)
})
describe("replay", () => {
test(
"inserts event from external payload",
withInstance(() => {
const id = Identifier.descending("message")
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "replayed" },
})
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id)
}),
)
test(
"throws on sequence mismatch",
withInstance(() => {
const id = Identifier.descending("message")
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "first" },
})
expect(() =>
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 5,
aggregateID: id,
data: { id, name: "bad" },
}),
).toThrow(/Sequence mismatch/)
}),
)
test(
"throws on unknown event type",
withInstance(() => {
expect(() =>
SyncEvent.replay({
id: "evt_1",
type: "unknown.event.1",
seq: 0,
aggregateID: "x",
data: {},
}),
).toThrow(/Unknown event type/)
}),
)
})
})

View File

@@ -46,6 +46,7 @@ import type {
GlobalDisposeResponses,
GlobalEventResponses,
GlobalHealthResponses,
GlobalSyncEventSubscribeResponses,
GlobalUpgradeErrors,
GlobalUpgradeResponses,
InstanceDisposeResponses,
@@ -174,6 +175,7 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
VcsDiffResponses,
VcsGetResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
@@ -230,6 +232,20 @@ class HeyApiRegistry<T> {
}
}
export class SyncEvent extends HeyApiClient {
/**
* Subscribe to global sync events
*
* Get global sync events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<GlobalSyncEventSubscribeResponses, unknown, ThrowOnError>({
url: "/global/sync-event",
...options,
})
}
}
export class Config extends HeyApiClient {
/**
* Get global configuration
@@ -329,6 +345,11 @@ export class Global extends HeyApiClient {
})
}
private _syncEvent?: SyncEvent
get syncEvent(): SyncEvent {
return (this._syncEvent ??= new SyncEvent({ client: this.client }))
}
private _config?: Config
get config(): Config {
return (this._config ??= new Config({ client: this.client }))
@@ -3719,6 +3740,38 @@ export class Vcs extends HeyApiClient {
...params,
})
}
/**
* Get VCS diff
*
* Retrieve the current git diff for the working tree or against the default branch.
*/
public diff<ThrowOnError extends boolean = false>(
parameters: {
directory?: string
workspace?: string
mode: "git" | "branch"
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "mode" },
],
},
],
)
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
url: "/vcs/diff",
...options,
...params,
})
}
}
export class Command extends HeyApiClient {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { defineMain } from "storybook-solidjs-vite"
import path from "node:path"
import { fileURLToPath } from "node:url"
import tailwindcss from "@tailwindcss/vite"
import { playgroundCss } from "./playground-css-plugin"
const here = path.dirname(fileURLToPath(import.meta.url))
const ui = path.resolve(here, "../../ui")
@@ -24,7 +25,7 @@ export default defineMain({
async viteFinal(config) {
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
return mergeConfig(config, {
plugins: [tailwindcss()],
plugins: [tailwindcss(), playgroundCss()],
resolve: {
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
alias: [

View File

@@ -0,0 +1,136 @@
/**
* Vite plugin that exposes a POST endpoint for the timeline playground
* to write CSS changes back to source files on disk.
*
* POST /__playground/apply-css
* Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> }
*
* For each edit the plugin finds `anchor` in the file, then locates the
* next `prop: <anything>;` after it and replaces the value portion.
* `file` is a basename resolved relative to packages/ui/src/components/.
*/
import type { Plugin } from "vite"
import type { IncomingMessage, ServerResponse } from "node:http"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(here, "../../ui/src/components")
const ENDPOINT = "/__playground/apply-css"
type Edit = { file: string; anchor: string; prop: string; value: string }
type Result = { file: string; prop: string; ok: boolean; error?: string }
function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } {
const results: Result[] = []
let out = content
for (const edit of edits) {
const name = edit.file
const idx = out.indexOf(edit.anchor)
if (idx === -1) {
results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` })
continue
}
// From the anchor position, find the next occurrence of `prop: <value>`
// We match `prop:` followed by any value up to `;`
const after = out.slice(idx)
const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`)
const match = re.exec(after)
if (!match) {
results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` })
continue
}
const start = idx + match.index + match[1].length
const end = start + match[2].length
out = out.slice(0, start) + edit.value + out.slice(end)
results.push({ file: name, prop: edit.prop, ok: true })
}
return { content: out, results }
}
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
export function playgroundCss(): Plugin {
return {
name: "playground-css",
configureServer(server) {
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
if (req.url !== ENDPOINT) return next()
if (req.method !== "POST") {
res.statusCode = 405
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ error: "Method not allowed" }))
return
}
let data = ""
req.on("data", (chunk: Buffer) => {
data += chunk.toString()
})
req.on("end", () => {
let payload: { edits: Edit[] }
try {
payload = JSON.parse(data)
} catch {
res.statusCode = 400
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ error: "Invalid JSON" }))
return
}
if (!Array.isArray(payload.edits)) {
res.statusCode = 400
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ error: "Missing edits array" }))
return
}
// Group by file
const grouped = new Map<string, Edit[]>()
for (const edit of payload.edits) {
if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue
const abs = path.resolve(root, edit.file)
if (!abs.startsWith(root)) continue
const key = abs
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key)!.push(edit)
}
const results: Result[] = []
for (const [abs, edits] of grouped) {
const name = path.basename(abs)
if (!fs.existsSync(abs)) {
for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" })
continue
}
try {
const content = fs.readFileSync(abs, "utf-8")
const applied = applyEdits(content, edits)
results.push(...applied.results)
if (applied.results.some((r) => r.ok)) {
fs.writeFileSync(abs, applied.content, "utf-8")
}
} catch (err) {
for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) })
}
}
res.statusCode = 200
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify({ results }))
})
})
},
}
}

View File

@@ -12,6 +12,7 @@
"./hooks": "./src/hooks/index.ts",
"./context": "./src/context/index.ts",
"./context/*": "./src/context/*.tsx",
"./font-loader": "./src/font-loader.ts",
"./styles": "./src/styles/index.css",
"./styles/tailwind": "./src/styles/tailwind/index.css",
"./theme": "./src/theme/index.ts",

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,24 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
shape-rendering="geometricPrecision"
d="M9.8132 15.9038L9 18.75L8.1868 15.9038C7.75968 14.4089 6.59112 13.2403 5.09619 12.8132L2.25 12L5.09619 11.1868C6.59113 10.7597 7.75968 9.59112 8.1868 8.09619L9 5.25L9.8132 8.09619C10.2403 9.59113 11.4089 10.7597 12.9038 11.1868L15.75 12L12.9038 12.8132C11.4089 13.2403 10.2403 14.4089 9.8132 15.9038Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.2589 8.71454L18 9.75L17.7411 8.71454C17.4388 7.50533 16.4947 6.56117 15.2855 6.25887L14.25 6L15.2855 5.74113C16.4947 5.43883 17.4388 4.49467 17.7411 3.28546L18 2.25L18.2589 3.28546C18.5612 4.49467 19.5053 5.43883 20.7145 5.74113L21.75 6L20.7145 6.25887C19.5053 6.56117 18.5612 7.50533 18.2589 8.71454Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M16.8942 20.5673L16.5 21.75L16.1058 20.5673C15.8818 19.8954 15.3546 19.3682 14.6827 19.1442L13.5 18.75L14.6827 18.3558C15.3546 18.1318 15.8818 17.6046 16.1058 16.9327L16.5 15.75L16.8942 16.9327C17.1182 17.6046 17.6454 18.1318 18.3173 18.3558L19.5 18.75L18.3173 19.1442C17.6454 19.3682 17.1182 19.8954 16.8942 20.5673Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-terminal h-5 w-5 text-primary"><path d="m4 17 6-6-6-6M12 19h8"/></svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 400">
<path fill="currentColor" d="M394.3,154.15v19.4c6.2-3.8,12.7-6.8,19.7-9.1,8.8-2.7,17.8-4.1,27.1-4.1h3.3v42.4h-3.3c-12.9,0-24,4.6-33.1,13.7-9.1,9.1-13.7,20.2-13.7,33.1v80.4h-42.4V154.15h42.4Z"/>
<path fill="currentColor" d="M464.5,264.65c-.3-2.9-.5-5.7-.5-8.4v-101.7h41.3v102.2c0,1.1,.1,2.3,.3,3.4,.1,1,.3,2.2,.5,3.4,1,4.5,2.9,8.5,5.7,12,2.6,3.5,5.9,6.5,9.9,8.7,3.5,2.1,7.4,3.4,11.5,3.9,4,.6,8,.4,12-.5,2.7-.7,5.3-1.7,7.7-2.9,2.4-1.3,4.6-2.8,6.5-4.6,2.4-2.1,4.4-4.5,6-7.2,1.6-2.6,2.8-5.4,3.6-8.2v-110.2h42.8v175.1h-42.9v-6.7c-1.5,.8-3,1.5-4.6,2.1-3.2,1.3-6.5,2.2-9.8,2.9-7.8,1.8-15.6,2.4-23.5,1.7-7.9-.7-15.5-2.6-22.8-5.8-10.7-4.7-19.8-11.5-27.3-20.4-7.4-8.9-12.5-19-15.1-30.4-.5-2.8-1-5.6-1.3-8.4Z"/>
<path fill="currentColor" d="M228,149.85v33.9c-5.5-3.3-11.3-6-17.5-8.1-7.8-2.4-15.8-3.6-24.2-3.6-22,0-40.6,7.7-56.1,23.2-15.5,15.5-23.3,34.3-23.3,56.2s7.8,40.6,23.3,56.1c15.4,15.5,34.1,23.3,56.1,23.3,8.3,0,16.4-1.3,24.2-3.8,6.2-1.9,12-4.6,17.5-8.1v10.6h37.7V119.95l-37.7,29.9Zm-.5,91.2l-3.7,30.2-17.9,13.5-19.6,14.7-19.8-14.8-18-13.4-3.6-30.2-1.8-16.5,21.9-9.5,21.2-9.2,25.7,11.2,17.6,7.5-2,16.5Z"/>
<path fill="currentColor" d="M792,329.95h-45v-107.2c0-11.9-4.3-31.9-33.4-31.9-12.3,0-32.8,4.1-32.8,31.3v107.8h-45v-107.8c0-23.3,8.2-42.9,23.6-56.9,13.9-12.5,33.1-19.4,54.2-19.4,46.1,0,78.4,31.6,78.4,76.9v107.2Z"/>
<rect fill="currentColor" x="293.59" y="293.67" width="29.3" height="29.3" transform="translate(-127.73 308.26) rotate(-45)"/>
<polygon fill="currentColor" points="229.7 89.95 226.4 137.75 264.7 108.85 270.4 62.55 229.7 89.95"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.2642 2.8689L12.042 8.0961M17.2642 2.8689V8.0961H12.042M17.2642 2.8689V4.30027M12.042 8.0961L6.81809 2.8689V8.0961H12.042ZM12.042 8.0961L17.2642 13.3225V20.8159L12.042 15.5887M12.042 8.0961V15.5887M12.042 8.0961L6.81892 13.3225M12.0296 2.1V21.9M12.042 15.5887L6.81892 20.8159V13.3225M6.81892 13.3225L6.81809 15.559H4.57739V8.09527H12.0412L6.81892 13.3225ZM11.9859 8.09527L17.2081 13.3225V15.559H19.4497V8.09527H11.9859Z" stroke="currentColor" stroke-width="0.825" stroke-miterlimit="10"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.0483 17.1416C19.6945 17.4914 18.987 18.0161 17.7488 18.0161C17.2182 18.0161 16.5991 18.0161 16.3338 18.0161C15.98 18.0161 13.3268 18.0161 10.143 18.0161C12.4424 15.8298 14.3881 13.9932 14.565 13.8183C14.7419 13.6434 15.1841 13.2061 15.6263 12.8563C16.5107 12.0692 17.2182 11.9817 17.8373 11.9817C18.7217 11.9817 19.4292 12.3316 20.0483 12.8563C21.2864 13.9932 21.2864 16.0047 20.0483 17.1416ZM21.5518 11.457C20.6674 10.495 19.3408 9.88281 17.9257 9.88281C16.6875 9.88281 15.6263 10.3201 14.6534 11.0197C14.2997 11.3695 13.769 11.7194 13.3268 12.2441C12.9731 12.5939 5.36719 19.9401 5.36719 19.9401C5.80939 20.0276 6.34003 20.0276 6.78223 20.0276C7.22443 20.0276 16.0685 20.0276 16.4222 20.0276C17.1298 20.0276 17.6604 20.0276 18.191 19.9401C19.3408 19.8527 20.4905 19.4154 21.4633 18.5409C23.4975 16.6168 23.4975 13.381 21.5518 11.457Z" fill="currentColor"/>
<path d="M9.1701 10.9323C8.19726 10.2326 7.22442 9.88281 6.07469 9.88281C4.65965 9.88281 3.33304 10.495 2.44864 11.457C0.502952 13.4685 0.502952 16.6168 2.53708 18.6283C3.42148 19.4154 4.30589 19.8527 5.36717 19.9401L7.4013 18.0161C7.04754 18.0161 6.60533 18.0161 6.25157 18.0161C5.10185 17.9287 4.39433 17.5789 3.95212 17.1416C2.71396 15.9172 2.71396 13.9932 3.86368 12.7688C4.48277 12.1566 5.19029 11.8943 6.07469 11.8943C6.60533 11.8943 7.4013 11.9817 8.19726 12.7688C8.55102 13.1186 9.52386 13.8183 9.87763 14.1681H9.96607L11.2927 12.8563V12.7688C10.6736 12.1566 9.70075 11.3695 9.1701 10.9323Z" fill="currentColor"/>
<path d="M18.4564 8.74536C17.4836 6.12171 14.9188 4.28516 12.0003 4.28516C8.5511 4.28516 5.80945 6.82135 5.27881 9.96973C5.54413 9.96973 5.80945 9.88228 6.16321 9.88228C6.51697 9.88228 6.95917 9.96973 7.31294 9.96973C7.75514 7.78336 9.70082 6.20917 12.0003 6.20917C13.946 6.20917 15.6263 7.34608 16.4223 9.00773C16.4223 9.00773 16.5107 9.09518 16.5107 9.00773C17.1298 8.92027 17.8373 8.74536 18.4564 8.74536C18.4564 8.83282 18.4564 8.83282 18.4564 8.74536Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,3 +1,4 @@
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.2587 5.29983C20.814 5.24008 23.9615 5.44575 26.4795 7.70668C28.5451 9.56134 29.2567 12.9997 28.7754 15.6659C28.3341 18.1098 27.2944 20.1094 27.7016 21.9609C28.1088 23.8124 29.1453 24.7252 30.027 25.1086C30.8787 25.4787 31.7019 25.3677 31.5452 26.0712C31.3884 26.7747 30.3233 27.0341 29.5455 27.0341C29.1191 27.0341 27.8294 26.9228 27.2572 26.5525C26.581 26.1149 26.4303 25.7228 26.1729 25.4075C26.1292 25.354 26.0521 25.3913 26.0624 25.4595C26.1797 26.2351 26.503 27.551 27.0276 28.3672C27.4521 29.0274 27.6275 29.5089 28.6 30.3236C29.5726 31.1381 30.0268 31.8787 29.0813 32.4033C28.2412 32.8691 26.4152 31.9159 25.3414 30.737C24.3749 29.6759 23.7019 28.367 23.4358 27.5154C23.2935 27.0601 23.151 26.2768 23.0087 25.6362C22.99 25.5519 22.8746 25.5655 22.8721 25.6518C22.8249 27.2822 22.5686 28.8393 22.1939 30.5087C21.9241 31.7105 21.2681 33.1747 20.6756 34.0635C19.9532 35.1471 18.8808 35.4853 18.0836 35.1891C17.446 34.952 17.8985 34.1463 18.0836 33.4487C18.2687 32.7513 18.6391 31.2923 18.8886 29.694C19.1144 28.2472 19.1741 26.8151 19.0735 25.4582C19.0673 25.3754 18.9402 25.3627 18.9144 25.4416C18.7066 26.078 18.3149 27.133 18.0292 27.7747C17.4332 29.1128 16.9922 29.7001 15.7702 31.1073C14.6712 32.3727 11.5105 34.1622 11.1045 32.4033C10.9935 31.922 11.5755 31.7112 12.1122 31.2923C12.9191 30.6629 13.5933 29.5459 14.2325 28.1819C14.8065 26.9571 15.0983 25.7724 15.1429 25.016C15.1472 24.943 15.0556 24.9155 15.0145 24.9758C14.7488 25.3656 14.3575 25.893 13.9266 26.3303C13.2067 27.0611 12.4083 27.8134 11.4824 28.1819C9.99368 28.7744 8.47556 28.367 8.58655 27.2934C8.67931 26.3963 9.68069 26.582 10.9272 25.222C12.1492 23.889 12.5564 22.8519 12.6305 21.704C12.6896 20.7857 11.7912 18.606 11.3345 17.6309C10.6063 16.0756 10.0681 11.6617 12.2603 8.81785C15.0004 5.26311 18.2959 5.33684 19.2587 5.29983ZM14.9842 11.5161C13.9647 11.5161 13.4549 12.2987 13.4549 13.2639C13.4549 14.2292 14.0213 15.0117 14.9842 15.0117C15.9471 15.0117 16.5135 14.2292 16.5135 13.2639C16.5135 12.2987 16.0038 11.5161 14.9842 11.5161Z" fill="currentColor"/>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="10" fill="#F5F5F5" style="fill:#F5F5F5;fill:color(display-p3 0.9608 0.9608 0.9608);fill-opacity:1;"/>
<path d="M9.64258 3.10608C10.3684 3.07819 11.8376 3.17404 13.0127 4.22913C13.9766 5.09463 14.3086 6.69977 14.084 7.94397C13.878 9.08427 13.3929 10.0176 13.583 10.8815C13.7731 11.7453 14.2566 12.1713 14.668 12.3502C15.0654 12.523 15.4501 12.4711 15.377 12.7994C15.3038 13.1277 14.8062 13.2487 14.4434 13.2487C14.2442 13.2486 13.6431 13.1967 13.376 13.024C13.0604 12.8199 12.9892 12.637 12.8691 12.4899C12.8488 12.465 12.8135 12.4824 12.8184 12.5143C12.8731 12.8762 13.0238 13.4901 13.2686 13.8707C13.4666 14.1789 13.5483 14.4037 14.002 14.7838C14.4558 15.164 14.6678 15.5098 14.2266 15.7545C13.8344 15.9715 12.9824 15.5262 12.4814 14.9762C12.0305 14.481 11.716 13.8706 11.5918 13.4733C11.5254 13.2608 11.459 12.8953 11.3926 12.5963C11.3839 12.5572 11.3306 12.5633 11.3291 12.6031C11.3071 13.364 11.1875 14.0908 11.0127 14.8698C10.8868 15.4305 10.5811 16.1142 10.3047 16.5289C9.96759 17.0346 9.46673 17.1925 9.09473 17.0543C8.79714 16.9437 9.00832 16.5673 9.09473 16.2418C9.18116 15.9162 9.35337 15.2355 9.46973 14.4899C9.57509 13.8147 9.60356 13.1466 9.55664 12.5133C9.55378 12.4747 9.49448 12.4687 9.48242 12.5055C9.38545 12.8025 9.20269 13.2949 9.06934 13.5944C8.79123 14.2188 8.58485 14.4924 8.01465 15.149C7.50186 15.7395 6.02756 16.5749 5.83789 15.7545C5.78606 15.5299 6.05719 15.4313 6.30762 15.236C6.68411 14.9422 6.99955 14.4212 7.29785 13.7848C7.56564 13.2134 7.70176 12.6602 7.72266 12.3073C7.72466 12.2733 7.68135 12.2598 7.66211 12.2877C7.53811 12.4696 7.35541 12.7164 7.1543 12.9205C6.81843 13.2615 6.44565 13.6129 6.01367 13.7848C5.31916 14.061 4.6105 13.8706 4.66211 13.3698C4.7054 12.9512 5.17315 13.0376 5.75488 12.403C6.32511 11.7809 6.51524 11.297 6.5498 10.7614C6.57745 10.3328 6.15844 9.31505 5.94531 8.85999C5.60552 8.13397 5.35407 6.07467 6.37695 4.74768C7.65531 3.08929 9.19275 3.12331 9.64258 3.10608ZM8.37988 6.00745C7.90409 6.00745 7.66602 6.37241 7.66602 6.82288C7.6661 7.27324 7.9306 7.63831 8.37988 7.63831C8.8289 7.63807 9.09269 7.2731 9.09277 6.82288C9.09277 6.37255 8.85538 6.00767 8.37988 6.00745ZM11.5244 6.00745C11.0486 6.00745 10.8105 6.37241 10.8105 6.82288C10.8106 7.27324 11.0751 7.63831 11.5244 7.63831C11.9734 7.63807 12.2372 7.2731 12.2373 6.82288C12.2373 6.37255 11.9999 6.00767 11.5244 6.00745Z" fill="black" style="fill:black;fill-opacity:1;"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,121 +1,9 @@
import { Link, Style } from "@solidjs/meta"
import { Show } from "solid-js"
import { Style, Link } from "@solidjs/meta"
import inter from "../assets/fonts/inter.woff2"
import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2"
import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2"
import firaCode from "../assets/fonts/fira-code-nerd-font.woff2"
import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2"
import hack from "../assets/fonts/hack-nerd-font.woff2"
import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2"
import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2"
import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2"
import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2"
import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2"
import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2"
import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2"
import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2"
import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2"
import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2"
import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2"
import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2"
import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2"
import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2"
import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2"
import iosevka from "../assets/fonts/iosevka-nerd-font.woff2"
import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2"
import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2"
import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2"
type MonoFont = {
family: string
regular: string
bold: string
}
export const MONO_NERD_FONTS = [
{
family: "JetBrains Mono Nerd Font",
regular: jetbrainsMono,
bold: jetbrainsMonoBold,
},
{
family: "Fira Code Nerd Font",
regular: firaCode,
bold: firaCodeBold,
},
{
family: "Cascadia Code Nerd Font",
regular: cascadiaCode,
bold: cascadiaCodeBold,
},
{
family: "Hack Nerd Font",
regular: hack,
bold: hackBold,
},
{
family: "Source Code Pro Nerd Font",
regular: sourceCodePro,
bold: sourceCodeProBold,
},
{
family: "Inconsolata Nerd Font",
regular: inconsolata,
bold: inconsolataBold,
},
{
family: "Roboto Mono Nerd Font",
regular: robotoMono,
bold: robotoMonoBold,
},
{
family: "Ubuntu Mono Nerd Font",
regular: ubuntuMono,
bold: ubuntuMonoBold,
},
{
family: "Intel One Mono Nerd Font",
regular: intelOneMono,
bold: intelOneMonoBold,
},
{
family: "Meslo LGS Nerd Font",
regular: mesloLgs,
bold: mesloLgsBold,
},
{
family: "Iosevka Nerd Font",
regular: iosevka,
bold: iosevkaBold,
},
{
family: "GeistMono Nerd Font",
regular: geistMono,
bold: geistMonoBold,
},
] satisfies MonoFont[]
const monoNerdCss = MONO_NERD_FONTS.map(
(font) => `
@font-face {
font-family: "${font.family}";
src: url("${font.regular}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "${font.family}";
src: url("${font.bold}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 700;
}`,
).join("")
import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
export const Font = () => {
return (
@@ -165,7 +53,6 @@ export const Font = () => {
descent-override: 25%;
line-gap-override: 1%;
}
${monoNerdCss}
`}</Style>
<Show when={typeof location === "undefined" || location.protocol !== "file:"}>
<Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />

View File

@@ -248,11 +248,6 @@
opacity: 1;
pointer-events: auto;
}
[data-component="markdown"] {
margin-top: 0;
font-size: var(--font-size-base);
}
}
[data-component="compaction-part"] {

View File

@@ -151,7 +151,6 @@ export const SessionReview = (props: SessionReviewProps) => {
const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((diff) => diff.file))
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0
@@ -282,11 +281,10 @@ export const SessionReview = (props: SessionReviewProps) => {
<Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
<For each={files()}>
{(file) => {
<For each={props.diffs}>
{(diff) => {
let wrapper: HTMLDivElement | undefined
const item = createMemo(() => diffs().get(file)!)
const file = diff.file
const expanded = createMemo(() => open().includes(file))
const force = () => !!store.force[file]
@@ -294,9 +292,9 @@ export const SessionReview = (props: SessionReviewProps) => {
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof item().before === "string" ? item().before : "")
const afterText = () => (typeof item().after === "string" ? item().after : "")
const changedLines = () => item().additions + item().deletions
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const changedLines = () => diff.additions + diff.deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
const tooLarge = createMemo(() => {
@@ -307,9 +305,9 @@ export const SessionReview = (props: SessionReviewProps) => {
})
const isAdded = () =>
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const selectedLines = createMemo(() => {
const current = selection()
@@ -346,7 +344,7 @@ export const SessionReview = (props: SessionReviewProps) => {
file,
selection,
comment,
preview: selectionPreview(item(), selection),
preview: selectionPreview(diff, selection),
})
},
onUpdate: ({ id, comment, selection }) => {
@@ -355,7 +353,7 @@ export const SessionReview = (props: SessionReviewProps) => {
file,
selection,
comment,
preview: selectionPreview(item(), selection),
preview: selectionPreview(diff, selection),
})
},
onDelete: (comment) => {
@@ -432,7 +430,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
<DiffChanges changes={item()} />
<DiffChanges changes={diff} />
</div>
</Match>
<Match when={isDeleted()}>
@@ -446,7 +444,7 @@ export const SessionReview = (props: SessionReviewProps) => {
</span>
</Match>
<Match when={true}>
<DiffChanges changes={item()} />
<DiffChanges changes={diff} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
@@ -492,7 +490,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={fileComponent}
mode="diff"
preloadedDiff={item().preloaded}
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
@@ -509,17 +507,17 @@ export const SessionReview = (props: SessionReviewProps) => {
commentedLines={commentedLines()}
before={{
name: file,
contents: typeof item().before === "string" ? item().before : "",
contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: file,
contents: typeof item().after === "string" ? item().after : "",
contents: typeof diff.after === "string" ? diff.after : "",
}}
media={{
mode: "auto",
path: file,
before: item().before,
after: item().after,
before: diff.before,
after: diff.after,
readFile: props.readFile,
}}
/>

View File

@@ -85,10 +85,6 @@
flex-direction: column;
align-self: stretch;
gap: 12px;
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
}
}
[data-slot="session-turn-diffs"] {
@@ -230,3 +226,7 @@
display: none;
}
}
[data-slot="session-turn-list"] {
gap: 48px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
type MonoFont = {
id: string
family: string
regular: string
bold: string
}
let files: Record<string, () => Promise<string>> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record<string, () => Promise<string>>
return files
}
export const MONO_NERD_FONTS = [
{
id: "jetbrains-mono",
family: "JetBrains Mono Nerd Font",
regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2",
bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2",
},
{
id: "fira-code",
family: "Fira Code Nerd Font",
regular: "./assets/fonts/fira-code-nerd-font.woff2",
bold: "./assets/fonts/fira-code-nerd-font-bold.woff2",
},
{
id: "cascadia-code",
family: "Cascadia Code Nerd Font",
regular: "./assets/fonts/cascadia-code-nerd-font.woff2",
bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2",
},
{
id: "hack",
family: "Hack Nerd Font",
regular: "./assets/fonts/hack-nerd-font.woff2",
bold: "./assets/fonts/hack-nerd-font-bold.woff2",
},
{
id: "source-code-pro",
family: "Source Code Pro Nerd Font",
regular: "./assets/fonts/source-code-pro-nerd-font.woff2",
bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2",
},
{
id: "inconsolata",
family: "Inconsolata Nerd Font",
regular: "./assets/fonts/inconsolata-nerd-font.woff2",
bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2",
},
{
id: "roboto-mono",
family: "Roboto Mono Nerd Font",
regular: "./assets/fonts/roboto-mono-nerd-font.woff2",
bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2",
},
{
id: "ubuntu-mono",
family: "Ubuntu Mono Nerd Font",
regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2",
bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2",
},
{
id: "intel-one-mono",
family: "Intel One Mono Nerd Font",
regular: "./assets/fonts/intel-one-mono-nerd-font.woff2",
bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2",
},
{
id: "meslo-lgs",
family: "Meslo LGS Nerd Font",
regular: "./assets/fonts/meslo-lgs-nerd-font.woff2",
bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2",
},
{
id: "iosevka",
family: "Iosevka Nerd Font",
regular: "./assets/fonts/iosevka-nerd-font.woff2",
bold: "./assets/fonts/iosevka-nerd-font-bold.woff2",
},
{
id: "geist-mono",
family: "GeistMono Nerd Font",
regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2",
bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2",
},
] satisfies MonoFont[]
const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record<string, MonoFont>
const loads = new Map<string, Promise<void>>()
function css(font: { family: string; regular: string; bold: string }) {
return `
@font-face {
font-family: "${font.family}";
src: url("${font.regular}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "${font.family}";
src: url("${font.bold}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 700;
}
`
}
export function ensureMonoFont(id: string | undefined) {
if (!id || id === "ibm-plex-mono") return Promise.resolve()
if (typeof document !== "object") return Promise.resolve()
const font = mono[id]
if (!font) return Promise.resolve()
const styleId = `oc-font-${font.id}`
if (document.getElementById(styleId)) return Promise.resolve()
const hit = loads.get(font.id)
if (hit) return hit
const files = getFiles()
const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => {
if (!regular || !bold) return
if (document.getElementById(styleId)) return
const style = document.createElement("style")
style.id = styleId
style.textContent = css({ family: font.family, regular, bold })
document.head.appendChild(style)
})
loads.set(font.id, load)
return load
}

View File

@@ -1,5 +1,7 @@
export const dict: Record<string, string> = {
"ui.sessionReview.title": "Session changes",
"ui.sessionReview.title.git": "Git changes",
"ui.sessionReview.title.branch": "Branch changes",
"ui.sessionReview.title.lastTurn": "Last turn changes",
"ui.sessionReview.diffStyle.unified": "Unified",
"ui.sessionReview.diffStyle.split": "Split",

View File

@@ -1,7 +1,7 @@
import { createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "../context/helper"
import { DEFAULT_THEMES } from "./default-themes"
import oc2ThemeJson from "./themes/oc-2.json"
import { resolveThemeVariant, themeToCss } from "./resolve"
import type { DesktopTheme } from "./types"
@@ -15,14 +15,101 @@ const STORAGE_KEYS = {
} as const
const THEME_STYLE_ID = "oc-theme"
let files: Record<string, () => Promise<{ default: DesktopTheme }>> | undefined
let ids: string[] | undefined
let known: Set<string> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json")
return files
}
function themeIDs() {
if (ids) return ids
ids = Object.keys(getFiles())
.map((path) => path.slice("./themes/".length, -".json".length))
.sort()
return ids
}
function knownThemes() {
if (known) return known
known = new Set(themeIDs())
return known
}
const names: Record<string, string> = {
"oc-2": "OC-2",
amoled: "AMOLED",
aura: "Aura",
ayu: "Ayu",
carbonfox: "Carbonfox",
catppuccin: "Catppuccin",
"catppuccin-frappe": "Catppuccin Frappe",
"catppuccin-macchiato": "Catppuccin Macchiato",
cobalt2: "Cobalt2",
cursor: "Cursor",
dracula: "Dracula",
everforest: "Everforest",
flexoki: "Flexoki",
github: "GitHub",
gruvbox: "Gruvbox",
kanagawa: "Kanagawa",
"lucent-orng": "Lucent Orng",
material: "Material",
matrix: "Matrix",
mercury: "Mercury",
monokai: "Monokai",
nightowl: "Night Owl",
nord: "Nord",
"one-dark": "One Dark",
onedarkpro: "One Dark Pro",
opencode: "OpenCode",
orng: "Orng",
"osaka-jade": "Osaka Jade",
palenight: "Palenight",
rosepine: "Rose Pine",
shadesofpurple: "Shades of Purple",
solarized: "Solarized",
synthwave84: "Synthwave '84",
tokyonight: "Tokyonight",
vercel: "Vercel",
vesper: "Vesper",
zenburn: "Zenburn",
}
const oc2Theme = oc2ThemeJson as DesktopTheme
function normalize(id: string | null | undefined) {
return id === "oc-1" ? "oc-2" : id
}
function read(key: string) {
if (typeof localStorage !== "object") return null
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function write(key: string, value: string) {
if (typeof localStorage !== "object") return
try {
localStorage.setItem(key, value)
} catch {}
}
function drop(key: string) {
if (typeof localStorage !== "object") return
try {
localStorage.removeItem(key)
} catch {}
}
function clear() {
localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT)
localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK)
drop(STORAGE_KEYS.THEME_CSS_LIGHT)
drop(STORAGE_KEYS.THEME_CSS_DARK)
}
function ensureThemeStyleElement(): HTMLStyleElement {
@@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement {
}
function getSystemMode(): "light" | "dark" {
if (typeof window !== "object") return "light"
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}
@@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da
const css = themeToCss(tokens)
if (themeId !== "oc-2") {
try {
localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
} catch {}
write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
}
const fullCss = `:root {
@@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
const variant = isDark ? theme.dark : theme.light
const tokens = resolveThemeVariant(variant, isDark)
const css = themeToCss(tokens)
try {
localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
} catch {}
write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
}
}
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2"
const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
const mode = colorScheme === "system" ? getSystemMode() : colorScheme
const [store, setStore] = createStore({
themes: DEFAULT_THEMES as Record<string, DesktopTheme>,
themeId: normalize(props.defaultTheme) ?? "oc-2",
colorScheme: "system" as ColorScheme,
mode: getSystemMode(),
themes: {
"oc-2": oc2Theme,
} as Record<string, DesktopTheme>,
themeId,
colorScheme,
mode,
previewThemeId: null as string | null,
previewScheme: null as ColorScheme | null,
})
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue)
if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
setStore("colorScheme", e.newValue as ColorScheme)
setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any))
}
})
const loads = new Map<string, Promise<DesktopTheme | undefined>>()
onMount(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handler = () => {
if (store.colorScheme === "system") {
setStore("mode", getSystemMode())
}
}
mediaQuery.addEventListener("change", handler)
onCleanup(() => mediaQuery.removeEventListener("change", handler))
const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID)
const themeId = normalize(savedTheme)
const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null
if (themeId && store.themes[themeId]) {
setStore("themeId", themeId)
}
if (savedTheme && themeId && savedTheme !== themeId) {
localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId)
clear()
}
if (savedScheme) {
setStore("colorScheme", savedScheme)
if (savedScheme !== "system") {
setStore("mode", savedScheme)
}
}
const currentTheme = store.themes[store.themeId]
if (currentTheme) {
cacheThemeVariants(currentTheme, store.themeId)
}
})
const load = (id: string) => {
const next = normalize(id)
if (!next) return Promise.resolve(undefined)
const hit = store.themes[next]
if (hit) return Promise.resolve(hit)
const pending = loads.get(next)
if (pending) return pending
const file = getFiles()[`./themes/${next}.json`]
if (!file) return Promise.resolve(undefined)
const task = file()
.then((mod) => {
const theme = mod.default
setStore("themes", next, theme)
return theme
})
.finally(() => {
loads.delete(next)
})
loads.set(next, task)
return task
}
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
applyThemeCss(theme, themeId, mode)
props.onThemeApplied?.(theme, mode)
}
const ids = () => {
const extra = Object.keys(store.themes)
.filter((id) => !knownThemes().has(id))
.sort()
const all = themeIDs()
if (extra.length === 0) return all
return [...all, ...extra]
}
const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes)
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) {
const next = normalize(e.newValue)
if (!next) return
if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
setStore("themeId", next)
if (next === "oc-2") {
clear()
return
}
void load(next).then((theme) => {
if (!theme || store.themeId !== next) return
cacheThemeVariants(theme, next)
})
}
if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
setStore("colorScheme", e.newValue as ColorScheme)
setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark"))
}
}
if (typeof window === "object") {
window.addEventListener("storage", onStorage)
onCleanup(() => window.removeEventListener("storage", onStorage))
}
onMount(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const onMedia = () => {
if (store.colorScheme !== "system") return
setStore("mode", getSystemMode())
}
mediaQuery.addEventListener("change", onMedia)
onCleanup(() => mediaQuery.removeEventListener("change", onMedia))
const rawTheme = read(STORAGE_KEYS.THEME_ID)
const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2"
const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
if (rawTheme && rawTheme !== savedTheme) {
write(STORAGE_KEYS.THEME_ID, savedTheme)
clear()
}
if (savedTheme !== store.themeId) setStore("themeId", savedTheme)
if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme)
setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme)
void load(savedTheme).then((theme) => {
if (!theme || store.themeId !== savedTheme) return
cacheThemeVariants(theme, savedTheme)
})
})
createEffect(() => {
const theme = store.themes[store.themeId]
if (theme) {
applyTheme(theme, store.themeId, store.mode)
}
if (!theme) return
applyTheme(theme, store.themeId, store.mode)
})
const setTheme = (id: string) => {
@@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
console.warn(`Theme "${id}" not found`)
return
}
const theme = store.themes[next]
if (!theme) {
if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) {
console.warn(`Theme "${id}" not found`)
return
}
setStore("themeId", next)
localStorage.setItem(STORAGE_KEYS.THEME_ID, next)
if (next === "oc-2") {
write(STORAGE_KEYS.THEME_ID, next)
clear()
return
}
cacheThemeVariants(theme, next)
void load(next).then((theme) => {
if (!theme || store.themeId !== next) return
cacheThemeVariants(theme, next)
write(STORAGE_KEYS.THEME_ID, next)
})
}
const setColorScheme = (scheme: ColorScheme) => {
setStore("colorScheme", scheme)
localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme)
write(STORAGE_KEYS.COLOR_SCHEME, scheme)
setStore("mode", scheme === "system" ? getSystemMode() : scheme)
}
@@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
themeId: () => store.themeId,
colorScheme: () => store.colorScheme,
mode: () => store.mode,
ids,
name: (id: string) => store.themes[id]?.name ?? names[id] ?? id,
loadThemes,
themes: () => store.themes,
setTheme,
setColorScheme,
@@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
previewTheme: (id: string) => {
const next = normalize(id)
if (!next) return
const theme = store.themes[next]
if (!theme) return
if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
setStore("previewThemeId", next)
const previewMode = store.previewScheme
? store.previewScheme === "system"
? getSystemMode()
: store.previewScheme
: store.mode
applyTheme(theme, next, previewMode)
void load(next).then((theme) => {
if (!theme || store.previewThemeId !== next) return
const mode = store.previewScheme
? store.previewScheme === "system"
? getSystemMode()
: store.previewScheme
: store.mode
applyTheme(theme, next, mode)
})
},
previewColorScheme: (scheme: ColorScheme) => {
setStore("previewScheme", scheme)
const previewMode = scheme === "system" ? getSystemMode() : scheme
const mode = scheme === "system" ? getSystemMode() : scheme
const id = store.previewThemeId ?? store.themeId
const theme = store.themes[id]
if (theme) {
applyTheme(theme, id, previewMode)
}
void load(id).then((theme) => {
if (!theme) return
if ((store.previewThemeId ?? store.themeId) !== id) return
if (store.previewScheme !== scheme) return
applyTheme(theme, id, mode)
})
},
commitPreview: () => {
if (store.previewThemeId) {
@@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
cancelPreview: () => {
setStore("previewThemeId", null)
setStore("previewScheme", null)
const theme = store.themes[store.themeId]
if (theme) {
void load(store.themeId).then((theme) => {
if (!theme) return
applyTheme(theme, store.themeId, store.mode)
}
})
},
}
},

Some files were not shown because too many files have changed in this diff Show More