app: allow providing username and password when connecting to remote server (#14872)

This commit is contained in:
Brendan Allan
2026-02-27 13:26:15 +08:00
committed by GitHub
parent 8c739b4a7d
commit 7528419172
6 changed files with 444 additions and 284 deletions

View File

@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -9,32 +10,27 @@ 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 { ServerHealthIndicator, 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"
interface AddRowProps {
value: string
placeholder: string
adding: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
interface EditRowProps {
interface ServerFormProps {
value: string
name: string
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onBack: () => void
}
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
@@ -83,83 +79,86 @@ function useServerPreview(fetcher: typeof fetch) {
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth({ url: normalized }, fetcher)
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy)
}
return { previewStatus }
}
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<div
classList={{
"size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
ref={(el) => {
// Position relative to input-wrapper
requestAnimationFrame(() => {
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
if (wrapper instanceof HTMLElement) {
wrapper.appendChild(el)
}
})
}}
/>
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.adding}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
class="pl-7"
/>
</div>
</div>
)
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.onBack()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
props.onSubmit()
}
function EditRow(props: EditRowProps) {
return (
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<div class="px-5">
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
type="text"
label={language.t("dialog.server.add.url")}
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={keyDown}
/>
</div>
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
onChange={props.onNameChange}
onKeyDown={keyDown}
/>
<div class="grid grid-cols-2 gap-2 min-w-0">
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder="username"
value={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder="password"
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
)
@@ -174,11 +173,13 @@ export function DialogSelectServer() {
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
name: "",
username: "",
password: "",
adding: false,
error: "",
showForm: false,
@@ -187,6 +188,9 @@ export function DialogSelectServer() {
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
@@ -196,27 +200,32 @@ export function DialogSelectServer() {
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: "",
password: "",
adding: false,
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
busy: false,
})
}
const replaceServer = (original: ServerConnection.Http, next: string) => {
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original))
@@ -271,8 +280,8 @@ export function DialogSelectServer() {
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist) {
server.add(conn.http.url)
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
@@ -283,21 +292,59 @@ export function DialogSelectServer() {
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, (next) => setStore("addServer", { status: next }))
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const scrollListToBottom = () => {
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
})
const handleAddNameChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, (next) => setStore("editServer", { status: next }))
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
async function handleAdd(value: string) {
@@ -310,16 +357,22 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher)
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
const result = await checkServerHealth(conn.http, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select({ type: "http", http: { url: normalized } }, true)
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
@@ -330,53 +383,115 @@ export function DialogSelectServer() {
return
}
if (normalized === original.http.url) {
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher)
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
replaceServer(original, normalized)
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const handleAddKey = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleAdd(store.addServer.url)
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const blurAdd = () => {
if (!store.addServer.url.trim()) {
resetAdd()
return
}
handleAdd(store.addServer.url)
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
resetEdit()
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
})
}
const submitForm = () => {
if (mode() === "add") {
void handleAdd(store.addServer.url)
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleEdit(original, store.editServer.value)
const original = editing()
if (!original) return
void handleEdit(original, store.editServer.value)
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
@@ -385,9 +500,29 @@ export function DialogSelectServer() {
}
return (
<Dialog title={language.t("dialog.server.title")}>
<Dialog title={formTitle()}>
<div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}>
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
@@ -400,143 +535,110 @@ export function DialogSelectServer() {
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i.http.url}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<ServerRow
conn={i}
status={store.status[key]}
dimmed={store.status[key]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i.http.url,
value: i.http.url,
error: "",
status: store.status[ServerConnection.key(i)]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<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"
>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</Show>
</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>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</div>
</Show>
<div class="px-5 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
<Show
when={isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
>
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
</div>
</div>
</Dialog>

View File

@@ -1,5 +1,6 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
children,
createEffect,
createMemo,
createSignal,
@@ -9,7 +10,7 @@ import {
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
@@ -20,13 +21,14 @@ interface ServerRowProps extends ParentProps {
versionClass?: string
dimmed?: boolean
badge?: JSXElement
showCredentials?: boolean
}
export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.conn))
const name = createMemo(() => serverName(props.conn))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -52,35 +54,71 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{name()}</span>
<span>{serverName(props.conn, true)}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-base">{props.status?.version}</span>
<span class="text-text-invert-weak">v{props.status?.version}</span>
</Show>
</span>
)
const badge = children(() => props.badge)
return (
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<Tooltip
class="flex-1"
value={tooltipValue()}
placement="top-start"
inactive={!truncated() && !props.conn.displayName}
>
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status?.healthy === true,
"bg-icon-critical-base": props.status?.healthy === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()}
</span>
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
{props.status?.version}
</span>
</Show>
{props.badge}
<div class="flex flex-col items-start">
<div class="flex flex-row items-center gap-2">
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()}
</span>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
v{props.status?.version}
</span>
</Show>
}
>
{(badge) => badge()}
</Show>
</div>
<Show when={props.showCredentials && props.conn.type === "http" && props.conn}>
{(conn) => (
<div class="flex flex-row gap-3">
<span>
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
) : (
<span class="text-text-weaker">no username</span>
)}
</span>
{conn().http.password && <span class="text-text-weak"></span>}
</div>
)}
</Show>
</div>
{props.children}
</div>
</Tooltip>
)
}
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return (
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
)
}

View File

@@ -8,7 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
@@ -276,10 +276,11 @@ export function StatusPopover() {
navigate("/")
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
status={health[key]}
dimmed={isBlocked()}
status={health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"

View File

@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
@@ -15,9 +16,9 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "")
}
export function serverDisplayName(conn?: ServerConnection.Any) {
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
if (!conn) return ""
if (conn.displayName) return conn.displayName
if (conn.displayName && !ignoreDisplayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
@@ -100,22 +101,33 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
]
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
return [...deduped.values()]
})
@@ -156,27 +168,29 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn = { ...input, http: { ...input.http, url: url_ } }
return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
setStore("list", store.list.length, conn)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => x !== key)
const list = store.list.filter((x) => url(x) !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
}
})
}
@@ -212,7 +226,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return state.active
},
get name() {
return serverDisplayName(current())
return serverName(current())
},
get list() {
return allServers()

View File

@@ -309,12 +309,17 @@ export const dict = {
"dialog.server.description": "Switch which OpenCode server this app connects to.",
"dialog.server.search.placeholder": "Search servers",
"dialog.server.empty": "No servers yet",
"dialog.server.add.title": "Add a server",
"dialog.server.add.url": "Server URL",
"dialog.server.add.title": "Add server",
"dialog.server.add.url": "Server address",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Could not connect to server",
"dialog.server.add.checking": "Checking...",
"dialog.server.add.button": "Add server",
"dialog.server.add.name": "Server name (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Username (optional)",
"dialog.server.add.password": "Password (optional)",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
"Connect to this server on app launch instead of starting a local server. Requires restart.",

View File

@@ -54,7 +54,7 @@
[data-slot="dialog-header"] {
display: flex;
padding: 20px;
padding: 16px 20px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;