app: refactor server management backend (#13813)
This commit is contained in:
9
.zed/settings.json
Normal file
9
.zed/settings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"format_on_save": "on",
|
||||||
|
"formatter": {
|
||||||
|
"external": {
|
||||||
|
"command": "bunx",
|
||||||
|
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,59 +458,63 @@ 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>
|
||||||
|
|
||||||
<DropdownMenu>
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu>
|
||||||
as={IconButton}
|
<DropdownMenu.Trigger
|
||||||
icon="dot-grid"
|
as={IconButton}
|
||||||
variant="ghost"
|
icon="dot-grid"
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
variant="ghost"
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
/>
|
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||||
<DropdownMenu.Portal>
|
/>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Content class="mt-1">
|
||||||
onSelect={() => {
|
<DropdownMenu.Item
|
||||||
setStore("editServer", {
|
onSelect={() => {
|
||||||
id: i,
|
setStore("editServer", {
|
||||||
value: i,
|
id: i.http.url,
|
||||||
error: "",
|
value: i.http.url,
|
||||||
status: store.status[i]?.healthy,
|
error: "",
|
||||||
})
|
status: store.status[ServerConnection.key(i)]?.healthy,
|
||||||
}}
|
})
|
||||||
>
|
}}
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||||
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.delete")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</DropdownMenu.Content>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
</DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
</DropdownMenu>
|
||||||
<DropdownMenu.ItemLabel>
|
</Show>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => handleRemove(i)}
|
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,24 +12,27 @@ 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) => {
|
||||||
session: {
|
createdClients.push(directory)
|
||||||
create: async () => {
|
return {
|
||||||
createdSessions.push(directory)
|
session: {
|
||||||
return { data: { id: `session-${createdSessions.length}` } }
|
create: async () => {
|
||||||
|
createdSessions.push(directory)
|
||||||
|
return { data: { id: `session-${createdSessions.length}` } }
|
||||||
|
},
|
||||||
|
shell: async () => {
|
||||||
|
sentShell.push(directory)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
prompt: async () => ({ data: undefined }),
|
||||||
|
command: async () => ({ data: undefined }),
|
||||||
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
shell: async () => {
|
worktree: {
|
||||||
sentShell.push(directory)
|
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||||
return { data: undefined }
|
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
}
|
||||||
command: async () => ({ data: undefined }),
|
}
|
||||||
abort: async () => ({ data: undefined }),
|
|
||||||
},
|
|
||||||
worktree: {
|
|
||||||
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: () => {
|
||||||
directory: "/repo/main",
|
const sdk = {
|
||||||
client: rootClient,
|
directory: "/repo/main",
|
||||||
url: "http://localhost:4096",
|
client: rootClient,
|
||||||
}),
|
url: "http://localhost:4096",
|
||||||
|
createClient(opts: any) {
|
||||||
|
return clientFor(opts.directory)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return sdk
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
setState("active", ServerConnection.key(conn))
|
||||||
|
return conn
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(input: string) {
|
function remove(key: ServerConnection.Key) {
|
||||||
const url = normalizeServerUrl(input)
|
const list = store.list.filter((x) => x !== key)
|
||||||
if (!url) return
|
batch(() => {
|
||||||
updateServerList(url, true)
|
setStore("list", list)
|
||||||
|
if (state.active === key) {
|
||||||
|
const next = list[0]
|
||||||
|
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (state.active) return
|
|
||||||
reconcileStartup()
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
22
packages/app/src/utils/server.ts
Normal file
22
packages/app/src/utils/server.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,5 +22,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "package.json"],
|
"include": ["src", "package.json"],
|
||||||
"exclude": ["dist", "ts-dist"]
|
"exclude": ["dist", "ts-dist"],
|
||||||
|
"references": [{ "path": "../sdk/js" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user