app: refactor server management backend (#13813)

This commit is contained in:
Brendan Allan
2026-02-18 23:03:24 +08:00
committed by GitHub
parent 2611c35acc
commit 1bb8574179
22 changed files with 594 additions and 460 deletions

9
.zed/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}

View File

@@ -1,35 +1,36 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { MetaProvider } from "@solidjs/meta"
import { PermissionProvider } from "@/context/permission" import { Navigate, Route, Router } from "@solidjs/router"
import { LayoutProvider } from "@/context/layout" import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean } __OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
} }
} }
@@ -107,30 +112,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
) )
} }
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
<Show when={server.url} keyed> <Show when={server.key} keyed>
{props.children} {props.children}
</Show> </Show>
) )
} }
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { export function AppInterface(props: {
const platform = usePlatform() children?: JSX.Element
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform) defaultServer: ServerConnection.Key
const defaultServerUrl = resolveDefaultServerUrl({ servers?: Array<ServerConnection.Any>
defaultUrl: props.defaultUrl, }) {
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return ( return (
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>

View File

@@ -1,19 +1,18 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { Button } from "@opencode-ai/ui/button"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps { interface AddRowProps {
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return if (!normalized) return
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher) const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>, status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
adding: false, adding: false,
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
}) })
} }
const replaceServer = (original: string, next: string) => { const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.url const active = server.key
const nextActive = active === original ? next : active const newConn = server.add(next)
if (!newConn) return
server.add(next) const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(original) server.remove(ServerConnection.key(original))
} }
const items = createMemo(() => { const items = createMemo(() => {
const current = server.url const current = server.current
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (a === active) return -1
if (b === active) return 1 if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b]) const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
}) })
async function refreshHealth() { async function refreshHealth() {
const results: Record<string, ServerHealth> = {} const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (url) => { items().map(async (conn) => {
results[url] = await checkServerHealth(url, fetcher) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
async function select(value: string, persist?: boolean) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close() dialog.close()
if (persist) { if (persist) {
server.add(value) server.add(conn.http.url)
navigate("/") navigate("/")
return return
} }
server.setActive(value) server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
} }
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
} }
resetAdd() resetAdd()
await select(normalized, true) await select({ type: "http", http: { url: normalized } }, true)
} }
async function handleEdit(original: string, value: string) { async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy) return if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) { if (!normalized) {
resetEdit() resetEdit()
return return
} }
if (normalized === original) { if (normalized === original.http.url) {
resetEdit() resetEdit()
return return
} }
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url) handleAdd(store.addServer.url)
} }
const handleEditKey = (event: KeyboardEvent, original: string) => { const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value) handleEdit(original, store.editServer.value)
} }
async function handleRemove(url: string) { async function handleRemove(url: ServerConnection.Key) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null) platform.setDefaultServerUrl?.(null)
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}> <div ref={(el) => (listRoot = el)}>
<List <List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={sortedItems} items={sortedItems}
key={(x) => x} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x) select(x) if (x) select(x)
}} }}
@@ -428,7 +430,7 @@ export function DialogSelectServer() {
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show <Show
when={store.editServer.id !== i} when={store.editServer.id !== i.http.url}
fallback={ fallback={
<EditRow <EditRow
value={store.editServer.value} value={store.editServer.value}
@@ -443,12 +445,12 @@ export function DialogSelectServer() {
} }
> >
<ServerRow <ServerRow
url={i} conn={i}
status={store.status[i]} status={store.status[ServerConnection.key(i)]}
dimmed={store.status[i]?.healthy === false} dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1" class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={ badge={
<Show when={defaultUrl() === i}> <Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -456,12 +458,13 @@ export function DialogSelectServer() {
} }
/> />
</Show> </Show>
<Show when={store.editServer.id !== i}> <Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4"> <div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}> <Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show> </Show>
<Show when={i.type === "http"}>
<DropdownMenu> <DropdownMenu>
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
@@ -476,23 +479,23 @@ export function DialogSelectServer() {
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
setStore("editServer", { setStore("editServer", {
id: i, id: i.http.url,
value: i, value: i.http.url,
error: "", error: "",
status: store.status[i]?.healthy, status: store.status[ServerConnection.key(i)]?.healthy,
}) })
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}> <Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i)}> <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={canDefault() && defaultUrl() === i}> <Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}> <DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
@@ -501,14 +504,17 @@ export function DialogSelectServer() {
</Show> </Show>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => handleRemove(i)} onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu> </DropdownMenu>
</Show>
</div> </div>
</Show> </Show>
</div> </div>

