Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 110 additions & 65 deletions packages/app/src/components/dialog-select-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis
.catch(() => ({ healthy: false }))
}

export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()
interface ServerSelectorProps {
onSelect?: (url: string) => void
showDialog?: boolean
}

export function ServerSelector(props: ServerSelectorProps) {
const server = useServer()
const platform = usePlatform()
const [store, setStore] = createStore({
Expand Down Expand Up @@ -84,14 +87,9 @@ export function DialogSelectServer() {

function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
dialog.close()
if (persist) {
server.add(value)
navigate("/")
return
}
server.setActive(value)
navigate("/")
if (persist) server.add(value)
if (!persist) server.setActive(value)
props.onSelect?.(value)
}

async function handleSubmit(e: SubmitEvent) {
Expand All @@ -114,66 +112,113 @@ export function DialogSelectServer() {
select(value, true)
}

return (
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
<div class="flex flex-col gap-4 pb-4">
<List
search={{ placeholder: "Search servers", autofocus: true }}
emptyMessage="No servers yet"
items={sortedItems}
key={(x) => x}
current={current()}
onSelect={(x) => {
if (x) select(x)
}}
>
{(i) => (
const content = (
<div class="flex flex-col gap-4 pb-4">
<List
search={{ placeholder: "Search servers", autofocus: true }}
emptyMessage="No servers yet"
items={sortedItems}
key={(x) => x}
current={current()}
onSelect={(x) => {
if (x) select(x)
}}
>
{(i) => (
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
)}
</List>

<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Add a server</h3>
</div>
<form onSubmit={handleSubmit}>
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0 h-auto">
<TextField
type="text"
label="Server URL"
hideLabel
placeholder="http://localhost:4096"
value={store.url}
onChange={(v) => {
setStore("url", v)
setStore("error", "")
}}
validationState={store.error ? "invalid" : "valid"}
error={store.error}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
)}
</List>

<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Add a server</h3>
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
{store.adding ? "Checking..." : "Add"}
</Button>
</div>
<form onSubmit={handleSubmit}>
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0 h-auto">
<TextField
type="text"
label="Server URL"
hideLabel
placeholder="http://localhost:4096"
value={store.url}
onChange={(v) => {
setStore("url", v)
setStore("error", "")
}}
validationState={store.error ? "invalid" : "valid"}
error={store.error}
/>
</div>
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
{store.adding ? "Checking..." : "Add"}
</Button>
</div>
</form>
</div>
</form>
</div>
</div>
)

if (props.showDialog === false) {
return content
}

return (
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
{content}
</Dialog>
)
}

export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()

return (
<ServerSelector
showDialog
onSelect={() => {
dialog.close()
navigate("/")
}}
/>
)
}

interface DialogSelectServerConnectionProps {
url: string
onRetry: () => void
onClose: () => void
}

export function DialogSelectServerConnection(props: DialogSelectServerConnectionProps) {
return (
<Dialog
title="Could not connect to server"
description={`Unable to connect to the OpenCode server at ${props.url}. Select or add a different server.`}
action={<div />}
>
<ServerSelector
showDialog={false}
onSelect={() => {
props.onClose()
props.onRetry()
}}
/>
</Dialog>
)
}
77 changes: 71 additions & 6 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,22 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
import {
batch,
createContext,
createEffect,
useContext,
onCleanup,
onMount,
type ParentProps,
Switch,
Match,
} from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectServerConnection } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"

type State = {
status: "loading" | "partial" | "complete"
Expand Down Expand Up @@ -67,12 +80,14 @@ function createGlobalSync() {
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
error?: InitError
connectionError: boolean
path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
}>({
ready: false,
connectionError: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
Expand Down Expand Up @@ -407,15 +422,13 @@ function createGlobalSync() {
onCleanup(unsub)

async function bootstrap() {
setGlobalStore("connectionError", false)
const health = await globalSDK.client.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
setGlobalStore(
"error",
new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
)
setGlobalStore("connectionError", true)
return
}

Expand Down Expand Up @@ -471,6 +484,9 @@ function createGlobalSync() {
get error() {
return globalStore.error
},
get connectionError() {
return globalStore.connectionError
},
child,
bootstrap,
project: {
Expand All @@ -481,15 +497,64 @@ function createGlobalSync() {

const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()

function ConnectionErrorOverlay(props: { url: string; onRetry: () => void }) {
const dialog = useDialog()

onMount(() => {
dialog.show(
() => <DialogSelectServerConnection url={props.url} onRetry={props.onRetry} onClose={() => dialog.close()} />,
{ preventClose: true },
)
})

return <div class="h-screen w-screen flex items-center justify-center bg-background-base" />
}

function ConnectionWatcher(props: { onRetry: () => void }) {
const server = useServer()
const globalSDK = useGlobalSDK()
const dialog = useDialog()

createEffect((prev: boolean | undefined) => {
const current = server.healthy()
if (prev === true && current === false) {
dialog.show(
() => (
<DialogSelectServerConnection
url={globalSDK.url}
onRetry={() => {
dialog.close()
props.onRetry()
}}
onClose={() => dialog.close()}
/>
),
{ preventClose: true },
)
}
return current
})

return null
}

export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
const globalSDK = useGlobalSDK()

return (
<Switch>
<Match when={value.connectionError}>
<ConnectionErrorOverlay url={globalSDK.url} onRetry={value.bootstrap} />
</Match>
<Match when={value.error}>
<ErrorPage error={value.error} />
</Match>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
<GlobalSyncContext.Provider value={value}>
<ConnectionWatcher onRetry={value.bootstrap} />
{props.children}
</GlobalSyncContext.Provider>
</Match>
</Switch>
)
Expand Down
5 changes: 1 addition & 4 deletions packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ export default function Home() {
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
dialog.show(() => <DialogSelectDirectory multiple={true} onSelect={resolve} />, { onClose: () => resolve(null) })
}
}

Expand Down
5 changes: 1 addition & 4 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -691,10 +691,7 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
dialog.show(() => <DialogSelectDirectory multiple={true} onSelect={resolve} />, { onClose: () => resolve(null) })
}
}

Expand Down
Loading