-
Notifications
You must be signed in to change notification settings - Fork 2
refactor(app): extract server dialog owners #680
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3282e5f
refactor(app): extract server dialog owners
Astro-Han 5e49bc8
fix(app): address server dialog review feedback
Astro-Han 562c6e4
Merge remote-tracking branch 'origin/dev' into codex/dialog-select-se…
Astro-Han 814847b
fix(app): harden server dialog extraction
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
78 changes: 78 additions & 0 deletions
78
packages/app/src/components/dialog-select-server-default.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
118 changes: 118 additions & 0 deletions
118
packages/app/src/components/dialog-select-server-list.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| 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() | ||
|
|
||
| return ( | ||
| <List | ||
| search={{ | ||
| placeholder: language.t("dialog.server.search.placeholder"), | ||
| autofocus: false, | ||
| }} | ||
| noInitialSelection | ||
| emptyMessage={language.t("dialog.server.empty")} | ||
| items={props.items} | ||
| key={(x) => x.http.url} | ||
| 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-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" | ||
|
Astro-Han marked this conversation as resolved.
Outdated
|
||
| > | ||
| {(i) => { | ||
| const key = ServerConnection.key(i) | ||
| const active = props.current() | ||
| 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={active && ServerConnection.key(active) === 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> | ||
| ) | ||
| } | ||
41 changes: 41 additions & 0 deletions
41
packages/app/src/components/dialog-select-server-source.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| 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") | ||
| }) | ||
|
|
||
| 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) | ||
| } | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.