View File

@@ -12,7 +12,9 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => ({ const clientFor = (directory: string) => {
createdClients.push(directory)
return {
session: { session: {
create: async () => { create: async () => {
createdSessions.push(directory) createdSessions.push(directory)
@@ -29,7 +31,8 @@ const clientFor = (directory: string) => ({
worktree: { worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }), create: async () => ({ data: { directory: `${directory}/new` } }),
}, },
}) }
}
beforeAll(async () => { beforeAll(async () => {
const rootClient = clientFor("/repo/main") const rootClient = clientFor("/repo/main")
@@ -88,11 +91,17 @@ beforeAll(async () => {
})) }))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => ({ useSDK: () => {
const sdk = {
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
url: "http://localhost:4096", url: "http://localhost:4096",
}), createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({

View File

@@ -1,21 +1,20 @@
import { Accessor } from "solid-js" import type { Message } from "@opencode-ai/sdk/v2/client"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useLocal } from "@/context/local" import { useNavigate, useParams } from "@solidjs/router"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal() const local = useLocal()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
@@ -175,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({ client = sdk.createClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
@@ -372,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined } const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => { timer.id = window.setTimeout(() => {
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs) }, timeoutMs)
}) })

View File

@@ -1,10 +1,19 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import {
import { serverDisplayName } from "@/context/server" createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps { interface ServerRowProps extends ParentProps {
url: string conn: ServerConnection.Any
status?: ServerHealth status?: ServerHealth
class?: string class?: string
nameClass?: string nameClass?: string
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url)) const name = createMemo(() => serverDisplayName(props.conn))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => { createEffect(() => {
name() name()
props.url props.conn.http.url
props.status?.version props.status?.version
queueMicrotask(check) queueMicrotask(check)
}) })

View File

@@ -1,21 +1,21 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { normalizeServerUrl, useServer } from "@/context/server" import { createStore, reconcile } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000 const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
} }
const listServersByHealth = ( const listServersByHealth = (
list: string[], list: ServerConnection.Any[],
active: string | undefined, active: ServerConnection.Key | undefined,
status: Record<string, ServerHealth | undefined>, status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => { ) => {
if (!list.length) return list if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
} }
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (ServerConnection.key(a) === active) return -1
if (b === active) return 1 if (ServerConnection.key(b) === active) return 1
const diff = rank(status[a]) - rank(status[b]) const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
} }
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => { const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
const list = servers() const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
const refresh = async () => { const refresh = async () => {
const results: Record<string, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
list.map(async (url) => { list.map(async (conn) => {
results[url] = await checkServerHealth(url, fetcher) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
if (dead) return if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
return status return status
} }
const useDefaultServerUrl = ( const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [url, setUrl] = createSignal<string | undefined>() const [url, setUrl] = createSignal<string | undefined>()
@@ -117,7 +117,14 @@ const useDefaultServerUrl = (
}) })
}) })
return { url, refresh: () => setTick((value) => value + 1) } return {
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
} }
const useMcpToggle = (input: { const useMcpToggle = (input: {
@@ -163,16 +170,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.url const current = server.current
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => item !== current)] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
}) })
const health = useServerHealth(servers, fetcher) const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -251,8 +258,9 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(url) => { {(s) => {
const isBlocked = () => health[url]?.healthy === false const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -264,19 +272,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(url) server.setActive(key)
navigate("/") navigate("/")
}} }}
> >
<ServerRow <ServerRow
url={url} conn={s}
status={health[url]} status={health[key]}
dimmed={isBlocked()} dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
badge={ badge={
<Show when={url === defaultServer.url()}> <Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
@@ -284,7 +292,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={url === server.url}> <Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow> </ServerRow>

View File

@@ -1,14 +1,15 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings" import { monoFontFamily, useSettings } from "@/context/settings"
import { parseKeybind, matchKeybind } from "@/context/command" import type { LocalPTY } from "@/context/terminal"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
@@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown) input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true }) input.container.addEventListener("click", input.handleLinkClick, {
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) capture: true,
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur) input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined let ws: WebSocket | undefined
@@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) useTerminalUiBindings({
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal() focusTerminal()
@@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory) url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) { url.username = server.current?.http.username ?? ""
url.username = "opencode" url.password = server.current?.http.password ?? ""
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url) const socket = new WebSocket(url)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket

View File

