+20
![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)




![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)


![opencode-agent[bot]](/assets/img/avatar_default.png)
opencode-agent[bot]
Aiden Cline
Adam
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aarav Sareen
Brendan Allan
James Long
James Long
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Jay
Brendan Allan
Luke Parker
Dustin Deus
Aiden Cline
冯基魁
opencode
Frank
Aiden Cline
Kit Langton
David Hill
usrnk1
Victor Navarro
Dax Raad
Dax
Nabs
Vladimir Glafirov
AidenGeunGeun
Mark
Jay
BB84
e916b99742
Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com> Co-authored-by: Jay <air@live.ca> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
463 lines
14 KiB
TypeScript
463 lines
14 KiB
TypeScript
// @refresh reload
|
|
|
|
import {
|
|
ACCEPTED_FILE_EXTENSIONS,
|
|
AppBaseProviders,
|
|
AppInterface,
|
|
handleNotificationClick,
|
|
loadLocaleDict,
|
|
normalizeLocale,
|
|
type Locale,
|
|
type Platform,
|
|
PlatformProvider,
|
|
ServerConnection,
|
|
useCommand,
|
|
useWslServers,
|
|
} from "@opencode-ai/app"
|
|
import type { UpdaterState } from "@opencode-ai/app/updater"
|
|
import * as Sentry from "@sentry/solid"
|
|
import type { AsyncStorage } from "@solid-primitives/storage"
|
|
import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
|
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
|
|
import { render } from "solid-js/web"
|
|
import pkg from "../../package.json"
|
|
import { initI18n, t } from "./i18n"
|
|
import { initializationData, initializationReady } from "./initialization"
|
|
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
|
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
|
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
|
|
import "./styles.css"
|
|
import { Splash } from "@opencode-ai/ui/logo"
|
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
|
|
|
const root = document.getElementById("root")
|
|
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|
throw new Error(t("error.dev.rootNotFound"))
|
|
}
|
|
|
|
if (import.meta.env.VITE_SENTRY_DSN) {
|
|
Sentry.init({
|
|
dsn: import.meta.env.VITE_SENTRY_DSN,
|
|
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
|
|
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
|
|
initialScope: {
|
|
tags: {
|
|
platform: "desktop",
|
|
},
|
|
},
|
|
integrations: (integrations) => {
|
|
return integrations.filter(
|
|
(i) =>
|
|
i.name !== "Breadcrumbs" &&
|
|
!(
|
|
import.meta.env.OPENCODE_CHANNEL === "prod" &&
|
|
(i.name === "GlobalHandlers" || i.name === "BrowserApiErrors")
|
|
),
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
void initI18n()
|
|
|
|
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
|
|
void window.api.updater.subscribe(setUpdaterState)
|
|
|
|
const deepLinkEvent = "opencode:deep-link"
|
|
|
|
type DesktopWindowState = {
|
|
id?: string
|
|
}
|
|
|
|
const emitDeepLinks = (urls: string[]) => {
|
|
if (urls.length === 0) return
|
|
window.__OPENCODE__ ??= {}
|
|
const pending = window.__OPENCODE__.deepLinks ?? []
|
|
window.__OPENCODE__.deepLinks = [...pending, ...urls]
|
|
window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
|
|
}
|
|
|
|
const listenForDeepLinks = () => {
|
|
void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
|
|
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
|
|
}
|
|
|
|
function windowLastActiveUrlKey(windowID: string) {
|
|
return `opencode.desktop.window.${windowID}.last-active-url`
|
|
}
|
|
|
|
function getLastActiveUrl(windowID: string) {
|
|
if (typeof localStorage !== "object") return "/"
|
|
try {
|
|
const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
|
|
if (value?.startsWith("/") && !value.startsWith("//")) return value
|
|
} catch {}
|
|
return "/"
|
|
}
|
|
|
|
function setLastActiveUrl(windowID: string, value: string) {
|
|
if (typeof localStorage !== "object") return
|
|
try {
|
|
localStorage.setItem(windowLastActiveUrlKey(windowID), value)
|
|
} catch {}
|
|
}
|
|
|
|
function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
|
|
const history = createMemoryHistory()
|
|
const initialUrl = getLastActiveUrl(props.windowID)
|
|
if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
|
|
onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
|
|
return <MemoryRouter {...props} history={history} />
|
|
}
|
|
|
|
const createPlatform = (windowState: DesktopWindowState): Platform => {
|
|
const attachmentPaths = new WeakMap<File, string>()
|
|
const os = (() => {
|
|
const ua = navigator.userAgent
|
|
if (ua.includes("Mac")) return "macos"
|
|
if (ua.includes("Windows")) return "windows"
|
|
if (ua.includes("Linux")) return "linux"
|
|
return undefined
|
|
})()
|
|
|
|
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
|
|
switch (action) {
|
|
case "view.resetZoom":
|
|
resetZoom()
|
|
return
|
|
case "view.zoomIn":
|
|
zoomIn()
|
|
return
|
|
case "view.zoomOut":
|
|
zoomOut()
|
|
return
|
|
}
|
|
|
|
return window.api.runDesktopMenuAction(action)
|
|
}
|
|
|
|
const storage = (() => {
|
|
const cache = new Map<string, AsyncStorage>()
|
|
|
|
const createStorage = (name: string) => {
|
|
const api: AsyncStorage = {
|
|
getItem: (key: string) => window.api.storeGet(name, key),
|
|
setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
|
|
removeItem: (key: string) => window.api.storeDelete(name, key),
|
|
clear: () => window.api.storeClear(name),
|
|
key: async (index: number) => (await window.api.storeKeys(name))[index],
|
|
getLength: () => window.api.storeLength(name),
|
|
get length() {
|
|
return api.getLength()
|
|
},
|
|
}
|
|
return api
|
|
}
|
|
|
|
return (name = "default.dat") => {
|
|
const cached = cache.get(name)
|
|
if (cached) return cached
|
|
const api = createStorage(name)
|
|
cache.set(name, api)
|
|
return api
|
|
}
|
|
})()
|
|
|
|
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
|
|
|
|
return {
|
|
platform: "desktop",
|
|
os,
|
|
version: pkg.version,
|
|
windowID: windowState.id,
|
|
|
|
async openDirectoryPickerDialog(opts) {
|
|
return window.api.openDirectoryPicker({
|
|
multiple: opts?.multiple ?? false,
|
|
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
|
|
})
|
|
},
|
|
|
|
async openAttachmentPickerDialog(opts, onFile) {
|
|
const result = await window.api.openFilePicker({
|
|
multiple: opts?.multiple ?? false,
|
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
|
defaultPath: opts?.defaultPath,
|
|
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
|
|
})
|
|
if (!result) return
|
|
try {
|
|
for (const file of result.files) {
|
|
const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name)
|
|
attachmentPaths.set(selected, file.path)
|
|
await onFile(selected)
|
|
}
|
|
} finally {
|
|
await window.api.releasePickedFiles(result.token)
|
|
}
|
|
},
|
|
|
|
getPathForFile(file) {
|
|
return attachmentPaths.get(file) ?? window.api.getPathForFile(file)
|
|
},
|
|
|
|
async saveFilePickerDialog(opts) {
|
|
return window.api.saveFilePicker({
|
|
title: opts?.title ?? t("desktop.dialog.saveFile"),
|
|
defaultPath: opts?.defaultPath,
|
|
})
|
|
},
|
|
|
|
openLink(url: string) {
|
|
window.api.openLink(url)
|
|
},
|
|
async openPath(path: string, app?: string) {
|
|
if (os === "windows") {
|
|
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
|
return window.api.openPath(path, resolvedApp ?? undefined)
|
|
}
|
|
return window.api.openPath(path, app)
|
|
},
|
|
async revealPath(path: string) {
|
|
return window.api.revealPath(path)
|
|
},
|
|
|
|
back() {
|
|
window.history.back()
|
|
},
|
|
|
|
forward() {
|
|
window.history.forward()
|
|
},
|
|
|
|
storage,
|
|
|
|
updater: {
|
|
state: updaterState,
|
|
check: () => window.api.updater.check(),
|
|
install: () => window.api.updater.install(),
|
|
},
|
|
|
|
exportDebugLogs: () => window.api.exportDebugLogs(),
|
|
|
|
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
|
|
|
|
restart: async () => {
|
|
await window.api.killSidecar().catch(() => undefined)
|
|
window.api.relaunch()
|
|
},
|
|
|
|
notify: async (title, description, href) => {
|
|
const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
|
|
if (focused) return
|
|
|
|
const notification = new Notification(title, {
|
|
body: description ?? "",
|
|
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
|
})
|
|
notification.onclick = () => {
|
|
void window.api.showWindow()
|
|
void window.api.setWindowFocus()
|
|
handleNotificationClick(href)
|
|
notification.close()
|
|
}
|
|
},
|
|
|
|
fetch: (input, init) => {
|
|
if (input instanceof Request) return fetch(input)
|
|
return fetch(input, init)
|
|
},
|
|
|
|
getDefaultServer: async () => {
|
|
const url = await window.api.getDefaultServerUrl().catch(() => null)
|
|
if (!url) return null
|
|
return ServerConnection.Key.make(url)
|
|
},
|
|
|
|
setDefaultServer: async (url: string | null) => {
|
|
await window.api.setDefaultServerUrl(url)
|
|
},
|
|
|
|
wslServers: wslServersApi,
|
|
|
|
getDisplayBackend: async () => {
|
|
return window.api.getDisplayBackend().catch(() => null)
|
|
},
|
|
|
|
setDisplayBackend: async (backend) => {
|
|
await window.api.setDisplayBackend(backend)
|
|
},
|
|
|
|
parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
|
|
|
|
webviewZoom,
|
|
|
|
getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
|
|
|
|
setPinchZoomEnabled,
|
|
|
|
runDesktopMenuAction,
|
|
|
|
checkAppExists: async (appName: string) => {
|
|
return window.api.checkAppExists(appName)
|
|
},
|
|
|
|
async readClipboardImage() {
|
|
const image = await window.api.readClipboardImage().catch(() => null)
|
|
if (!image) return null
|
|
const blob = new Blob([image.buffer], { type: "image/png" })
|
|
return new File([blob], `pasted-image-${Date.now()}.png`, {
|
|
type: "image/png",
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
let menuTrigger = null as null | ((id: string) => void)
|
|
window.api.onMenuCommand((id) => {
|
|
menuTrigger?.(id)
|
|
})
|
|
listenForDeepLinks()
|
|
|
|
function LoadingSplash() {
|
|
return (
|
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
|
const platform = createPlatform(props.windowState)
|
|
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())
|
|
|
|
// Fetch sidecar credentials (available immediately, before health check)
|
|
const [sidecar] = createResource(() => window.api.awaitInitialization())
|
|
|
|
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
|
const [locale] = createResource(loadLocale)
|
|
const router = (props: BaseRouterProps) => (
|
|
<DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
|
|
)
|
|
const onboarding = Promise.withResolvers<void>()
|
|
|
|
function handleClick(e: MouseEvent) {
|
|
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
|
if (link?.href) {
|
|
e.preventDefault()
|
|
platform.openLink(link.href)
|
|
}
|
|
}
|
|
|
|
function Inner() {
|
|
const cmd = useCommand()
|
|
menuTrigger = (id) => cmd.trigger(id)
|
|
|
|
const theme = useTheme()
|
|
|
|
createEffect(() => {
|
|
theme.themeId()
|
|
theme.mode()
|
|
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
|
|
if (bg) {
|
|
void window.api.setBackgroundColor(bg)
|
|
}
|
|
})
|
|
|
|
return null
|
|
}
|
|
|
|
function App() {
|
|
const wslServers = useWslServers()
|
|
const ready = createMemo(
|
|
() =>
|
|
!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading,
|
|
)
|
|
const servers = createMemo(() => {
|
|
const data = initializationData(sidecar)
|
|
const list: ServerConnection.Any[] = []
|
|
if (data) {
|
|
list.push({
|
|
displayName: "Local Server",
|
|
type: "sidecar",
|
|
variant: "base",
|
|
http: {
|
|
url: data.url,
|
|
username: data.username ?? undefined,
|
|
password: data.password ?? undefined,
|
|
},
|
|
})
|
|
}
|
|
list.push(...readyWslConnections(wslServers.data))
|
|
return list
|
|
})
|
|
const effectiveDefaultServer = createMemo(() =>
|
|
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
|
|
)
|
|
return (
|
|
<Show when={ready()} fallback={<LoadingSplash />}>
|
|
<Show when={effectiveDefaultServer()} keyed>
|
|
{(key) => (
|
|
<AppInterface
|
|
defaultServer={key}
|
|
servers={servers()}
|
|
router={router}
|
|
startup={onboarding.promise}
|
|
serverScoped={
|
|
<DesktopFirstLaunchOnboarding
|
|
initialUrl={getLastActiveUrl(platform.windowID ?? "browser")}
|
|
onLoaded={onboarding.resolve}
|
|
/>
|
|
}
|
|
>
|
|
<Inner />
|
|
</AppInterface>
|
|
)}
|
|
</Show>
|
|
</Show>
|
|
)
|
|
}
|
|
|
|
onMount(() => {
|
|
document.addEventListener("click", handleClick)
|
|
onCleanup(() => {
|
|
document.removeEventListener("click", handleClick)
|
|
})
|
|
})
|
|
|
|
return (
|
|
<PlatformProvider value={platform}>
|
|
<AppBaseProviders locale={locale.latest}>
|
|
<Show when={true}>{(_) => <App />}</Show>
|
|
</AppBaseProviders>
|
|
</PlatformProvider>
|
|
)
|
|
}
|
|
|
|
render(() => {
|
|
const [windowState] = createResource(async () => {
|
|
const api = window.api as typeof window.api & {
|
|
getWindowID?: () => Promise<string>
|
|
}
|
|
return { id: await api.getWindowID?.() }
|
|
})
|
|
|
|
return (
|
|
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
|
|
{(state) => <DesktopRoot windowState={state} />}
|
|
</Show>
|
|
)
|
|
}, root!)
|