From 7528419172d126d829f43218024edc5626490e57 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 27 Feb 2026 13:26:15 +0800 Subject: app: allow providing username and password when connecting to remote server (#14872) --- .../app/src/components/dialog-select-server.tsx | 578 ++++++++++++--------- packages/app/src/components/server/server-row.tsx | 82 ++- packages/app/src/components/status-popover.tsx | 5 +- 3 files changed, 403 insertions(+), 262 deletions(-) (limited to 'packages/app/src/components') 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, 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 ( -
-
-
{ - // Position relative to input-wrapper - requestAnimationFrame(() => { - const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]') - if (wrapper instanceof HTMLElement) { - wrapper.appendChild(el) - } - }) - }} - /> - -
-
- ) -} +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 ( -
event.stopPropagation()}> -
-
+
+
+
+ +
+
+ + +
) @@ -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, 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('[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 ( +
+ + {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} +
+ ) + }) + + 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 ( - +
-
(listRoot = el)}> + + } + > { 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: () => ( - - ), - } - : 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 ( -
- handleEditKey(event, i)} - onBlur={() => handleEdit(i, store.editServer.value)} - /> - } - > - - - {language.t("dialog.server.status.default")} - - - } - /> - - -
- -

{language.t("dialog.server.current")}

+
+
+ +
+ + + {language.t("dialog.server.status.default")} + - - - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - setStore("editServer", { - id: i.http.url, - value: i.http.url, - error: "", - status: store.status[ServerConnection.key(i)]?.healthy, - }) - }} - > - {language.t("dialog.server.menu.edit")} + } + showCredentials + /> +
+ + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + setDefault(i.http.url)}> + + {language.t("dialog.server.menu.default")} + - - setDefault(i.http.url)}> - - {language.t("dialog.server.menu.default")} - - - - - setDefault(null)}> - - {language.t("dialog.server.menu.defaultRemove")} - - - - - handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > + + + setDefault(null)}> - {language.t("dialog.server.menu.delete")} + {language.t("dialog.server.menu.defaultRemove")} - - - - -
-
+ + + handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + + + + +
) }} -
+
- + } > - {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")} - + +
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 = () => ( - {name()} + {serverName(props.conn, true)} - {props.status?.version} + v{props.status?.version} ) + const badge = children(() => props.badge) + return ( - +
-
- - {name()} - - - - {props.status?.version} - - - {props.badge} +
+
+ + {name()} + + + + v{props.status?.version} + + + } + > + {(badge) => badge()} + +
+ + {(conn) => ( +
+ + {conn().http.username ? ( + {conn().http.username} + ) : ( + no username + )} + + {conn().http.password && ••••••••} +
+ )} +
+
{props.children}
) } + +export function ServerHealthIndicator(props: { health?: ServerHealth }) { + return ( +
+ ) +} 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("/") }} > +