@@ -1,8 +1,9 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
@@ -17,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => { const eventFetch = (() => {
if (!platform.fetch) return if (!platform.fetch || !server.current) return
try { try {
const url = new URL(server.url) const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch if (url.protocol === "http:" && !loopback) return platform.fetch
} catch { } catch {
@@ -38,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})() })()
const eventSdk = createOpencodeClient({ const currentServer = server.current
baseUrl: server.url, if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
fetch: eventFetch, fetch: eventFetch,
headers: eventFetch ? undefined : auth, server: currentServer.http,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event
@@ -133,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (streamErrorLogged) return if (streamErrorLogged) return
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream error", { console.error("[global-sdk] event stream error", {
url: server.url, url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
@@ -166,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (!aborted(error) && !streamErrorLogged) { if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream failed", { console.error("[global-sdk] event stream failed", {
url: server.url, url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
@@ -200,12 +193,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
flush() flush()
}) })
const sdk = createOpencodeClient({ const sdk = createSdkForServer({
baseUrl: server.url, server: server.current.http,
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
return { url: server.url, client: sdk, event: emitter } return {
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
}, },
}) })

View File

@@ -1,41 +1,41 @@
import { import type {
type Config, Config,
type Path, OpencodeClient,
type Project, Path,
type ProviderAuthResponse, Project,
type ProviderListResponse, ProviderAuthResponse,
type Todo, ProviderListResponse,
createOpencodeClient, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "./global-sdk" import { getFilename } from "@opencode-ai/util/path"
import type { InitError } from "../pages/error"
import { import {
createContext, createContext,
createEffect, createEffect,
untrack,
getOwner, getOwner,
useContext, Match,
onCleanup, onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Switch, Switch,
Match, untrack,
useContext,
} from "solid-js" } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast" import { createStore, produce, reconcile } from "solid-js/store"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { createRefreshQueue } from "./global-sync/queue" import type { InitError } from "../pages/error"
import { createChildStoreManager } from "./global-sync/child-store" import { useGlobalSDK } from "./global-sdk"
import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -77,7 +77,7 @@ function createGlobalSync() {
loadSessionsFallback: 0, loadSessionsFallback: 0,
} }
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>() const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
@@ -151,9 +151,7 @@ function createGlobalSync() {
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory) const cached = sdkCache.get(directory)
if (cached) return cached if (cached) return cached
const sdk = createOpencodeClient({ const sdk = globalSDK.createClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory, directory,
throwOnError: true, throwOnError: true,
}) })
@@ -193,7 +191,10 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory) const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) { if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission }) const next = trimSessions(store.session, {
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
} }
@@ -218,10 +219,17 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission }) const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: store.permission,
})
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
) )
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit }) sessionMeta.set(directory, { limit })
@@ -331,7 +339,9 @@ function createGlobalSync() {
await bootstrapGlobal({ await bootstrapGlobal({
globalSDK: globalSDK.client, globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"), connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore, setGlobalStore,
}) })

View File

@@ -1,21 +1,21 @@
import { import type {
type Config, Config,
type Path, OpencodeClient,
type PermissionRequest, Path,
type Project, PermissionRequest,
type ProviderAuthResponse, Project,
type ProviderListResponse, ProviderAuthResponse,
type QuestionRequest, ProviderListResponse,
type Todo, QuestionRequest,
createOpencodeClient, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -31,7 +31,7 @@ type GlobalStore = {
} }
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient> globalSDK: OpencodeClient
connectErrorTitle: string connectErrorTitle: string
connectErrorDescription: string connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: ReturnType<typeof createOpencodeClient> sdk: OpencodeClient
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache

View File

@@ -1,9 +1,8 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
@@ -12,14 +11,11 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { directory: Accessor<string> }) => { init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory) const directory = createMemo(props.directory)
const client = createMemo(() => const client = createMemo(() =>
createOpencodeClient({ globalSDK.createClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(), directory: directory(),
throwOnError: true, throwOnError: true,
}), }),
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() { get url() {
return globalSDK.url return globalSDK.url
}, },
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
} }
}, },
}) })

View File

