dialog-select-server.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
  2. import { createStore, reconcile } from "solid-js/store"
  3. import { useDialog } from "@opencode-ai/ui/context/dialog"
  4. import { Dialog } from "@opencode-ai/ui/dialog"
  5. import { List } from "@opencode-ai/ui/list"
  6. import { Button } from "@opencode-ai/ui/button"
  7. import { IconButton } from "@opencode-ai/ui/icon-button"
  8. import { TextField } from "@opencode-ai/ui/text-field"
  9. import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
  10. import { usePlatform } from "@/context/platform"
  11. import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
  12. import { useNavigate } from "@solidjs/router"
  13. import { useLanguage } from "@/context/language"
  14. import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
  15. import { Tooltip } from "@opencode-ai/ui/tooltip"
  16. import { useGlobalSDK } from "@/context/global-sdk"
  17. import { showToast } from "@opencode-ai/ui/toast"
  18. type ServerStatus = { healthy: boolean; version?: string }
  19. interface AddRowProps {
  20. value: string
  21. placeholder: string
  22. adding: boolean
  23. error: string
  24. status: boolean | undefined
  25. onChange: (value: string) => void
  26. onKeyDown: (event: KeyboardEvent) => void
  27. onBlur: () => void
  28. }
  29. interface EditRowProps {
  30. value: string
  31. placeholder: string
  32. busy: boolean
  33. error: string
  34. status: boolean | undefined
  35. onChange: (value: string) => void
  36. onKeyDown: (event: KeyboardEvent) => void
  37. onBlur: () => void
  38. }
  39. async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
  40. const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
  41. const sdk = createOpencodeClient({
  42. baseUrl: url,
  43. fetch: platform.fetch,
  44. signal,
  45. })
  46. return sdk.global
  47. .health()
  48. .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
  49. .catch(() => ({ healthy: false }))
  50. }
  51. function AddRow(props: AddRowProps) {
  52. return (
  53. <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
  54. <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
  55. <div
  56. classList={{
  57. "size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
  58. "bg-icon-success-base": props.status === true,
  59. "bg-icon-critical-base": props.status === false,
  60. "bg-border-weak-base": props.status === undefined,
  61. }}
  62. ref={(el) => {
  63. // Position relative to input-wrapper
  64. requestAnimationFrame(() => {
  65. const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
  66. if (wrapper instanceof HTMLElement) {
  67. wrapper.appendChild(el)
  68. }
  69. })
  70. }}
  71. />
  72. <TextField
  73. type="text"
  74. hideLabel
  75. placeholder={props.placeholder}
  76. value={props.value}
  77. autofocus
  78. validationState={props.error ? "invalid" : "valid"}
  79. error={props.error}
  80. disabled={props.adding}
  81. onChange={props.onChange}
  82. onKeyDown={props.onKeyDown}
  83. onBlur={props.onBlur}
  84. class="pl-7"
  85. />
  86. </div>
  87. </div>
  88. )
  89. }
  90. function EditRow(props: EditRowProps) {
  91. return (
  92. <div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
  93. <div
  94. classList={{
  95. "size-1.5 rounded-full shrink-0": true,
  96. "bg-icon-success-base": props.status === true,
  97. "bg-icon-critical-base": props.status === false,
  98. "bg-border-weak-base": props.status === undefined,
  99. }}
  100. />
  101. <div class="flex-1 min-w-0">
  102. <TextField
  103. type="text"
  104. hideLabel
  105. placeholder={props.placeholder}
  106. value={props.value}
  107. autofocus
  108. validationState={props.error ? "invalid" : "valid"}
  109. error={props.error}
  110. disabled={props.busy}
  111. onChange={props.onChange}
  112. onKeyDown={props.onKeyDown}
  113. onBlur={props.onBlur}
  114. />
  115. </div>
  116. </div>
  117. )
  118. }
  119. export function DialogSelectServer() {
  120. const navigate = useNavigate()
  121. const dialog = useDialog()
  122. const server = useServer()
  123. const platform = usePlatform()
  124. const globalSDK = useGlobalSDK()
  125. const language = useLanguage()
  126. const [store, setStore] = createStore({
  127. status: {} as Record<string, ServerStatus | undefined>,
  128. addServer: {
  129. url: "",
  130. adding: false,
  131. error: "",
  132. showForm: false,
  133. status: undefined as boolean | undefined,
  134. },
  135. editServer: {
  136. id: undefined as string | undefined,
  137. value: "",
  138. error: "",
  139. busy: false,
  140. status: undefined as boolean | undefined,
  141. },
  142. })
  143. const [defaultUrl, defaultUrlActions] = createResource(
  144. async () => {
  145. try {
  146. const url = await platform.getDefaultServerUrl?.()
  147. if (!url) return null
  148. return normalizeServerUrl(url) ?? null
  149. } catch (err) {
  150. showToast({
  151. variant: "error",
  152. title: language.t("common.requestFailed"),
  153. description: err instanceof Error ? err.message : String(err),
  154. })
  155. return null
  156. }
  157. },
  158. { initialValue: null },
  159. )
  160. const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
  161. const looksComplete = (value: string) => {
  162. const normalized = normalizeServerUrl(value)
  163. if (!normalized) return false
  164. const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
  165. if (!host) return false
  166. if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
  167. return host.includes(".") || host.includes(":")
  168. }
  169. const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
  170. setStatus(undefined)
  171. if (!looksComplete(value)) return
  172. const normalized = normalizeServerUrl(value)
  173. if (!normalized) return
  174. const result = await checkHealth(normalized, platform)
  175. setStatus(result.healthy)
  176. }
  177. const resetAdd = () => {
  178. setStore("addServer", {
  179. url: "",
  180. error: "",
  181. showForm: false,
  182. status: undefined,
  183. })
  184. }
  185. const resetEdit = () => {
  186. setStore("editServer", {
  187. id: undefined,
  188. value: "",
  189. error: "",
  190. status: undefined,
  191. busy: false,
  192. })
  193. }
  194. const replaceServer = (original: string, next: string) => {
  195. const active = server.url
  196. const nextActive = active === original ? next : active
  197. server.add(next)
  198. if (nextActive) server.setActive(nextActive)
  199. server.remove(original)
  200. }
  201. const items = createMemo(() => {
  202. const current = server.url
  203. const list = server.list
  204. if (!current) return list
  205. if (!list.includes(current)) return [current, ...list]
  206. return [current, ...list.filter((x) => x !== current)]
  207. })
  208. const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
  209. const sortedItems = createMemo(() => {
  210. const list = items()
  211. if (!list.length) return list
  212. const active = current()
  213. const order = new Map(list.map((url, index) => [url, index] as const))
  214. const rank = (value?: ServerStatus) => {
  215. if (value?.healthy === true) return 0
  216. if (value?.healthy === false) return 2
  217. return 1
  218. }
  219. return list.slice().sort((a, b) => {
  220. if (a === active) return -1
  221. if (b === active) return 1
  222. const diff = rank(store.status[a]) - rank(store.status[b])
  223. if (diff !== 0) return diff
  224. return (order.get(a) ?? 0) - (order.get(b) ?? 0)
  225. })
  226. })
  227. async function refreshHealth() {
  228. const results: Record<string, ServerStatus> = {}
  229. await Promise.all(
  230. items().map(async (url) => {
  231. results[url] = await checkHealth(url, platform)
  232. }),
  233. )
  234. setStore("status", reconcile(results))
  235. }
  236. createEffect(() => {
  237. items()
  238. refreshHealth()
  239. const interval = setInterval(refreshHealth, 10_000)
  240. onCleanup(() => clearInterval(interval))
  241. })
  242. async function select(value: string, persist?: boolean) {
  243. if (!persist && store.status[value]?.healthy === false) return
  244. dialog.close()
  245. if (persist) {
  246. server.add(value)
  247. navigate("/")
  248. return
  249. }
  250. server.setActive(value)
  251. navigate("/")
  252. }
  253. const handleAddChange = (value: string) => {
  254. if (store.addServer.adding) return
  255. setStore("addServer", { url: value, error: "" })
  256. void previewStatus(value, (next) => setStore("addServer", { status: next }))
  257. }
  258. const scrollListToBottom = () => {
  259. const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
  260. if (!scroll) return
  261. requestAnimationFrame(() => {
  262. scroll.scrollTop = scroll.scrollHeight
  263. })
  264. }
  265. const handleEditChange = (value: string) => {
  266. if (store.editServer.busy) return
  267. setStore("editServer", { value, error: "" })
  268. void previewStatus(value, (next) => setStore("editServer", { status: next }))
  269. }
  270. async function handleAdd(value: string) {
  271. if (store.addServer.adding) return
  272. const normalized = normalizeServerUrl(value)
  273. if (!normalized) {
  274. resetAdd()
  275. return
  276. }
  277. setStore("addServer", { adding: true, error: "" })
  278. const result = await checkHealth(normalized, platform)
  279. setStore("addServer", { adding: false })
  280. if (!result.healthy) {
  281. setStore("addServer", { error: language.t("dialog.server.add.error") })
  282. return
  283. }
  284. resetAdd()
  285. await select(normalized, true)
  286. }
  287. async function handleEdit(original: string, value: string) {
  288. if (store.editServer.busy) return
  289. const normalized = normalizeServerUrl(value)
  290. if (!normalized) {
  291. resetEdit()
  292. return
  293. }
  294. if (normalized === original) {
  295. resetEdit()
  296. return
  297. }
  298. setStore("editServer", { busy: true, error: "" })
  299. const result = await checkHealth(normalized, platform)
  300. setStore("editServer", { busy: false })
  301. if (!result.healthy) {
  302. setStore("editServer", { error: language.t("dialog.server.add.error") })
  303. return
  304. }
  305. replaceServer(original, normalized)
  306. resetEdit()
  307. }
  308. const handleAddKey = (event: KeyboardEvent) => {
  309. event.stopPropagation()
  310. if (event.key !== "Enter" || event.isComposing) return
  311. event.preventDefault()
  312. handleAdd(store.addServer.url)
  313. }
  314. const blurAdd = () => {
  315. if (!store.addServer.url.trim()) {
  316. resetAdd()
  317. return
  318. }
  319. handleAdd(store.addServer.url)
  320. }
  321. const handleEditKey = (event: KeyboardEvent, original: string) => {
  322. event.stopPropagation()
  323. if (event.key === "Escape") {
  324. event.preventDefault()
  325. resetEdit()
  326. return
  327. }
  328. if (event.key !== "Enter" || event.isComposing) return
  329. event.preventDefault()
  330. handleEdit(original, store.editServer.value)
  331. }
  332. async function handleRemove(url: string) {
  333. server.remove(url)
  334. }
  335. return (
  336. <Dialog title={language.t("dialog.server.title")}>
  337. <div class="flex flex-col gap-2">
  338. <List
  339. search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
  340. noInitialSelection
  341. emptyMessage={language.t("dialog.server.empty")}
  342. items={sortedItems}
  343. key={(x) => x}
  344. onSelect={(x) => {
  345. if (x) select(x)
  346. }}
  347. onFilter={(value) => {
  348. if (value && store.addServer.showForm && !store.addServer.adding) {
  349. resetAdd()
  350. }
  351. }}
  352. divider={true}
  353. 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"
  354. add={
  355. store.addServer.showForm
  356. ? {
  357. render: () => (
  358. <AddRow
  359. value={store.addServer.url}
  360. placeholder={language.t("dialog.server.add.placeholder")}
  361. adding={store.addServer.adding}
  362. error={store.addServer.error}
  363. status={store.addServer.status}
  364. onChange={handleAddChange}
  365. onKeyDown={handleAddKey}
  366. onBlur={blurAdd}
  367. />
  368. ),
  369. }
  370. : undefined
  371. }
  372. >
  373. {(i) => {
  374. const [truncated, setTruncated] = createSignal(false)
  375. let nameRef: HTMLSpanElement | undefined
  376. let versionRef: HTMLSpanElement | undefined
  377. const check = () => {
  378. const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
  379. const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
  380. setTruncated(nameTruncated || versionTruncated)
  381. }
  382. createEffect(() => {
  383. check()
  384. window.addEventListener("resize", check)
  385. onCleanup(() => window.removeEventListener("resize", check))
  386. })
  387. const tooltipValue = () => {
  388. const name = serverDisplayName(i)
  389. const version = store.status[i]?.version
  390. return (
  391. <span class="flex items-center gap-2">
  392. <span>{name}</span>
  393. <Show when={version}>
  394. <span class="text-text-invert-base">{version}</span>
  395. </Show>
  396. </span>
  397. )
  398. }
  399. return (
  400. <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
  401. <Show
  402. when={store.editServer.id !== i}
  403. fallback={
  404. <EditRow
  405. value={store.editServer.value}
  406. placeholder={language.t("dialog.server.add.placeholder")}
  407. busy={store.editServer.busy}
  408. error={store.editServer.error}
  409. status={store.editServer.status}
  410. onChange={handleEditChange}
  411. onKeyDown={(event) => handleEditKey(event, i)}
  412. onBlur={() => handleEdit(i, store.editServer.value)}
  413. />
  414. }
  415. >
  416. <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
  417. <div
  418. class="flex items-center gap-3 px-4 min-w-0 flex-1"
  419. classList={{ "opacity-50": store.status[i]?.healthy === false }}
  420. >
  421. <div
  422. classList={{
  423. "size-1.5 rounded-full shrink-0": true,
  424. "bg-icon-success-base": store.status[i]?.healthy === true,
  425. "bg-icon-critical-base": store.status[i]?.healthy === false,
  426. "bg-border-weak-base": store.status[i] === undefined,
  427. }}
  428. />
  429. <span ref={nameRef} class="truncate">
  430. {serverDisplayName(i)}
  431. </span>
  432. <Show when={store.status[i]?.version}>
  433. <span ref={versionRef} class="text-text-weak text-14-regular truncate">
  434. {store.status[i]?.version}
  435. </span>
  436. </Show>
  437. <Show when={defaultUrl() === i}>
  438. <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
  439. {language.t("dialog.server.status.default")}
  440. </span>
  441. </Show>
  442. </div>
  443. </Tooltip>
  444. </Show>
  445. <Show when={store.editServer.id !== i}>
  446. <div class="flex items-center justify-center gap-5 pl-4">
  447. <Show when={current() === i}>
  448. <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
  449. </Show>
  450. <DropdownMenu>
  451. <DropdownMenu.Trigger
  452. as={IconButton}
  453. icon="dot-grid"
  454. variant="ghost"
  455. class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
  456. onClick={(e: MouseEvent) => e.stopPropagation()}
  457. onPointerDown={(e: PointerEvent) => e.stopPropagation()}
  458. />
  459. <DropdownMenu.Portal>
  460. <DropdownMenu.Content class="mt-1">
  461. <DropdownMenu.Item
  462. onSelect={() => {
  463. setStore("editServer", {
  464. id: i,
  465. value: i,
  466. error: "",
  467. status: store.status[i]?.healthy,
  468. })
  469. }}
  470. >
  471. <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
  472. </DropdownMenu.Item>
  473. <Show when={canDefault() && defaultUrl() !== i}>
  474. <DropdownMenu.Item
  475. onSelect={async () => {
  476. try {
  477. await platform.setDefaultServerUrl?.(i)
  478. defaultUrlActions.mutate(i)
  479. } catch (err) {
  480. showToast({
  481. variant: "error",
  482. title: language.t("common.requestFailed"),
  483. description: err instanceof Error ? err.message : String(err),
  484. })
  485. }
  486. }}
  487. >
  488. <DropdownMenu.ItemLabel>
  489. {language.t("dialog.server.menu.default")}
  490. </DropdownMenu.ItemLabel>
  491. </DropdownMenu.Item>
  492. </Show>
  493. <Show when={canDefault() && defaultUrl() === i}>
  494. <DropdownMenu.Item
  495. onSelect={async () => {
  496. try {
  497. await platform.setDefaultServerUrl?.(null)
  498. defaultUrlActions.mutate(null)
  499. } catch (err) {
  500. showToast({
  501. variant: "error",
  502. title: language.t("common.requestFailed"),
  503. description: err instanceof Error ? err.message : String(err),
  504. })
  505. }
  506. }}
  507. >
  508. <DropdownMenu.ItemLabel>
  509. {language.t("dialog.server.menu.defaultRemove")}
  510. </DropdownMenu.ItemLabel>
  511. </DropdownMenu.Item>
  512. </Show>
  513. <DropdownMenu.Separator />
  514. <DropdownMenu.Item
  515. onSelect={() => handleRemove(i)}
  516. class="text-text-on-critical-base hover:bg-surface-critical-weak"
  517. >
  518. <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
  519. </DropdownMenu.Item>
  520. </DropdownMenu.Content>
  521. </DropdownMenu.Portal>
  522. </DropdownMenu>
  523. </div>
  524. </Show>
  525. </div>
  526. )
  527. }}
  528. </List>
  529. <div class="px-5 pb-5">
  530. <Button
  531. variant="secondary"
  532. icon="plus-small"
  533. size="large"
  534. onClick={() => {
  535. setStore("addServer", { showForm: true, url: "", error: "" })
  536. scrollListToBottom()
  537. }}
  538. class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
  539. >
  540. {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
  541. </Button>
  542. </div>
  543. </div>
  544. </Dialog>
  545. )
  546. }