diff options
| author | Brendan Allan <[email protected]> | 2026-02-27 13:26:15 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-27 13:26:15 +0800 |
| commit | 7528419172d126d829f43218024edc5626490e57 (patch) | |
| tree | f8b2383f92e336aee02a7e274aa2e288d75e7cbd /packages/app | |
| parent | 8c739b4a7db455a33fd77b4c997f2a9eaf27648a (diff) | |
| download | opencode-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.tsx | 578 | ||||
| -rw-r--r-- | packages/app/src/components/server/server-row.tsx | 82 | ||||
| -rw-r--r-- | packages/app/src/components/status-popover.tsx | 5 | ||||
| -rw-r--r-- | packages/app/src/context/server.tsx | 50 | ||||
| -rw-r--r-- | packages/app/src/i18n/en.ts | 9 |
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.", |