@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -15,9 +15,10 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "") return withProtocol.replace(/\/+$/, "")
} }
export function serverDisplayName(url: string) { export function serverDisplayName(conn?: ServerConnection.Any) {
if (!url) return "" if (!conn) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
} }
function projectsKey(url: string) { function projectsKey(url: string) {
@@ -27,80 +28,104 @@ function projectsKey(url: string) {
return url return url
} }
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultUrl: string; isSidecar?: boolean }) => { init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>, lastProject: {} as Record<string, string>,
}), }),
) )
const allServers = createMemo(
(): Array<ServerConnection.Any> => [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
],
)
const [state, setState] = createStore({ const [state, setState] = createStore({
active: "", active: props.defaultServer,
healthy: undefined as boolean | undefined, healthy: undefined as boolean | undefined,
}) })
const healthy = () => state.healthy const healthy = () => state.healthy
const defaultUrl = () => normalizeServerUrl(props.defaultUrl) function startHealthPolling(conn: ServerConnection.Any) {
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
let alive = true let alive = true
let busy = false let busy = false
const run = () => { const run = () => {
if (busy) return if (busy) return
busy = true busy = true
void check(url) void check(conn)
.then((next) => { .then((next) => {
if (!alive) return if (!alive) return
setState("healthy", next) setState("healthy", next)
@@ -118,59 +143,70 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
} }
function setActive(input: string) { function setActive(input: ServerConnection.Key) {
const url = normalizeServerUrl(input) if (state.active !== input) setState("active", input)
if (!url) return
setState("active", url)
} }
function add(input: string) { function add(input: string) {
const url = normalizeServerUrl(input) const url = normalizeServerUrl(input)
if (!url) return if (!url) return
updateServerList(url) return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
} }
const conn: ServerConnection.Http = { type: "http", http }
function remove(input: string) { setState("active", ServerConnection.key(conn))
const url = normalizeServerUrl(input) return conn
if (!url) return
updateServerList(url, true)
}
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
}) })
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => x !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
}
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => { createEffect(() => {
const url = state.active const current_ = current()
if (!url) return if (!current_) return
setState("healthy", undefined) setState("healthy", undefined)
onCleanup(startHealthPolling(url)) onCleanup(startHealthPolling(current_))
}) })
const origin = createMemo(() => projectsKey(state.active)) const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local") const isLocal = createMemo(() => origin() === "local")
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
return { return {
ready: isReady, ready: isReady,
healthy, healthy,
isLocal, isLocal,
get url() { get key() {
return state.active return state.active
}, },
get name() { get name() {
return serverDisplayName(state.active) return serverDisplayName(current())
}, },
get list() { get list() {
return store.list return allServers()
},
get current() {
return current()
}, },
setActive, setActive,
add, add,

View File

@@ -1,11 +1,14 @@
// @refresh reload // @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -107,12 +110,22 @@ const platform: Platform = {
setDefaultServerUrl: writeDefaultServerUrl, setDefaultServerUrl: writeDefaultServerUrl,
} }
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
<AppInterface /> <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),

View File

@@ -1,4 +1,5 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click" export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { checkServerHealth } from "./server-health" import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
url: "http://localhost:4096",
}
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
if (init?.signal) return init.signal if (init?.signal) return init.signal
if (input instanceof Request) return input.signal if (input instanceof Request) return input.signal
@@ -15,7 +20,7 @@ describe("checkServerHealth", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch) const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" }) expect(result).toEqual({ healthy: true, version: "1.2.3" })
}) })
@@ -25,7 +30,7 @@ describe("checkServerHealth", () => {
throw new Error("network") throw new Error("network")
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch) const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false }) expect(result).toEqual({ healthy: false })
}) })
@@ -51,7 +56,9 @@ describe("checkServerHealth", () => {
) )
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => { const result = await checkServerHealth(server, fetch, {
timeoutMs: 10,
}).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
}) })
@@ -71,7 +78,9 @@ describe("checkServerHealth", () => {
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const abort = new AbortController() const abort = new AbortController()
await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal }) await checkServerHealth(server, fetch, {
signal: abort.signal,
})
expect(signal).toBe(abort.signal) expect(signal).toBe(abort.signal)
}) })
@@ -87,7 +96,7 @@ describe("checkServerHealth", () => {
}) })
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { const result = await checkServerHealth(server, fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })
@@ -103,7 +112,7 @@ describe("checkServerHealth", () => {
throw new TypeError("network") throw new TypeError("network")
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { const result = await checkServerHealth(server, fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })

View File

@@ -1,4 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
export type ServerHealth = { healthy: boolean; version?: string } export type ServerHealth = { healthy: boolean; version?: string }
@@ -17,7 +18,10 @@ function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
if (timeout) { if (timeout) {
try { try {
return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined } return {
signal: timeout.call(AbortSignal, timeoutMs),
clear: undefined as (() => void) | undefined,
}
} catch {} } catch {}
} }
const controller = new AbortController() const controller = new AbortController()
@@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
} }
export async function checkServerHealth( export async function checkServerHealth(
url: string, server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch, fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions, opts?: CheckServerHealthOptions,
): Promise<ServerHealth> { ): Promise<ServerHealth> {
@@ -67,8 +71,8 @@ export async function checkServerHealth(
.catch(() => ({ healthy: false })) .catch(() => ({ healthy: false }))
} }
const attempt = (count: number): Promise<ServerHealth> => const attempt = (count: number): Promise<ServerHealth> =>
createOpencodeClient({ createSdkForServer({
baseUrl: url, server,
fetch, fetch,
signal, signal,
}) })

View File

@@ -0,0 +1,22 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
export function createSdkForServer({
server,
...config
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
server: ServerConnection.HttpBase
}) {
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()
return createOpencodeClient({
...config,
headers: { ...config.headers, ...auth },
baseUrl: server.url,
})
}

View File

@@ -22,5 +22,6 @@
} }
}, },
"include": ["src", "package.json"], "include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"] "exclude": ["dist", "ts-dist"],
"references": [{ "path": "../sdk/js" }]
} }

