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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/app/src/components/dialog-select-server-default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createMemo, createResource } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import { useCheckServerHealth } from "@/utils/server-health"

export const DEFAULT_USERNAME = "opencode"

function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}

export function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)

const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}

return { defaultKey, canDefault, setDefault }
}

export function useServerPreview() {
const checkServerHealth = useCheckServerHealth()

const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}

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 http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}

return { previewStatus }
}
84 changes: 84 additions & 0 deletions packages/app/src/components/dialog-select-server-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { useLanguage } from "@/context/language"

export interface ServerFormProps {
value: string
name: string
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onBack: () => void
}

export 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()
}

return (
<div class="px-5">
<div class="bg-surface-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"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
disabled={props.busy}
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={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
)
}
121 changes: 121 additions & 0 deletions packages/app/src/components/dialog-select-server-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { type ServerHealth } from "@/utils/server-health"

interface ServerConnectionListProps {
items: () => ServerConnection.Any[]
current: () => ServerConnection.Any | undefined
status: Record<ServerConnection.Key, ServerHealth | undefined>
defaultKey: () => ServerConnection.Key | null | undefined
canDefault: () => boolean
setDefault: (key: ServerConnection.Key | null) => void | Promise<void>
onEdit: (conn: ServerConnection.Http) => void
onRemove: (key: ServerConnection.Key) => void | Promise<void>
onSelect: (conn: ServerConnection.Any) => void | Promise<void>
}

export function ServerConnectionList(props: ServerConnectionListProps) {
const language = useLanguage()
const currentKey = () => {
const current = props.current()
return current ? ServerConnection.key(current) : undefined
}

return (
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={props.items}
key={ServerConnection.key}
onSelect={(x) => {
if (x) props.onSelect(x)
}}
divider={true}
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-base [&_[data-slot=list-items]]:rounded-[var(--radius-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 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={props.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={props.status[key]?.healthy === false}
status={props.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.defaultKey() === ServerConnection.key(i)}>
<span class="text-fg-base bg-surface-base text-body px-1.5 rounded-sm">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={currentKey() === 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-row-active-overlay 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
props.onEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.canDefault() && props.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.setDefault(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.canDefault() && props.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.onRemove(ServerConnection.key(i))}
class="text-error-text hover:bg-error-bg"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
)
}
53 changes: 53 additions & 0 deletions packages/app/src/components/dialog-select-server-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"

const dialogSource = readFileSync(new URL("./dialog-select-server.tsx", import.meta.url), "utf8")
const defaultsSource = readFileSync(new URL("./dialog-select-server-default.ts", import.meta.url), "utf8")
const formSource = readFileSync(new URL("./dialog-select-server-form.tsx", import.meta.url), "utf8")
const listSource = readFileSync(new URL("./dialog-select-server-list.tsx", import.meta.url), "utf8")

describe("dialog-select-server source boundary", () => {
test("keeps server form, list, and preview/default hooks in their owner files", () => {
expect(dialogSource).toContain("ServerForm")
expect(dialogSource).toContain("ServerConnectionList")
expect(dialogSource).toContain("useDefaultServer")
expect(dialogSource).toContain("useServerPreview")
expect(defaultsSource).toContain("export function useDefaultServer")
expect(defaultsSource).toContain("export function useServerPreview")
expect(formSource).toContain("export function ServerForm")
expect(listSource).toContain("export function ServerConnectionList")

for (const ownerImplementation of [
"export function useDefaultServer",
"export function useServerPreview",
"export function ServerForm",
"export function ServerConnectionList",
"createResource",
"TextField",
"DropdownMenu",
]) {
expect(dialogSource).not.toContain(ownerImplementation)
}
})

test("preserves add/edit fields and list menu actions after extraction", () => {
for (const key of [
"dialog.server.add.url",
"dialog.server.add.name",
"dialog.server.add.username",
"dialog.server.add.password",
]) {
expect(formSource).toContain(key)
}

for (const key of [
"dialog.server.menu.edit",
"dialog.server.menu.default",
"dialog.server.menu.defaultRemove",
"dialog.server.menu.delete",
"dialog.server.status.default",
]) {
expect(listSource).toContain(key)
}
})
})
Loading
Loading