app: allow providing username and password when connecting to remote server (#14872)
This commit is contained in:
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
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 { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
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 { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface AddRowProps {
|
interface ServerFormProps {
|
||||||
value: string
|
|
||||||
placeholder: string
|
|
||||||
adding: boolean
|
|
||||||
error: string
|
|
||||||
status: boolean | undefined
|
|
||||||
onChange: (value: string) => void
|
|
||||||
onKeyDown: (event: KeyboardEvent) => void
|
|
||||||
onBlur: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditRowProps {
|
|
||||||
value: string
|
value: string
|
||||||
|
name: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
placeholder: string
|
placeholder: string
|
||||||
busy: boolean
|
busy: boolean
|
||||||
error: string
|
error: string
|
||||||
status: boolean | undefined
|
status: boolean | undefined
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onKeyDown: (event: KeyboardEvent) => void
|
onNameChange: (value: string) => void
|
||||||
onBlur: () => void
|
onUsernameChange: (value: string) => void
|
||||||
|
onPasswordChange: (value: string) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
@@ -83,73 +79,47 @@ function useServerPreview(fetcher: typeof fetch) {
|
|||||||
return host.includes(".") || host.includes(":")
|
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)
|
setStatus(undefined)
|
||||||
if (!looksComplete(value)) return
|
if (!looksComplete(value)) return
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return
|
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)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { previewStatus }
|
return { previewStatus }
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function ServerForm(props: ServerFormProps) {
|
||||||
return (
|
const language = useLanguage()
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
event.stopPropagation()
|
||||||
<div
|
if (event.key === "Escape") {
|
||||||
classList={{
|
event.preventDefault()
|
||||||
"size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
|
props.onBack()
|
||||||
"bg-icon-success-base": props.status === true,
|
return
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
})
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
}}
|
event.preventDefault()
|
||||||
/>
|
props.onSubmit()
|
||||||
<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 EditRow(props: EditRowProps) {
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
|
<div class="px-5">
|
||||||
<div
|
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
|
||||||
classList={{
|
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||||
"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">
|
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
hideLabel
|
label={language.t("dialog.server.add.url")}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
autofocus
|
autofocus
|
||||||
@@ -157,10 +127,39 @@ function EditRow(props: EditRowProps) {
|
|||||||
error={props.error}
|
error={props.error}
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
onKeyDown={props.onKeyDown}
|
onKeyDown={keyDown}
|
||||||
onBlur={props.onBlur}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -174,11 +173,13 @@ export function DialogSelectServer() {
|
|||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
let listRoot: HTMLDivElement | undefined
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
@@ -187,6 +188,9 @@ export function DialogSelectServer() {
|
|||||||
editServer: {
|
editServer: {
|
||||||
id: undefined as string | undefined,
|
id: undefined as string | undefined,
|
||||||
value: "",
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
busy: false,
|
busy: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
@@ -196,27 +200,32 @@ export function DialogSelectServer() {
|
|||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
url: "",
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetEdit = () => {
|
const resetEdit = () => {
|
||||||
setStore("editServer", {
|
setStore("editServer", {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
value: "",
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
busy: false,
|
busy: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: string) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
if (!newConn) return
|
if (!newConn) return
|
||||||
|
|
||||||
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
||||||
if (nextActive) server.setActive(nextActive)
|
if (nextActive) server.setActive(nextActive)
|
||||||
server.remove(ServerConnection.key(original))
|
server.remove(ServerConnection.key(original))
|
||||||
@@ -271,8 +280,8 @@ export function DialogSelectServer() {
|
|||||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
if (persist) {
|
if (persist && conn.type === "http") {
|
||||||
server.add(conn.http.url)
|
server.add(conn)
|
||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,21 +292,59 @@ export function DialogSelectServer() {
|
|||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
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 handleAddNameChange = (value: string) => {
|
||||||
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
if (store.addServer.adding) return
|
||||||
if (!scroll) return
|
setStore("addServer", { name: value, error: "" })
|
||||||
requestAnimationFrame(() => {
|
}
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
|
||||||
})
|
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) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { value, error: "" })
|
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) {
|
async function handleAdd(value: string) {
|
||||||
@@ -310,16 +357,22 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
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 })
|
setStore("addServer", { adding: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
await select({ type: "http", http: { url: normalized } }, true)
|
await select(conn, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||||
@@ -330,53 +383,115 @@ export function DialogSelectServer() {
|
|||||||
return
|
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()
|
resetEdit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
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 })
|
setStore("editServer", { busy: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (normalized === original.http.url) {
|
||||||
replaceServer(original, normalized)
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
resetEdit()
|
resetEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddKey = (event: KeyboardEvent) => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
event.stopPropagation()
|
if (store.editServer.id) return "edit"
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
if (store.addServer.showForm) return "add"
|
||||||
event.preventDefault()
|
return "list"
|
||||||
handleAdd(store.addServer.url)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const blurAdd = () => {
|
const editing = createMemo(() => {
|
||||||
if (!store.addServer.url.trim()) {
|
if (!store.editServer.id) return
|
||||||
|
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
resetAdd()
|
resetAdd()
|
||||||
return
|
resetEdit()
|
||||||
}
|
|
||||||
handleAdd(store.addServer.url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
|
const startAdd = () => {
|
||||||
event.stopPropagation()
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault()
|
|
||||||
resetEdit()
|
resetEdit()
|
||||||
|
setStore("addServer", {
|
||||||
|
showForm: true,
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (conn: ServerConnection.Http) => {
|
||||||
|
resetAdd()
|
||||||
|
setStore("editServer", {
|
||||||
|
id: conn.http.url,
|
||||||
|
value: conn.http.url,
|
||||||
|
name: conn.displayName ?? "",
|
||||||
|
username: conn.http.username ?? "",
|
||||||
|
password: conn.http.password ?? "",
|
||||||
|
error: "",
|
||||||
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
|
busy: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
if (mode() === "add") {
|
||||||
|
void handleAdd(store.addServer.url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
const original = editing()
|
||||||
event.preventDefault()
|
if (!original) return
|
||||||
handleEdit(original, store.editServer.value)
|
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) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||||
@@ -385,9 +500,29 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.server.title")}>
|
<Dialog title={formTitle()}>
|
||||||
<div class="flex flex-col gap-2">
|
<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
|
<List
|
||||||
search={{
|
search={{
|
||||||
placeholder: language.t("dialog.server.search.placeholder"),
|
placeholder: language.t("dialog.server.search.placeholder"),
|
||||||
@@ -400,69 +535,33 @@ export function DialogSelectServer() {
|
|||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) select(x)
|
if (x) select(x)
|
||||||
}}
|
}}
|
||||||
onFilter={(value) => {
|
|
||||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
|
||||||
resetAdd()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
divider={true}
|
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"
|
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"
|
||||||
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
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
const key = ServerConnection.key(i)
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<Show
|
<div class="flex flex-col h-full items-start w-5">
|
||||||
when={store.editServer.id !== i.http.url}
|
<ServerHealthIndicator health={store.status[key]} />
|
||||||
fallback={
|
</div>
|
||||||
<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
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
status={store.status[key]}
|
|
||||||
dimmed={store.status[key]?.healthy === false}
|
dimmed={store.status[key]?.healthy === false}
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
status={store.status[key]}
|
||||||
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
<Show when={defaultUrl() === i.http.url}>
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
showCredentials
|
||||||
/>
|
/>
|
||||||
</Show>
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<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}>
|
<Show when={ServerConnection.key(current()) === key}>
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={i.type === "http"}>
|
<Show when={i.type === "http"}>
|
||||||
@@ -479,12 +578,8 @@ export function DialogSelectServer() {
|
|||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setStore("editServer", {
|
if (i.type !== "http") return
|
||||||
id: i.http.url,
|
startEdit(i)
|
||||||
value: i.http.url,
|
|
||||||
error: "",
|
|
||||||
status: store.status[ServerConnection.key(i)]?.healthy,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
@@ -508,35 +603,42 @@ export function DialogSelectServer() {
|
|||||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.delete")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</Show>
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
|
<Show
|
||||||
|
when={isFormMode()}
|
||||||
|
fallback={
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => {
|
onClick={startAdd}
|
||||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
|
||||||
scrollListToBottom()
|
|
||||||
}}
|
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
{language.t("dialog.server.add.button")}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import {
|
import {
|
||||||
|
children,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { type ServerConnection, serverDisplayName } from "@/context/server"
|
import { type ServerConnection, serverName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface ServerRowProps extends ParentProps {
|
interface ServerRowProps extends ParentProps {
|
||||||
@@ -20,13 +21,14 @@ interface ServerRowProps extends ParentProps {
|
|||||||
versionClass?: string
|
versionClass?: string
|
||||||
dimmed?: boolean
|
dimmed?: boolean
|
||||||
badge?: JSXElement
|
badge?: JSXElement
|
||||||
|
showCredentials?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerRow(props: ServerRowProps) {
|
export function ServerRow(props: ServerRowProps) {
|
||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
let nameRef: HTMLSpanElement | undefined
|
let nameRef: HTMLSpanElement | undefined
|
||||||
let versionRef: HTMLSpanElement | undefined
|
let versionRef: HTMLSpanElement | undefined
|
||||||
const name = createMemo(() => serverDisplayName(props.conn))
|
const name = createMemo(() => serverName(props.conn))
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
@@ -52,35 +54,71 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span>{name()}</span>
|
<span>{serverName(props.conn, true)}</span>
|
||||||
<Show when={props.status?.version}>
|
<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>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const badge = children(() => props.badge)
|
||||||
|
|
||||||
return (
|
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 class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||||
<div
|
<div class="flex flex-col items-start">
|
||||||
classList={{
|
<div class="flex flex-row items-center gap-2">
|
||||||
"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"}>
|
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||||
{name()}
|
{name()}
|
||||||
</span>
|
</span>
|
||||||
|
<Show
|
||||||
|
when={badge()}
|
||||||
|
fallback={
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||||
{props.status?.version}
|
v{props.status?.version}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
{props.badge}
|
}
|
||||||
|
>
|
||||||
|
{(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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
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 { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -276,10 +276,11 @@ export function StatusPopover() {
|
|||||||
navigate("/")
|
navigate("/")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ServerHealthIndicator health={health[key]} />
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={s}
|
conn={s}
|
||||||
status={health[key]}
|
|
||||||
dimmed={isBlocked()}
|
dimmed={isBlocked()}
|
||||||
|
status={health[key]}
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { checkServerHealth } from "@/utils/server-health"
|
import { checkServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
|
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
export function normalizeServerUrl(input: string) {
|
export function normalizeServerUrl(input: string) {
|
||||||
@@ -15,9 +16,9 @@ export function normalizeServerUrl(input: string) {
|
|||||||
return withProtocol.replace(/\/+$/, "")
|
return withProtocol.replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverDisplayName(conn?: ServerConnection.Any) {
|
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
|
||||||
if (!conn) return ""
|
if (!conn) return ""
|
||||||
if (conn.displayName) return conn.displayName
|
if (conn.displayName && !ignoreDisplayName) return conn.displayName
|
||||||
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,22 +101,33 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
Persist.global("server", ["server.v3"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as string[],
|
list: [] as StoredServer[],
|
||||||
projects: {} as Record<string, StoredProject[]>,
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
lastProject: {} as Record<string, string>,
|
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 allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||||
const servers = [
|
const servers = [
|
||||||
...(props.servers ?? []),
|
...(props.servers ?? []),
|
||||||
...store.list.map((value) => ({
|
...store.list.map((value) =>
|
||||||
|
typeof value === "string"
|
||||||
|
? {
|
||||||
type: "http" as const,
|
type: "http" as const,
|
||||||
http: typeof value === "string" ? { url: value } : value,
|
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()]
|
return [...deduped.values()]
|
||||||
})
|
})
|
||||||
@@ -156,27 +168,29 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
if (state.active !== input) setState("active", input)
|
if (state.active !== input) setState("active", input)
|
||||||
}
|
}
|
||||||
|
|
||||||
function add(input: string) {
|
function add(input: ServerConnection.Http) {
|
||||||
const url = normalizeServerUrl(input)
|
const url_ = normalizeServerUrl(input.http.url)
|
||||||
if (!url) return
|
if (!url_) return
|
||||||
|
const conn = { ...input, http: { ...input.http, url: url_ } }
|
||||||
return batch(() => {
|
return batch(() => {
|
||||||
const http: ServerConnection.HttpBase = { url }
|
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||||
if (!store.list.includes(url)) {
|
if (existing !== -1) {
|
||||||
setStore("list", store.list.length, url)
|
setStore("list", existing, conn)
|
||||||
|
} else {
|
||||||
|
setStore("list", store.list.length, conn)
|
||||||
}
|
}
|
||||||
const conn: ServerConnection.Http = { type: "http", http }
|
|
||||||
setState("active", ServerConnection.key(conn))
|
setState("active", ServerConnection.key(conn))
|
||||||
return conn
|
return conn
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(key: ServerConnection.Key) {
|
function remove(key: ServerConnection.Key) {
|
||||||
const list = store.list.filter((x) => x !== key)
|
const list = store.list.filter((x) => url(x) !== key)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("list", list)
|
setStore("list", list)
|
||||||
if (state.active === key) {
|
if (state.active === key) {
|
||||||
const next = list[0]
|
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
|
return state.active
|
||||||
},
|
},
|
||||||
get name() {
|
get name() {
|
||||||
return serverDisplayName(current())
|
return serverName(current())
|
||||||
},
|
},
|
||||||
get list() {
|
get list() {
|
||||||
return allServers()
|
return allServers()
|
||||||
|
|||||||
@@ -309,12 +309,17 @@ export const dict = {
|
|||||||
"dialog.server.description": "Switch which OpenCode server this app connects to.",
|
"dialog.server.description": "Switch which OpenCode server this app connects to.",
|
||||||
"dialog.server.search.placeholder": "Search servers",
|
"dialog.server.search.placeholder": "Search servers",
|
||||||
"dialog.server.empty": "No servers yet",
|
"dialog.server.empty": "No servers yet",
|
||||||
"dialog.server.add.title": "Add a server",
|
"dialog.server.add.title": "Add server",
|
||||||
"dialog.server.add.url": "Server URL",
|
"dialog.server.add.url": "Server address",
|
||||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||||
"dialog.server.add.error": "Could not connect to server",
|
"dialog.server.add.error": "Could not connect to server",
|
||||||
"dialog.server.add.checking": "Checking...",
|
"dialog.server.add.checking": "Checking...",
|
||||||
"dialog.server.add.button": "Add server",
|
"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.title": "Default server",
|
||||||
"dialog.server.default.description":
|
"dialog.server.default.description":
|
||||||
"Connect to this server on app launch instead of starting a local server. Requires restart.",
|
"Connect to this server on app launch instead of starting a local server. Requires restart.",
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
[data-slot="dialog-header"] {
|
[data-slot="dialog-header"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 20px;
|
padding: 16px 20px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user