View File

@@ -1,36 +1,37 @@
// @refresh reload // @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import { import {
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
PlatformProvider,
Platform,
useCommand,
handleNotificationClick, handleNotificationClick,
type Platform,
PlatformProvider,
ServerConnection,
useCommand,
} from "@opencode-ai/app" } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { readImage } from "@tauri-apps/plugin-clipboard-manager"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { UPDATER_ENABLED } from "./updater" import { open, save } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n" import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { type as ostype } from "@tauri-apps/plugin-os"
import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json" import pkg from "../package.json"
import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css" import "./styles.css"
import { commands, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core" import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { createMenu } from "./menu" import { createMenu } from "./menu"
const root = document.getElementById("root") const root = document.getElementById("root")
@@ -58,7 +59,7 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
} }
const createPlatform = (password: Accessor<string | null>): Platform => { const createPlatform = (): Platform => {
const os = (() => { const os = (() => {
const type = ostype() const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type if (type === "macos" || type === "windows" || type === "linux") return type
@@ -344,22 +345,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
}, },
fetch: (input, init) => { fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) { if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input) return tauriFetch(input)
} else { } else {
const headers = new Headers(init?.headers) return tauriFetch(input, init)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
} }
}, },
@@ -417,7 +406,11 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
return new Promise<File | null>((resolve) => { return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (!blob) return resolve(null) if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) resolve(
new File([blob], `pasted-image-${Date.now()}.png`, {
type: "image/png",
}),
)
}, "image/png") }, "image/png")
}) })
}, },
@@ -431,9 +424,7 @@ createMenu((id) => {
void listenForDeepLinks() void listenForDeepLinks()
render(() => { render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null) const platform = createPlatform()
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -455,9 +446,16 @@ render(() => {
<AppBaseProviders> <AppBaseProviders>
<ServerGate> <ServerGate>
{(data) => { {(data) => {
setServerPassword(data().password) const server: ServerConnection.Sidecar = {
window.__OPENCODE__ ??= {} displayName: "Local Server",
window.__OPENCODE__.serverPassword = data().password ?? undefined type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
function Inner() { function Inner() {
const cmd = useCommand() const cmd = useCommand()
@@ -468,7 +466,7 @@ render(() => {
} }
return ( return (
<AppInterface defaultUrl={data().url} isSidecar> <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
<Inner /> <Inner />
</AppInterface> </AppInterface>
) )

View File

@@ -12,8 +12,18 @@
".": "./src/index.ts", ".": "./src/index.ts",
"./client": "./src/client.ts", "./client": "./src/client.ts",
"./server": "./src/server.ts", "./server": "./src/server.ts",
"./v2": "./src/v2/index.ts", "./v2": {
"./v2/client": "./src/v2/client.ts", "types": "./dist/src/v2/index.d.ts",
"default": "./src/v2/index.ts"
},
"./v2/client": {
"types": "./dist/src/v2/client.d.ts",
"default": "./src/v2/client.ts"
},
"./v2/gen/client": {
"types": "./dist/src/v2/gen/client/index.d.ts",
"default": "./src/v2/gen/client/index.ts"
},
"./v2/server": "./src/v2/server.ts" "./v2/server": "./src/v2/server.ts"
}, },
"files": [ "files": [

View File

@@ -6,8 +6,8 @@
"module": "nodenext", "module": "nodenext",
"declaration": true, "declaration": true,
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"] "lib": ["es2022", "dom", "dom.iterable"],
"composite": true
}, },
"include": ["src"], "include": ["src"]
"exclude": ["src/gen"]
} }