summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-02-27 13:26:15 +0800
committerGitHub <[email protected]>2026-02-27 13:26:15 +0800
commit7528419172d126d829f43218024edc5626490e57 (patch)
treef8b2383f92e336aee02a7e274aa2e288d75e7cbd /packages/app
parent8c739b4a7db455a33fd77b4c997f2a9eaf27648a (diff)
downloadopencode-7528419172d126d829f43218024edc5626490e57.tar.gz
opencode-7528419172d126d829f43218024edc5626490e57.zip
app: allow providing username and password when connecting to remote server (#14872)
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/components/dialog-select-server.tsx578
-rw-r--r--packages/app/src/components/server/server-row.tsx82
-rw-r--r--packages/app/src/components/status-popover.tsx5
-rw-r--r--packages/app/src/context/server.tsx50
-rw-r--r--packages/app/src/i18n/en.ts9
5 files changed, 442 insertions, 282 deletions
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index 76c8ff60e..4813ecc45 100644
--- a/packages/app/src/components/dialog-select-server.tsx
+++ b/packages/app/src/components/dialog-select-server.tsx
@@ -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
}
+ if (normalized === original.http.url) {
+ server.add(conn)
+ } else {
+ replaceServer(original, conn)
+ }
+
+ resetEdit()
+ }
+
+ const mode = createMemo<"list" | "add" | "edit">(() => {
+ if (store.editServer.id) return "edit"
+ if (store.addServer.showForm) return "add"
+ return "list"
+ })
- replaceServer(original, normalized)
+ 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 handleAddKey = (event: KeyboardEvent) => {
- event.stopPropagation()
- if (event.key !== "Enter" || event.isComposing) return
- event.preventDefault()
- handleAdd(store.addServer.url)
+ const startAdd = () => {
+ resetEdit()
+ setStore("addServer", {
+ showForm: true,
+ url: "",
+ name: "",
+ username: "",
+ password: "",
+ error: "",
+ status: undefined,
+ })
}
- const blurAdd = () => {
- if (!store.addServer.url.trim()) {
- resetAdd()
- return
- }
- handleAdd(store.addServer.url)
+ 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 handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
- event.stopPropagation()
- if (event.key === "Escape") {
- event.preventDefault()
- resetEdit()
+ 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>
-
- <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>
+ }
+ 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={() => {
+ 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.default")}
+ </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>
+ <Show when={canDefault() && defaultUrl() === i.http.url}>
+ <DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
- {language.t("dialog.server.menu.delete")}
+ {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </Show>
- </div>
- </Show>
+ </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>
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx
index 12dcebfa9..5bb290ec3 100644
--- a/packages/app/src/components/server/server-row.tsx
+++ b/packages/app/src/components/server/server-row.tsx
@@ -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,
+ }}
+ />
+ )
+}
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index a846385e7..b441d1c5e 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -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"
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 3849bb6ae..4ff777e2e 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -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()
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 0b7a2e280..e13d407af 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -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.",