feat(app): flatten settings with a server switcher

Settings is one flat section list with the per-server sections first: Providers, Models, Extensions, Projects, and Worktrees, then the app sections, Servers, and About.

With more than one server, per-server pages show a server pill in the title row. It defaults to the server of the tab settings opened from and carries across sections through ?server=. An unreachable server stays selectable and its pages offer Retry and Edit server.

The separate per-server settings screen is gone; old server links open the flat pages with that server selected. The Servers page shows one card per server with its connection and terminal shell.
This commit is contained in:
Dax Raad
2026-10-19 11:05:57 -04:00
Unverified
parent 03d185b697
commit cb07a38a91
28 changed files with 893 additions and 558 deletions
@@ -120,30 +120,21 @@ test("settings opened from a remote session sweep every server and keep the remo
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
await view.listed(REMOTE_SERVER, directoryB)
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-pending-a")])
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
await expect(settings.getByRole("tab")).toHaveText([
"127.0.0.1:4097",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
])
await settings.getByRole("tab", { name: "Models" }).click()
// Per-server pages start on the server of the tab settings opened from.
await settings.getByRole("tab", { name: "Models", exact: true }).click()
await expect(settings.getByRole("button", { name: "Server 127.0.0.1:4097", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER))
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page).toHaveURL("/settings?tab=models")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("Escape")
await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER))
await expectSessionTitle(page, sessionB.title)
@@ -43,7 +43,8 @@ async function open(page: Page, input: { count?: number; seed?: SeedInput } = {}
// Readiness includes the server-backed project inventory, not just the settings shell.
if (input.count === 0) return view
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(input.count ?? (input.seed?.servers ? 2 : 1))
// Only the selected server's projects are indexed.
await expect(view.results.getByRole("option")).toHaveCount(input.count ?? 1)
await view.search.clear()
return view
@@ -331,11 +332,11 @@ test("IME confirmation does not activate a search result", async ({ page }) => {
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
})
test("multi-server results navigate to the named server and keep the query on return", async ({ page }) => {
test("multi-server results follow the selected server and keep the query on return", async ({ page }) => {
await mockOpenCodeServer(page, {
server: REMOTE_SERVER,
directory: "/remote/opencode",
project: project({ id: "proj_search_remote", directory: "/remote/opencode", name: "OpenCode" }),
project: project({ id: "proj_search_remote", directory: "/remote/opencode", name: "Remote app" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
@@ -347,17 +348,21 @@ test("multi-server results navigate to the named server and keep the query on re
},
})
await view.search.fill("Remote app")
await expect(view.results.getByRole("option")).toHaveCount(0)
await view.search.fill("MCPs")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Providers", exact: true }).click()
await view.settings.getByRole("button", { name: "Server 127.0.0.1:4096", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Build server", exact: true }).click()
await view.search.fill("MCPs")
await expect(view.results.getByRole("option")).toHaveCount(2)
await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click()
await expect(view.search).toHaveCount(0)
await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("button", { name: "Server Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toHaveValue("MCPs")
await view.search.fill("Build server OpenCode name")
await view.search.fill("Remote app name")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("Remote app")
})
@@ -7,7 +7,7 @@ import { expectSessionTitle } from "../utils/waits"
test.use({ viewport: { width: 1280, height: 900 } })
for (const mode of ["failed", "stopped", "ready"] as const) {
test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => {
test(`manages a ${mode} configured WSL server from the Servers page`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/repo",
project: project({ id: "proj_wsl_settings", directory: "/repo", name: "WSL project" }),
@@ -17,21 +17,27 @@ for (const mode of ["failed", "stopped", "ready"] as const) {
})
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled()
const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true })
await expect(ubuntu).toHaveCount(1)
await ubuntu.click()
await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible()
const connection = settings.locator('[data-component="settings-server-connection"]')
const switcher = settings.getByRole("button", { name: /^Server / })
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await switcher.click()
await page.getByRole("menuitemradio", { name: /^Ubuntu/ }).click()
const unavailable = settings.getByRole("status").filter({ hasText: "Can't reach Ubuntu" })
if (mode !== "ready") await expect(unavailable).toBeVisible()
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
const connection = settings.getByRole("region", { name: "Ubuntu", exact: true })
if (mode !== "ready") {
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled()
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
}
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(switcher).toHaveText("ServerUbuntu")
await expect(unavailable).toHaveCount(0)
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
@@ -39,7 +45,7 @@ for (const mode of ["failed", "stopped", "ready"] as const) {
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0)
await expect(connection).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled()
})
}
@@ -54,7 +60,8 @@ test("adding a WSL server while the WSL extension is down shows that WSL is unav
})
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1)
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await expect(settings.getByRole("region", { name: "Ubuntu", exact: true })).toHaveCount(1)
await page.getByRole("checkbox", { name: "WSL extension" }).uncheck()
await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter")
@@ -75,7 +82,8 @@ test("adding an SSH server while the SSH extension is down fails instead of conn
})
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1)
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await expect(settings.getByRole("region", { name: "Ubuntu", exact: true })).toHaveCount(1)
await page.getByRole("checkbox", { name: "SSH extension" }).uncheck()
await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter")
@@ -116,11 +124,10 @@ test("an open session's terminal follows its WSL server to the endpoint it resta
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Ubuntu", exact: true }).click()
const connection = settings.locator('[data-component="settings-server-connection"]')
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
const connection = settings.getByRole("region", { name: "Ubuntu", exact: true })
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expectSessionTitle(page, wsl.title)
@@ -174,8 +181,8 @@ test("WSL session and draft tabs outlive the extension going away until the serv
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Ubuntu", exact: true }).click()
const connection = settings.locator('[data-component="settings-server-connection"]')
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
const connection = settings.getByRole("region", { name: "Ubuntu", exact: true })
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("remove:wsl:Ubuntu")
+48 -5
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, seed, session } from "../utils/app"
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSession, openSettings, type WorkspaceInput } from "../utils/workspace"
@@ -134,6 +134,50 @@ test("single-server settings expose scoped pages without a server picker", async
expect((await updated).postDataJSON()).toEqual({ shell: "bash" })
})
test("several servers share one selected server across per-server pages", async ({ page }) => {
const offline = "http://127.0.0.1:4099"
await mockRemoteServer(page, {
name: "Build server",
directory: "/remote/settings-demo",
provider: {
all: [{ id: "openrouter", name: "OpenRouter", models: { kimi: { id: "kimi", name: "Kimi K2.5" } } }],
connected: ["openrouter"],
default: { providerID: "openrouter", modelID: "kimi" },
},
integrations: [{ id: "openrouter", name: "OpenRouter", methods: [{ type: "key" }], connections: [] }],
})
// Nothing answers on the offline server.
await seed(page, {
servers: [
{ url: REMOTE_SERVER, name: "Build server" },
{ url: offline, name: "Offline box" },
],
})
const { settings } = await open(page)
const switcher = settings.locator('[data-action="settings-server-select"]:visible')
const connected = settings.locator('[data-component="connected-providers-section"]')
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await expect(switcher).toHaveText("Server127.0.0.1:4096")
await expect(connected.getByText("No connected providers")).toBeVisible()
await switcher.click()
await page.getByRole("menuitemradio", { name: "Build server", exact: true }).click()
await expect(connected.getByText("OpenRouter", { exact: true })).toBeVisible()
await expect(page).toHaveURL(
(url) => url.searchParams.get("server") === REMOTE_SERVER && url.searchParams.get("tab") === "providers",
)
await settings.getByRole("tab", { name: "Models", exact: true }).click()
await expect(switcher).toHaveText("ServerBuild server")
await expect(settings.getByRole("switch", { name: "Kimi K2.5", exact: true })).toBeVisible()
await switcher.click()
await page.getByRole("menuitemradio", { name: /^Offline box/ }).click()
await expect(settings.getByRole("status").filter({ hasText: "Can't reach Offline box" })).toBeVisible()
await expect(settings.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Edit server", exact: true })).toBeVisible()
})
test("project list menus rename, edit, add, and stay inside the scrollport", async ({ page }) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) =>
project({ id: `project-${index}`, directory: `/projects/${name}`, name }),
@@ -1280,7 +1324,7 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
const { settings } = await open(page)
await expect(page.getByRole("dialog")).toHaveCount(0)
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
@@ -1315,7 +1359,7 @@ test("the add server dialog pairs from a one-time link and explains a spent one"
const link = `${paired}/auth/connect/one-time-code`
const addServer = async () => {
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(link)
@@ -1327,7 +1371,6 @@ test("the add server dialog pairs from a one-time link and explains a spent one"
const added = await addServer()
await expect(added).toBeHidden()
await expect(settings.getByText(paired, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to settings" }).click()
const spent = await addServer()
await expect(spent.getByRole("alert")).toHaveText(
@@ -1359,7 +1402,7 @@ test("a pairing code with several addresses keeps the first one that works with
}
const { settings } = await open(page)
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor
+8
View File
@@ -769,6 +769,14 @@ export const dict = {
"settings.servers.description": "Manage server connections",
"settings.server.description": "Manage this server’s connection and preferences",
"settings.server.section.connection": "Connection",
"settings.server.switcher.label": "Server",
"settings.server.switcher.unreachable": "Unreachable",
"settings.server.switcher.manage": "Manage servers…",
"settings.server.unreachable.title": "Can't reach {{server}}",
"settings.server.unreachable.description":
"{{server}} isn't responding. Its settings will appear here once it reconnects.",
"settings.server.unreachable.retry": "Retry",
"settings.server.unreachable.edit": "Edit server",
"settings.server.preferences.websearch.title": "Third-party search",
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
"settings.projects.title": "Projects",
+19 -3
View File
@@ -2,7 +2,7 @@ import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { authTokenFromCredentials } from "./api"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { Accessor, createEffect, createSignal, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = {
@@ -174,12 +174,18 @@ export function createServerHealth(
check: (http: ServerConnection.HttpBase) => Promise<ServerHealth>,
) {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
const [attempt, setAttempt] = createSignal(0)
const endpoints = new Map<ServerConnection.Key, string>()
// Callers of `retry` wait for the probe round it starts.
const waiting: (() => void)[] = []
createEffect(() => {
attempt()
if (!enabled()) {
endpoints.clear()
setStatus(reconcile({}))
waiting.splice(0).forEach((resolve) => resolve())
return
}
@@ -250,7 +256,9 @@ export function createServerHealth(
setStatus(reconcile(results))
}
void refresh()
void refresh().finally(() => {
if (!dead) waiting.splice(0).forEach((resolve) => resolve())
})
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
@@ -258,5 +266,13 @@ export function createServerHealth(
})
})
return status
return {
status,
/** Probes every server now instead of at the next poll; resolves when that round finishes. */
retry: () =>
new Promise<void>((resolve) => {
waiting.push(resolve)
setAttempt((value) => value + 1)
}),
}
}
+5 -4
View File
@@ -81,14 +81,14 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
// credentials, so its controller waits until health recovers and then starts with the current ones.
createMemo(() => {
for (const conn of server.list) {
if (serverHealth[ServerConnection.key(conn)]?.unauthorized) continue
if (serverHealth.status[ServerConnection.key(conn)]?.unauthorized) continue
ensureServerCtx(conn)
}
})
createEffect(() => {
for (const [key] of serverCtxs) {
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key))
if (serverHealth.status[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key))
disposeServerCtx(key)
}
})
@@ -96,7 +96,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
return {
servers: {
list: () => server.list,
health: serverHealth,
health: serverHealth.status,
retry: serverHealth.retry,
},
models,
ensureServerCtx(conn: ServerConnection.Any) {
@@ -106,7 +107,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
serverCtx(key: ServerConnection.Key) {
const conn = server.list.find((item) => ServerConnection.key(item) === key)
if (!conn || serverHealth[key]?.unauthorized) return
if (!conn || serverHealth.status[key]?.unauthorized) return
return ensureServerCtx(conn)
},
@@ -62,7 +62,11 @@ const WorkspaceDestinationSetting: Component = () => {
)
}
export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
export const ShellSetting: Component<{
controller: ShellSettingsController
/** Search reveals the row with the default action; the Servers page names the other servers' rows apart. */
action?: string
}> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
@@ -78,7 +82,7 @@ export const ShellSetting: Component<{ controller: ShellSettingsController }> =
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
data-action={props.action ?? "settings-shell"}
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
placement="bottom-end"
+4 -2
View File
@@ -1,6 +1,6 @@
import { useFilteredList } from "@opencode/ui/hooks"
import { Switch } from "@opencode/ui/switch"
import { type Component, createEffect, createMemo, For, Show } from "solid-js"
import { type Component, createEffect, createMemo, For, type JSX, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
@@ -27,6 +27,7 @@ export const SettingsModels: Component<{
active?: boolean
provider?: string
onReveal?: () => void
headerAction?: JSX.Element
}> = (props) => {
const language = useLanguage()
const models = useModels()
@@ -142,10 +143,11 @@ export const SettingsModels: Component<{
<>
<div class="settings-tab-header settings-tab-header--stacked">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<div class="settings-tab-header-actions">{props.headerAction}</div>
</div>
<SettingsSearchField
value={list.filter()}
@@ -1,4 +1,4 @@
import { Component, For, createEffect, createMemo, createResource } from "solid-js"
import { Component, For, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
@@ -24,6 +24,7 @@ interface PluginRowItem {
export const SettingsExtensions: Component<{
subtab?: SettingsView["subtab"]
onSubtab: (value: SettingsView["subtab"]) => void
headerAction?: JSX.Element
}> = (props) => {
const language = useLanguage()
const serverSdk = useServerSDK()
@@ -76,10 +77,11 @@ export const SettingsExtensions: Component<{
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<div class="settings-tab-header-actions">{props.headerAction}</div>
</div>
</div>
@@ -9,7 +9,7 @@ import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
import { consoleProviderGroup } from "@/providers/catalog/console"
import { useIntegrations } from "@/providers/catalog/integrations"
import { createEffect, createMemo, type Component, For, Show } from "solid-js"
import { createEffect, createMemo, type Component, For, type JSX, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
@@ -41,6 +41,7 @@ const PROVIDER_NOTES = [
export const SettingsProviders: Component<{
directory: string | undefined
onSelectProvider?: (providerID: string) => void
headerAction?: JSX.Element
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
@@ -394,10 +395,11 @@ export const SettingsProviders: Component<{
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<div class="settings-tab-header-actions">{props.headerAction}</div>
</div>
</div>
+41 -52
View File
@@ -1,39 +1,48 @@
import { describe, expect, test } from "bun:test"
import { parseSettingsView, settingsViewRedirect, settingsViewUrl, type SettingsView } from "./route"
const parse = (view: SettingsView) => parseSettingsView(new URL(settingsViewUrl(view), "http://localhost").search)
describe("settings route", () => {
test("keeps root preferences at the canonical route", () => {
expect(settingsViewUrl({ type: "root", tab: "general" })).toBe("/settings")
expect(parseSettingsView("", false)).toEqual({ type: "root", tab: "general", subtab: undefined })
expect(parseSettingsView("")).toEqual({ type: "root", tab: "general", server: undefined, subtab: undefined })
})
test("round trips root, server, and project pages", () => {
test("round trips root pages, the selected server, and project pages", () => {
const root: SettingsView = { type: "root", tab: "appearance" }
const server: SettingsView = { type: "server", server: "wsl:Debian", tab: "models" }
const models: SettingsView = { type: "root", tab: "models", server: "wsl:Debian" }
// Preferences with a selected server names its tab, which a link to the old server page lacks.
const preferences: SettingsView = { type: "root", tab: "general", server: "wsl:Debian" }
const project: SettingsView = {
type: "project",
server: "local",
project: "C:\\work folder",
parent: "server",
tab: "extensions",
subtab: "skills",
}
expect(parseSettingsView(new URL(settingsViewUrl(root), "http://localhost").search, false)).toEqual({
...root,
subtab: undefined,
})
expect(parseSettingsView(new URL(settingsViewUrl(server), "http://localhost").search, true)).toEqual({
...server,
subtab: undefined,
})
expect(parseSettingsView(new URL(settingsViewUrl(project), "http://localhost").search, true)).toEqual(project)
expect(parse(root)).toEqual({ ...root, server: undefined, subtab: undefined })
expect(parse(models)).toEqual({ ...models, subtab: undefined })
expect(settingsViewUrl(preferences)).toBe("/settings?server=wsl%3ADebian&tab=general")
expect(parse(preferences)).toEqual({ ...preferences, subtab: undefined })
expect(parse(project)).toEqual(project)
})
test("derives project ancestry and keeps search reveal state transient", () => {
test("opens links to a server's old settings page on the flat pages with that server selected", () => {
expect(parseSettingsView("?server=wsl%3ADebian")).toEqual({ type: "root", tab: "servers", server: "wsl:Debian" })
expect(parseSettingsView("?server=wsl%3ADebian&tab=providers")).toEqual({
type: "root",
tab: "providers",
server: "wsl:Debian",
subtab: undefined,
})
})
test("keeps search reveal state transient", () => {
expect(
parseSettingsView("?server=local&project=%2Fwork&tab=extensions&subtab=plugins", false, {
parseSettingsView("?server=local&project=%2Fwork&tab=extensions&subtab=plugins", {
target: "settings-plugin",
searchActivation: 2,
}),
@@ -41,7 +50,6 @@ describe("settings route", () => {
type: "project",
server: "local",
project: "/work",
parent: "root",
tab: "extensions",
subtab: "plugins",
target: "settings-plugin",
@@ -49,25 +57,22 @@ describe("settings route", () => {
})
})
test("keeps a restored server page until the server lists load", () => {
const view: SettingsView = { type: "server", server: "wsl:Debian", tab: "models" }
test("keeps a restored server selection until the server lists load, then drops a removed one", () => {
const view: SettingsView = { type: "root", tab: "models", server: "wsl:Debian" }
const local = { key: "local", connected: true, starting: false }
const wsl = { key: "wsl:Debian", connected: false, starting: true }
expect(settingsViewRedirect({ view, loaded: false, servers: [local] })).toBeUndefined()
expect(settingsViewRedirect({ view, loaded: true, servers: [local, wsl] })).toBeUndefined()
expect(settingsViewRedirect({ view, loaded: true, servers: [local] })).toEqual({ type: "back" })
expect(settingsViewRedirect({ view, loaded: true, servers: [local] })).toEqual({
type: "root",
tab: "models",
server: undefined,
})
})
test("keeps a project page while its server starts", () => {
const view: SettingsView = {
type: "project",
server: "wsl:Debian",
project: "/work",
parent: "server",
tab: "general",
}
const view: SettingsView = { type: "project", server: "wsl:Debian", project: "/work", tab: "general" }
const local = { key: "local", connected: true, starting: false }
expect(
@@ -83,40 +88,24 @@ describe("settings route", () => {
loaded: true,
servers: [local, { key: "wsl:Debian", connected: false, starting: false }],
}),
).toEqual({ type: "server", server: "wsl:Debian" })
})
test("moves a single server's page to the root settings", () => {
const local = { key: "local", connected: true, starting: false }
expect(
settingsViewRedirect({
view: { type: "server", server: "local", tab: "general" },
loaded: true,
servers: [local],
}),
).toEqual({ type: "root", tab: "servers" })
expect(
settingsViewRedirect({
view: { type: "server", server: "local", tab: "models" },
loaded: true,
servers: [local],
}),
).toEqual({ type: "root", tab: "models" })
).toEqual({ type: "root", tab: "projects", server: "wsl:Debian" })
expect(settingsViewRedirect({ view, loaded: true, servers: [local] })).toEqual({ type: "root", tab: "projects" })
})
test("falls back for invalid scope and tab combinations", () => {
expect(parseSettingsView("?tab=unknown", false)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?project=%2Fwork&tab=extensions", false)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?server=local&tab=about", true)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?tab=toString", false)).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?tab=extensions&subtab=toString", false)).toEqual({
expect(parseSettingsView("?tab=unknown")).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?project=%2Fwork&tab=extensions")).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?tab=toString")).toEqual({ type: "root", tab: "general" })
expect(parseSettingsView("?tab=extensions&subtab=toString")).toEqual({
type: "root",
tab: "extensions",
server: undefined,
subtab: undefined,
})
expect(parseSettingsView("?tab=extensions&subtab=lsps", false)).toEqual({
expect(parseSettingsView("?tab=extensions&subtab=lsps")).toEqual({
type: "root",
tab: "extensions",
server: undefined,
subtab: undefined,
})
})
+28 -42
View File
@@ -18,7 +18,8 @@ declare const extensionTab: unique symbol
/** A root page a GUI extension contributes; the value is its SettingsPage id. */
export type SettingsExtensionTab = string & { readonly [extensionTab]: true }
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
/** Root pages that show one server's settings: the selected server, or the default one. */
export type SettingsServerTab = "projects" | "workspaces" | "providers" | "models" | "extensions"
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
@@ -28,21 +29,18 @@ type SettingsViewState = {
searchActivation?: number
}
type SettingsNestedView =
| { type: "server"; server: string; tab: SettingsServerTab }
| {
type: "project"
server: string
project: string
tab: SettingsProjectTab
parent: "root" | "server"
}
type SettingsProjectView = { type: "project"; server: string; project: string; tab: SettingsProjectTab }
export type SettingsView = ({ type: "root"; tab: SettingsRootTab | SettingsExtensionTab } | SettingsNestedView) &
/** `server` on a root view is the server its per-server pages show; without it they show the default server. */
export type SettingsView = (
| { type: "root"; tab: SettingsRootTab | SettingsExtensionTab; server?: string }
| SettingsProjectView
) &
SettingsViewState
/** A view of a page the host owns. */
export type SettingsHostView = ({ type: "root"; tab: SettingsRootTab } | SettingsNestedView) & SettingsViewState
export type SettingsHostView = ({ type: "root"; tab: SettingsRootTab; server?: string } | SettingsProjectView) &
SettingsViewState
export type SettingsTransientView = Pick<SettingsView, "target" | "searchActivation">
@@ -63,7 +61,6 @@ const rootTabs: Record<SettingsRootTab, true> = {
}
const serverTabs: Record<SettingsServerTab, true> = {
general: true,
projects: true,
workspaces: true,
providers: true,
@@ -86,37 +83,26 @@ const subtabs: Record<NonNullable<SettingsView["subtab"]>, true> = {
export function parseSettingsView(
search: string,
multipleServers: boolean,
transient?: SettingsTransientView,
extensionTabs?: ReadonlySet<string>,
): SettingsView {
const params = new URLSearchParams(search)
const tab = params.get("tab") ?? "general"
const server = params.get("server")
const server = params.get("server") ?? undefined
const project = params.get("project")
const subtab = params.get("subtab")
const nested = subtab && isSubtab(subtab) && tab === "extensions" ? subtab : undefined
if (project && server && isProjectTab(tab)) {
return {
type: "project",
server,
project,
parent: multipleServers ? "server" : "root",
tab,
subtab: nested,
...transient,
}
}
if (project && server && isProjectTab(tab)) return { type: "project", server, project, tab, subtab: nested, ...transient }
if (!project && server && isServerTab(tab))
return { type: "server", server, tab, subtab: nested === "lsps" ? undefined : nested, ...transient }
// Links to a server's own page, which the Servers page replaced, name a server without a tab.
if (!project && server && !params.has("tab")) return { type: "root", tab: "servers", server, ...transient }
if (!project && !server && isRootTab(tab))
return { type: "root", tab, subtab: nested === "lsps" ? undefined : nested, ...transient }
if (!project && isRootTab(tab))
return { type: "root", tab, server, subtab: nested === "lsps" ? undefined : nested, ...transient }
if (!project && !server && extensionTabs && isExtensionTab(tab, extensionTabs))
return { type: "root", tab, subtab: undefined, ...transient }
if (!project && extensionTabs && isExtensionTab(tab, extensionTabs))
return { type: "root", tab, server, subtab: undefined, ...transient }
return { type: "root", tab: "general", ...transient }
}
@@ -124,11 +110,12 @@ export function parseSettingsView(
export function settingsViewUrl(view: SettingsView) {
const params = new URLSearchParams()
if (view.type !== "root") params.set("server", view.server)
if (view.server) params.set("server", view.server)
if (view.type === "project") params.set("project", view.project)
if (view.tab !== "general") params.set("tab", view.tab)
// A server without a tab names the Servers page, so a root view with a server always names its tab.
if (view.tab !== "general" || (view.type === "root" && view.server)) params.set("tab", view.tab)
if (view.tab === "extensions" && view.subtab) params.set("subtab", view.subtab)
const search = params.toString()
@@ -138,25 +125,24 @@ export function settingsViewUrl(view: SettingsView) {
type RedirectServer = { key: string; connected: boolean; starting: boolean }
/** Where a server or project view must go when its server is gone, unreachable, or the only one. */
/** Where a view must go when the server it names is gone, or a project's server is unavailable. */
export function settingsViewRedirect(input: {
view: SettingsView
/** False while the saved, WSL, and SSH server lists load; a restored route waits for them. */
loaded: boolean
servers: readonly RedirectServer[]
}): { type: "back" } | { type: "server"; server: string } | { type: "root"; tab: SettingsRootTab } | undefined {
}): SettingsView | undefined {
const view = input.view
if (view.type === "root" || !input.loaded) return
if (!view.server || !input.loaded) return
const server = input.servers.find((item) => item.key === view.server)
if (!server) return { type: "back" }
if (view.type === "root") return server ? undefined : { ...view, server: undefined }
if (!server) return { type: "root", tab: "projects" }
// A starting WSL or SSH server connects shortly; only an unavailable one leaves the project page.
if (view.type === "project" && !server.connected && !server.starting) return { type: "server", server: server.key }
if (view.type === "server" && input.servers.length === 1)
return { type: "root", tab: view.tab === "general" ? "servers" : view.tab }
if (!server.connected && !server.starting) return { type: "root", tab: "projects", server: server.key }
}
export function isRootTab(value: string): value is SettingsRootTab {
+4 -3
View File
@@ -170,7 +170,8 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
},
]
export const serverSettings: Entry<SettingsServerTab>[] = [
// Pages showing the selected server, and rows of its card on the Servers page.
export const serverSettings: Entry<SettingsServerTab | "servers">[] = [
{ tab: "projects", label: "settings.tab.projects" },
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
{ tab: "providers", label: "settings.providers.title", keywords: "connect api key credentials" },
@@ -185,14 +186,14 @@ export const serverSettings: Entry<SettingsServerTab>[] = [
{ tab: "extensions", subtab: "plugins", label: "settings.extensions.tab.plugins" },
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
{
tab: "general",
tab: "servers",
label: "settings.general.row.shell.title",
target: "settings-shell",
description: "settings.general.row.shell.description",
keywords: "bash zsh powershell",
},
{
tab: "general",
tab: "servers",
label: "settings.server.preferences.websearch.title",
target: "settings-websearch",
description: "settings.server.preferences.websearch.description",
+13 -11
View File
@@ -19,6 +19,8 @@ export type SettingsSearchExtension = { extension: string; value: SettingsPage }
export function settingsSearchIndex(input: {
servers: readonly SettingsSearchServer[]
/** The server per-server pages show; only its settings and projects are indexed. */
selected?: string
desktop: boolean
browser: boolean
mobile: boolean
@@ -26,6 +28,7 @@ export function settingsSearchIndex(input: {
extensions?: readonly SettingsSearchExtension[]
}) {
const items: SettingsSearchResult[] = []
const multiple = input.servers.length > 1
const add = (
entry: (typeof clientSettings)[number],
@@ -42,7 +45,9 @@ export function settingsSearchIndex(input: {
entry.section ??
(view.type !== "root" && view.tab === "general"
? "settings.general.section.general"
: pageLabels[view.tab]),
: view.tab === "servers" && multiple
? "settings.tab.servers"
: pageLabels[view.tab]),
)
items.push({
@@ -123,11 +128,9 @@ export function settingsSearchIndex(input: {
})
})
input.servers.forEach((server) => {
const view = (tab: SettingsServerTab, target?: string, subtab?: SettingsView["subtab"]): SettingsHostView => {
if (input.servers.length === 1) return { type: "root", tab: tab === "general" ? "servers" : tab, target, subtab }
return { type: "server", server: server.key, tab, target, subtab }
}
// With several servers, a result names the server its page shows.
const view = (tab: SettingsServerTab | "servers", target?: string, subtab?: SettingsView["subtab"]) =>
({ type: "root", tab, server: multiple ? server.key : undefined, target, subtab }) satisfies SettingsHostView
items.push({
id: `server:${server.key}`,
@@ -138,16 +141,16 @@ export function settingsSearchIndex(input: {
owner: input.translate("settings.tab.servers"),
page: input.translate("settings.server.section.connection"),
server: server.key,
view: view("general"),
view: view("servers"),
})
if (!server.connected) return
if (!server.connected || server.key !== input.selected) return
serverSettings.forEach((entry) => add(entry, view(entry.tab, entry.target, entry.subtab), server.name, server.key))
// Sections on the servers page render under each server's general page.
// Sections on the servers page render with each server's connection.
extensions.forEach((item) => {
if (item.value.page !== "servers") return
item.value.entries?.forEach((entry) =>
addExtension(item, entry, view("general", entry.id), item.value.title, server.name, server.key),
addExtension(item, entry, view("servers", entry.id), item.value.title, server.name, server.key),
)
})
server.projects.forEach((project) => {
@@ -156,7 +159,6 @@ export function settingsSearchIndex(input: {
server: server.key,
project: project.worktree,
tab: "general",
parent: input.servers.length > 1 ? "server" : "root",
}
const name = displayName(project)
@@ -3,7 +3,6 @@ import { dict } from "@opencode/ui/i18n/en"
import en from "@/runtime/i18n/en"
import { settingsSearchIndex, type SettingsSearchServer } from "./search-index"
import { rankSettings } from "./search-results"
import type { SettingsView } from "./surface"
const strings = new Map(Object.entries({ ...dict, ...en }))
@@ -14,11 +13,10 @@ const servers: SettingsSearchServer[] = [
{ key: "remote", name: "Build server", connected: true, projects: [project] },
]
const root: SettingsView = { type: "root", tab: "general" }
function index(input: Partial<Parameters<typeof settingsSearchIndex>[0]> = {}) {
return settingsSearchIndex({
servers,
selected: "local",
desktop: false,
browser: false,
mobile: false,
@@ -28,13 +26,12 @@ function index(input: Partial<Parameters<typeof settingsSearchIndex>[0]> = {}) {
}
describe("settings search index", () => {
test("uses concrete server identity and adapts single-server destinations", () => {
const multi = rankSettings("models", index(), root).filter((item) => item.topLevel)
test("indexes the selected server's pages and names it only when there are several servers", () => {
const multi = rankSettings("models", index({ selected: "remote" })).filter((item) => item.topLevel)
expect(multi.map((item) => item.view)).toEqual([
{ type: "server", server: "local", tab: "models", target: undefined, subtab: undefined },
{ type: "server", server: "remote", tab: "models", target: undefined, subtab: undefined },
{ type: "root", server: "remote", tab: "models", target: undefined, subtab: undefined },
])
const single = rankSettings("models", index({ servers: [servers[0]] }), root).find((item) => item.topLevel)!
const single = rankSettings("models", index({ servers: [servers[0]] })).find((item) => item.topLevel)!
expect(single.server).toBe("local")
expect(single.view).toEqual({ type: "root", tab: "models", target: undefined, subtab: undefined })
})
@@ -42,13 +39,13 @@ describe("settings search index", () => {
test("keeps unavailable servers discoverable without advertising unloaded settings", () => {
const items = index({ servers: [{ ...servers[0], connected: false }] })
expect(items.filter((item) => item.server).map((item) => item.id)).toEqual(["server:local"])
expect(rankSettings("Local server", items, root)[0].view).toEqual({
expect(rankSettings("Local server", items)[0].view).toEqual({
type: "root",
tab: "servers",
target: undefined,
subtab: undefined,
})
expect(rankSettings("font", items, root)).toHaveLength(3)
expect(rankSettings("font", items)).toHaveLength(3)
})
test("only advertises settings supported by this platform", () => {
@@ -71,79 +68,70 @@ describe("settings search index", () => {
items.map((item) => item.id),
)
expect(
rankSettings("agent", items, root)
rankSettings("agent", items)
.filter((item) => item.title === "Agent")
.map((item) => item.page),
).toEqual(["Desktop notifications", "Sound effects"])
expect(rankSettings("follow-up behavior", items, root)[0].page).toBe("Preferences / General")
expect(rankSettings("follow-up behavior", items)[0].page).toBe("Preferences / General")
expect(items.some((item) => item.view.target === "settings-project-startup")).toBe(false)
})
test("retains project avatars and excludes color when a custom icon owns its appearance", () => {
const withIcon = { ...project, icon: { color: "orange", override: "data:image/png;base64,example" } }
const items = index({ servers: [{ ...servers[0], projects: [withIcon] }] })
expect(rankSettings("opencode", items, root)[0].projectInfo).toEqual(withIcon)
expect(rankSettings("opencode color", items, root)).toEqual([])
expect(rankSettings("opencode", items)[0].projectInfo).toEqual(withIcon)
expect(rankSettings("opencode color", items)).toEqual([])
})
})
describe("settings search ranking", () => {
test("prioritizes top-level pages and omits generic project-setting copies", () => {
expect(rankSettings("work", index({ servers: [servers[0]] }), root).map((item) => item.title)).toEqual([
expect(rankSettings("work", index({ servers: [servers[0]] })).map((item) => item.title)).toEqual([
"Worktrees",
"Default environment",
])
expect(rankSettings("mcps", index(), root).map((item) => item.view.type)).toEqual(["server", "server"])
expect(rankSettings("project name", index(), root)).toEqual([])
expect(rankSettings("startup", index(), root)).toEqual([])
expect(rankSettings("mcps", index()).map((item) => item.view)).toEqual([
{ type: "root", server: "local", tab: "extensions", target: undefined, subtab: "mcps" },
])
expect(rankSettings("project name", index())).toEqual([])
expect(rankSettings("startup", index())).toEqual([])
})
test("qualifies project settings by project name and supports explicit server names", () => {
const matches = rankSettings("opencode name", index(), root)
expect(matches.map((item) => item.view.target)).toEqual(["settings-project-name", "settings-project-name"])
expect(rankSettings("Build server opencode name", index(), root).map((item) => item.server)).toEqual(["remote"])
expect(rankSettings("opencode skills", index(), root).map((item) => item.view.subtab)).toEqual(["skills", "skills"])
test("qualifies project settings by project name on the selected server", () => {
expect(rankSettings("opencode name", index()).map((item) => item.view.target)).toEqual(["settings-project-name"])
expect(rankSettings("opencode name", index({ selected: "remote" })).map((item) => item.server)).toEqual([
"remote",
])
expect(rankSettings("opencode skills", index()).map((item) => item.view.subtab)).toEqual(["skills"])
})
test("project names alone find projects, including names that are setting labels", () => {
const items = index({ servers: [{ ...servers[0], projects: [{ ...project, name: "Skills" }] }] })
expect(
rankSettings("skills", items, root)
rankSettings("skills", items)
.filter((item) => item.project)
.map((item) => item.entity),
).toEqual([true])
expect(rankSettings("opencode", index(), root).map((item) => item.entity)).toEqual([true, true])
expect(rankSettings("opencode", index()).map((item) => item.entity)).toEqual([true])
})
test("normalizes whitespace, case, and Unicode in qualified names", () => {
const items = index({ servers: [{ ...servers[0], projects: [{ ...project, name: "Open Code" }] }] })
expect(rankSettings(" OPEN CODE name ", items, root).map((item) => item.view.target)).toEqual([
expect(rankSettings(" OPEN CODE name ", items).map((item) => item.view.target)).toEqual([
"settings-project-name",
])
})
test("does not search paths or use the Projects category to match unrelated names", () => {
const items = index({ servers: [{ ...servers[0], projects: [{ ...project, name: "codename" }] }] })
expect(rankSettings("project name", items, root).some((item) => item.project)).toBe(false)
expect(rankSettings("/projects/opencode", items, root)).toEqual([])
})
test("ranks equivalent matches by origin without merging different servers", () => {
const origin: SettingsView = {
type: "project",
server: "remote",
project: project.worktree,
tab: "general",
parent: "server",
}
expect(rankSettings("opencode name", index(), origin).map((item) => item.server)).toEqual(["remote", "local"])
expect(rankSettings("project name", items).some((item) => item.project)).toBe(false)
expect(rankSettings("/projects/opencode", items)).toEqual([])
})
test("supports synonyms and fuzzy title matches while rejecting unrelated queries", () => {
expect(rankSettings("dark mode", index(), root)[0].view.target).toBe("settings-color-scheme")
expect(rankSettings("termfont", index(), root)[0].title).toBe("Terminal Font")
expect(rankSettings(" ", index(), root)).toEqual([])
expect(rankSettings("zzzzzzzzz", index(), root)).toEqual([])
expect(rankSettings("dark mode", index())[0].view.target).toBe("settings-color-scheme")
expect(rankSettings("termfont", index())[0].title).toBe("Terminal Font")
expect(rankSettings(" ", index())).toEqual([])
expect(rankSettings("zzzzzzzzz", index())).toEqual([])
})
})
+2 -10
View File
@@ -21,7 +21,7 @@ export type SettingsSearchResult = {
view: SettingsView
}
export function rankSettings(query: string, items: SettingsSearchResult[], origin: SettingsView) {
export function rankSettings(query: string, items: SettingsSearchResult[]) {
const value = normalize(query)
if (!value) return []
@@ -59,20 +59,12 @@ export function rankSettings(query: string, items: SettingsSearchResult[], origi
if (!score) return []
const proximity =
origin.type === "project" && origin.server === item.server && origin.project === item.project
? 2
: origin.type !== "root" && origin.server === item.server
? 1
: 0
return [{ item, score, proximity }]
return [{ item, score }]
})
.sort(
(a, b) =>
Number(!!b.item.topLevel) - Number(!!a.item.topLevel) ||
b.score - a.score ||
b.proximity - a.proximity ||
a.item.title.localeCompare(b.item.title),
)
.map((result) => result.item)
+2 -3
View File
@@ -74,11 +74,10 @@ export function SettingsSearch() {
}),
)
const origin = () => search.state.origin ?? surface.view()
const catalog = createMemo(() =>
settingsSearchIndex({
servers: inventory(),
selected: surface.server()?.key,
desktop: platform.platform === "desktop",
browser: platform.platform === "desktop",
mobile: mobile(),
@@ -87,7 +86,7 @@ export function SettingsSearch() {
}),
)
const matches = createMemo(() => rankSettings(search.state.query, catalog(), origin()))
const matches = createMemo(() => rankSettings(search.state.query, catalog()))
const category = (item: SettingsSearchResult) =>
item.topLevel
@@ -1,6 +1,7 @@
import { createMemo } from "solid-js"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useExtensionServers, type ExtensionServer } from "@/runtime/extension/servers"
import type { ServerHealth } from "@/runtime/server/health"
export type SettingsServer = {
key: ServerConnection.Key
@@ -52,3 +53,8 @@ export function useSettingsServers() {
return createMemo(() => settingsServers(servers.list, extensions.entries()))
}
/** Whether a server's pages can load: it runs and its last health check did not fail. */
export function settingsServerReachable(server: SettingsServer, health: ServerHealth | undefined) {
return !!server.connection && health?.healthy !== false
}
+135 -77
View File
@@ -1,6 +1,6 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { createMemo, Show, type Component } from "solid-js"
import { createMemo, For, Show, type Component } from "solid-js"
import { ServerRowMenu } from "@/servers/registry/row-menu"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { ExtensionServerRow } from "@/servers/registry/extension-row"
@@ -15,12 +15,107 @@ import { createServerShellController } from "@/settings/general/controllers"
import type { SettingsServer } from "./inventory"
import "@/settings/settings.css"
export const SettingsServerGeneral: Component<{
entry: SettingsServer
nested?: boolean
onAddServer?: () => void
onServerChange?: (server: ServerConnection.Any) => void
/** The only server's connection and preferences. */
export const SettingsServerGeneral: Component<{ entry: SettingsServer; onAddServer: () => void }> = (props) => {
const language = useLanguage()
const controller = useServerCollectionController()
const health = createMemo(() => controller.collection.health()[props.entry.key])
return (
<>
<SettingsServersHeader title={language.t("settings.section.server")} onAddServer={props.onAddServer} />
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section settings-server-connection" data-component="settings-server-connection">
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
<SettingsList>
<ServerConnectionRow entry={props.entry} />
</SettingsList>
</section>
<Show when={props.entry.connection && health()?.healthy !== false ? props.entry.connection : undefined} keyed>
{(server) => (
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
<SettingsList>
<ServerShell server={server} />
</SettingsList>
</section>
)}
</Show>
</div>
</>
)
}
/** Every server, each with its connection and terminal shell. */
export const SettingsServerList: Component<{
servers: readonly SettingsServer[]
/** The server whose shell row search results reveal. */
selected?: string
onAddServer: () => void
onServerChange: (server: ServerConnection.Any) => void
}> = (props) => {
const language = useLanguage()
const controller = useServerCollectionController()
return (
<>
<SettingsServersHeader title={language.t("settings.tab.servers")} onAddServer={props.onAddServer} />
<div class="settings-tab-body settings-tab-body--sectioned">
<For each={props.servers}>
{(entry) => (
<section
class="settings-section settings-server-connection"
data-component="settings-server-connection"
aria-label={entry.name}
>
<SettingsList>
<ServerConnectionRow entry={entry} onServerChange={props.onServerChange} />
<Show
when={
entry.connection && controller.collection.health()[entry.key]?.healthy !== false
? entry.connection
: undefined
}
keyed
>
{(server) => (
<ServerShell
server={server}
action={entry.key === props.selected ? undefined : `settings-shell:${entry.key}`}
/>
)}
</Show>
</SettingsList>
</section>
)}
</For>
</div>
</>
)
}
function SettingsServersHeader(props: { title: string; onAddServer: () => void }) {
const language = useLanguage()
return (
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{props.title}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
</div>
<div class="settings-tab-header-actions">
<AddServerMenu onAddServer={props.onAddServer} />
</div>
</div>
</div>
)
}
function ServerConnectionRow(props: { entry: SettingsServer; onServerChange?: (server: ServerConnection.Any) => void }) {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerCollectionController()
@@ -34,83 +129,46 @@ export const SettingsServerGeneral: Component<{
server.type === "http" && health()?.unauthorized ? server : undefined
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">
{props.nested ? props.entry.name : language.t("settings.section.server")}
</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t(props.nested ? "settings.server.description" : "settings.servers.description")}
</span>
</div>
<Show when={!props.nested && props.onAddServer}>
<AddServerMenu onAddServer={() => props.onAddServer?.()} />
</Show>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section settings-server-connection" data-component="settings-server-connection">
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
<SettingsList>
<Show
when={props.entry.source?.entry.row ? props.entry.key : undefined}
keyed
fallback={
<Show when={props.entry.connection}>
{(server) => (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} authenticationRequired={!!health()?.unauthorized} />
<div class="settings-servers-copy">
<bdi class="settings-servers-name" dir="auto">
{serverName(server()) || props.entry.key}
</bdi>
<bdi class="settings-servers-meta" dir="ltr">
{server().http.url}
</bdi>
</div>
</div>
<div class="settings-servers-actions">
<Show when={signedOut(server())}>
{(http) => (
<Button size="small" variant="neutral" onClick={() => edit(http())}>
{language.t("server.action.authenticate")}
</Button>
)}
</Show>
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
</div>
<Show
when={props.entry.source?.entry.row ? props.entry.key : undefined}
keyed
fallback={
<Show when={props.entry.connection}>
{(server) => (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} authenticationRequired={!!health()?.unauthorized} />
<div class="settings-servers-copy">
<bdi class="settings-servers-name" dir="auto">
{serverName(server()) || props.entry.key}
</bdi>
<bdi class="settings-servers-meta" dir="ltr">
{server().http.url}
</bdi>
</div>
</div>
<div class="settings-servers-actions">
<Show when={signedOut(server())}>
{(http) => (
<Button size="small" variant="neutral" onClick={() => edit(http())}>
{language.t("server.action.authenticate")}
</Button>
)}
</Show>
}
>
{(key) => <ExtensionServerRow server={key} controller={controller} />}
</Show>
</SettingsList>
</section>
<Show when={props.entry.connection} keyed>
{(server) => <ServerShell server={server} />}
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
</div>
)}
</Show>
</div>
</>
}
>
{(key) => <ExtensionServerRow server={key} controller={controller} />}
</Show>
)
}
function ServerShell(props: { server: ServerConnection.Any }) {
const language = useLanguage()
function ServerShell(props: { server: ServerConnection.Any; action?: string }) {
const controller = createServerShellController(() => props.server)
return (
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
<SettingsList>
<ShellSetting controller={controller} />
</SettingsList>
</section>
)
return <ShellSetting controller={controller} action={props.action} />
}
@@ -0,0 +1,77 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import type { ServerHealth } from "@/runtime/server/health"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { useSettingsSurface } from "@/settings/surface"
import { settingsServerReachable, useSettingsServers, type SettingsServer } from "./inventory"
/** Chooses the server per-server pages show; renders nothing with one server. */
export function SettingsServerSwitcher() {
const language = useLanguage()
const global = useGlobal()
const surface = useSettingsSurface()
const servers = useSettingsServers()
const health = (server: SettingsServer) => global.servers.health[server.key]
const unreachable = (server: SettingsServer) => !settingsServerReachable(server, health(server))
return (
<Show when={servers().length > 1 && surface.server()}>
{(selected) => (
<Menu placement="bottom-end" gutter={6} modal={false}>
<Menu.Trigger
as={Button}
size="small"
variant="outline"
class="settings-server-switcher"
data-action="settings-server-select"
>
<ServerDot health={health(selected())} unreachable={unreachable(selected())} />
<span class="settings-server-switcher-label">{language.t("settings.server.switcher.label")}</span>
<span class="settings-server-switcher-name">{selected().name}</span>
<Icon name="chevron-down" class="settings-server-switcher-chevron" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-server-switcher-menu">
<Menu.RadioGroup value={selected().key} onChange={(key) => surface.selectServer(key)}>
<For each={servers()}>
{(server) => (
<Menu.RadioItem value={server.key} closeOnSelect>
<ServerDot health={health(server)} unreachable={unreachable(server)} />
<span class="settings-server-switcher-option">{server.name}</span>
<Show when={unreachable(server)}>
<span class="settings-server-switcher-status">
{language.t("settings.server.switcher.unreachable")}
</span>
</Show>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
<Menu.Separator />
<Menu.Item onSelect={() => surface.select("servers")}>
<span class="settings-server-switcher-icon">
<Icon name="server" />
</span>
{language.t("settings.server.switcher.manage")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
)
}
function ServerDot(props: { health: ServerHealth | undefined; unreachable: boolean }) {
const health = () => props.health ?? (props.unreachable ? { healthy: false } : undefined)
return (
<span class="settings-server-switcher-icon">
<ServerHealthIndicator health={health()} authenticationRequired={!!health()?.unauthorized} />
</span>
)
}
@@ -0,0 +1,86 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import type { JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { DialogServer } from "@/servers/connect/dialog"
import { SettingsList } from "@/settings/list"
import type { SettingsServer } from "./inventory"
import "@/settings/settings.css"
/** A per-server page while its server is unreachable. */
export function SettingsServerUnavailable(props: {
entry: SettingsServer
title: string
description: string
headerAction?: JSX.Element
/** Opens the Servers page, for servers this page cannot edit. */
onManage: () => void
onServerChange: (server: ServerConnection.Any) => void
}) {
const language = useLanguage()
const dialog = useDialog()
const global = useGlobal()
const [state, setState] = createStore({ retrying: false })
const http = () =>
props.entry.connection?.type === "http" && !ServerConnection.builtin(props.entry.connection)
? props.entry.connection
: undefined
const retry = () => {
setState("retrying", true)
void global.servers.retry().finally(() => setState("retrying", false))
}
const edit = () => {
const server = http()
if (!server) return props.onManage()
void dialog.push(() => <DialogServer mode="edit" server={server} onSave={props.onServerChange} />)
}
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{props.title}</h2>
<span class="text-11-regular text-v2-text-text-muted">{props.description}</span>
</div>
<div class="settings-tab-header-actions">{props.headerAction}</div>
</div>
</div>
<div class="settings-tab-body">
<SettingsList>
<div class="settings-server-unavailable" role="status">
<Icon name="outline-hexagonal-warning" class="settings-server-unavailable-icon" />
<div class="settings-server-unavailable-title">
{language.t("settings.server.unreachable.title", { server: props.entry.name })}
</div>
<div class="settings-server-unavailable-description">
{language.t("settings.server.unreachable.description", { server: props.entry.name })}
</div>
<div class="settings-server-unavailable-actions">
<Button
size="normal"
variant="neutral"
icon="refresh"
disabled={state.retrying}
onClick={retry}
>
{language.t("settings.server.unreachable.retry")}
</Button>
<Button size="normal" variant="ghost-muted" onClick={edit}>
{language.t("settings.server.unreachable.edit")}
</Button>
</div>
</div>
</SettingsList>
</div>
</>
)
}
+112
View File
@@ -532,6 +532,113 @@
color: var(--v2-text-text-base);
}
/* As tall as the title's line box, so header actions center on the title rather than on the title and description. */
.settings-tab-header-actions {
display: flex;
flex-shrink: 0;
align-self: flex-start;
align-items: center;
gap: 8px;
height: var(--line-height-base);
}
.settings-tab-header-actions:not(:has(*)) {
display: none;
}
.settings-server-switcher[data-component="button-v2"] {
max-width: 240px;
gap: 6px;
padding-inline: 6px;
}
.settings-server-switcher-icon {
display: inline-flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
}
.settings-server-switcher-label {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-weight: 440;
}
.settings-server-switcher-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-server-switcher-chevron {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.settings-server-switcher-menu[data-component="menu-v2-content"] {
min-width: 220px;
max-width: min(320px, calc(100vw - 32px));
}
/* One grid for every row: a 16px status or icon column, the name, then the selection check. */
.settings-server-switcher-menu [data-component="menu-v2-item"] {
padding-inline-start: 8px;
}
.settings-server-switcher-option {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-server-switcher-status {
flex-shrink: 0;
color: var(--v2-text-text-faint);
}
.settings-server-unavailable {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px 16px;
text-align: center;
}
.settings-server-unavailable-icon {
margin-bottom: 4px;
color: var(--v2-icon-icon-muted);
}
.settings-server-unavailable-title {
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-server-unavailable-description {
max-width: 360px;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
.settings-server-unavailable-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.settings-tab-body {
display: flex;
flex-direction: column;
@@ -796,6 +903,11 @@
flex-wrap: wrap;
}
/* Wrapped below the description, the actions no longer share the title's line. */
.settings-tab-header-actions {
height: auto;
}
.settings-tab-header-row [data-component="select-v2-root"] {
width: 100%;
}
+112 -218
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import {
createEffect,
createMemo,
For,
lazy,
on,
onCleanup,
@@ -16,13 +17,10 @@ import {
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { useServerCollectionController } from "@/servers/registry/controller"
import { AddServerMenu } from "@/servers/registry/add-menu"
import { DialogServer } from "@/servers/connect/dialog"
import { LocationProvider } from "@/workspaces/location"
import { SettingsGeneral } from "./general/general"
@@ -32,8 +30,10 @@ import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
import { useSettingsServers, useSettingsServersLoaded, type SettingsServer } from "./servers/inventory"
import { SettingsServerGeneral, SettingsServerList } from "./servers/servers"
import { SettingsServerSwitcher } from "./servers/switcher"
import { SettingsServerUnavailable } from "./servers/unavailable"
import { settingsServerReachable, useSettingsServers, useSettingsServersLoaded } from "./servers/inventory"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { useWorkspacesPrefetch } from "./workspaces/queries"
import { SettingsProjects } from "./workspaces/projects"
@@ -45,7 +45,7 @@ import { SettingsProjectGeneral } from "./workspaces/project"
import { ProjectSettingsExtensions } from "./workspaces/project-extensions"
import { useSettingsSurface } from "./surface"
import { settingsViewRedirect } from "./route"
import { pageIcons } from "./pages"
import { pageIcons, pageLabels } from "./pages"
import { revealSettingsSearch } from "./search-reveal"
import { ExtensionSettingsPages } from "@/runtime/extension/settings-page-view"
import "@/settings/settings.css"
@@ -64,13 +64,22 @@ const rootClientTabs = [
] as const
const serverTabs = [
{ value: "projects", icon: pageIcons.projects, label: "settings.tab.projects" },
{ value: "workspaces", icon: pageIcons.workspaces, label: "settings.tab.workspaces" },
{ value: "providers", icon: pageIcons.providers, label: "settings.providers.title" },
{ value: "models", icon: pageIcons.models, label: "settings.models.title" },
{ value: "extensions", icon: pageIcons.extensions, label: "settings.tab.extensions" },
{ value: "projects", icon: pageIcons.projects, label: "settings.tab.projects" },
{ value: "workspaces", icon: pageIcons.workspaces, label: "settings.tab.workspaces" },
] as const
// The heading of each per-server page, which its unreachable state keeps.
const serverPages = {
providers: { title: "settings.providers.title", description: "settings.providers.description" },
models: { title: "settings.models.title", description: "settings.models.description" },
extensions: { title: "settings.tab.extensions", description: "settings.extensions.description" },
projects: { title: "settings.projects.title", description: "settings.projects.description" },
workspaces: { title: "settings.tab.workspaces", description: "settings.workspaces.description" },
} as const
const experimentalTab = [
{ value: "experimental", icon: pageIcons.experimental, label: "settings.tab.experimental" },
] as const
@@ -81,11 +90,6 @@ const guiExtensionsTab = [
const aboutTab = [{ value: "about", icon: pageIcons.about, label: "settings.tab.about" }] as const
const nestedServerTabs = [
{ value: "general", icon: pageIcons.servers, label: "settings.general.section.general" },
...serverTabs,
] as const
const nestedProjectTabs = [
{ value: "general", icon: pageIcons.projects, label: "settings.general.section.general" },
{ value: "workspaces", icon: pageIcons.workspaces, label: "settings.tab.workspaces" },
@@ -147,7 +151,7 @@ export function SettingsScreen() {
const targetServer = createMemo(() => {
const view = surface.view()
if (view.type === "root") return undefined
if (view.type !== "project") return undefined
return connection(view.server)
})
@@ -172,11 +176,7 @@ export function SettingsScreen() {
})),
})
if (next?.type === "back") surface.back()
if (next?.type === "server") surface.replaceServer(next.server)
if (next?.type === "root") surface.open(next.tab)
if (next) surface.replace(next)
})
return (
@@ -204,9 +204,6 @@ export function SettingsScreen() {
<Match when={surface.view().type === "root"}>
<RootSettings />
</Match>
<Match when={surface.view().type === "server"}>
<Show when={targetServer()}>{(server) => <ServerSettings entry={server()} />}</Show>
</Match>
<Match when={surface.view().type === "project"}>
<Show when={targetServer()?.connection} keyed>
{(server) => (
@@ -223,11 +220,8 @@ function RootSettings() {
const language = useLanguage()
const dialog = useDialog()
const surface = useSettingsSurface()
const layout = useLayout()
const tabs = useTabs()
const servers = useServerCollectionController()
const global = useGlobal()
const inventory = useSettingsServers()
const loaded = useSettingsServersLoaded()
const platform = usePlatform()
const [state, setState] = createStore<{ worktreeFilterReset: number; modelProvider: string | undefined }>({
@@ -235,33 +229,17 @@ function RootSettings() {
modelProvider: undefined,
})
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
const single = createMemo(() => singleEntry()?.connection)
const prefetchWorkspaces = useWorkspacesPrefetch(single)
const multiple = createMemo(() => inventory().length > 1)
const ordered = createMemo(() => {
const order = new Map(list().map((server, index) => [ServerConnection.key(server), index]))
// The selected server while its pages can load; an unreachable one shows its pages' unavailable state instead.
const reachable = createMemo(() => {
const entry = surface.server()
return inventory().toSorted((a, b) => (order.get(a.key) ?? list().length) - (order.get(b.key) ?? list().length))
return entry && settingsServerReachable(entry, global.servers.health[entry.key]) ? entry.connection : undefined
})
const sourceServer = createMemo(() => {
const route = surface.route()
if (route.type === "session") return connectionFor(list(), route.server)
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return connectionFor(list(), draft?.server)
}
return connectionFor(list(), layout.home.selection().server)
})
const sourceDirectory = useSettingsDirectory(sourceServer)
const prefetchWorkspaces = useWorkspacesPrefetch(reachable)
const activeDirectory = useSettingsDirectory(reachable)
// Development builds list the built-in GUI extensions of this window.
const guiExtensions = !!GuiExtensionsSettings && platform.platform === "desktop"
createEffect(() => {
@@ -274,12 +252,18 @@ function RootSettings() {
if (view.tab === "gui-extensions" && !guiExtensions) surface.open("general")
})
const addServer = () =>
void dialog.push(() => (
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const selectServer = (server: ServerConnection.Any) => surface.selectServer(ServerConnection.key(server))
const addServer = () => void dialog.push(() => <DialogServer mode="add" onSave={selectServer} />)
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: serverTabs.map((item) => ({
...item,
label: language.t(item.label),
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
})),
},
{
items: [
...rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })),
@@ -290,31 +274,15 @@ function RootSettings() {
})),
],
},
...(multiple()
? [
{
label: language.t("settings.tab.servers"),
action: <AddServerMenu compact onAddServer={addServer} />,
items: ordered().map((server) => ({
value: `server:${server.key}`,
icon: "server" as const,
label: server.name,
})),
},
]
: [
{
items: [
...serverTabs.map((item) => ({
...item,
label: language.t(item.label),
disabled: !single(),
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
})),
{ value: "servers", icon: "server" as const, label: language.t("settings.section.server") },
],
},
]),
{
items: [
{
value: "servers",
icon: pageIcons.servers,
label: language.t(multiple() ? "settings.tab.servers" : pageLabels.servers),
},
],
},
...(experimentalSettingsAvailable
? [{ items: experimentalTab.map((item) => ({ ...item, label: language.t(item.label) })) }]
: []),
@@ -322,22 +290,7 @@ function RootSettings() {
{ items: aboutTab.map((item) => ({ ...item, label: language.t(item.label) })) },
])
createEffect(() => {
const view = surface.view()
if (view.type !== "root" || !loaded() || !multiple()) return
if (["projects", "workspaces", "providers", "models", "extensions", "servers"].includes(view.tab))
surface.open("general")
})
const change = (value: string) => {
if (value.startsWith("server:")) {
surface.openServer(value.slice("server:".length))
return
}
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
surface.select(value)
}
@@ -349,7 +302,6 @@ function RootSettings() {
backLabel={language.t("settings.backToApp")}
onBack={() => surface.close()}
onChange={change}
mobileAction={multiple() ? <AddServerMenu compact onAddServer={addServer} /> : undefined}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral />
@@ -377,13 +329,62 @@ function RootSettings() {
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.view().tab === "about"} />
</Tabs.Content>
<Show when={single()} keyed>
<Show
when={reachable()}
keyed
fallback={
<Show when={surface.server()}>
{(entry) => (
<For each={serverTabs}>
{(item) => (
<Tabs.Content value={item.value} class="settings-panel">
<SettingsServerUnavailable
entry={entry()}
title={language.t(serverPages[item.value].title)}
description={language.t(serverPages[item.value].description)}
headerAction={<SettingsServerSwitcher />}
onManage={() => surface.openServers(entry().key)}
onServerChange={selectServer}
/>
</Tabs.Content>
)}
</For>
)}
</Show>
}
>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders
directory={undefined}
headerAction={<SettingsServerSwitcher />}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels
active={surface.view().tab === "models"}
provider={state.modelProvider}
headerAction={<SettingsServerSwitcher />}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions
subtab={surface.view().subtab}
headerAction={<SettingsServerSwitcher />}
onSubtab={(value) => surface.subtab(value)}
/>
</Tabs.Content>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
headerAction={<SettingsServerSwitcher />}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -392,134 +393,31 @@ function RootSettings() {
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={sourceServer() === server ? sourceDirectory() : undefined}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders
directory={undefined}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels
active={surface.view().tab === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
<Show when={singleEntry()}>
{(entry) => (
<Tabs.Content value="servers" class="settings-panel">
<SettingsServerGeneral entry={entry()} onAddServer={addServer} />
</Tabs.Content>
)}
</Show>
</SettingsNavigation>
)
}
function ServerSettings(props: { entry: SettingsServer }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection)
const [state, setState] = createStore<{ worktreeFilterReset: number; modelProvider: string | undefined }>({
worktreeFilterReset: 0,
modelProvider: undefined,
})
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: nestedServerTabs.map((item) => ({
...item,
label: item.value === "general" ? props.entry.name : language.t(item.label),
disabled: item.value !== "general" && !props.entry.connection,
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
})),
},
])
createEffect(() => {
if (!props.entry.connection && surface.view().tab !== "general") surface.select("general")
})
const change = (value: string) => {
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
surface.select(value)
}
return (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToSettings")}
onBack={() => surface.back()}
onChange={change}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsServerGeneral
entry={props.entry}
nested
onServerChange={(server) => surface.replaceServer(ServerConnection.key(server))}
/>
</Tabs.Content>
<Show when={props.entry.connection} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={activeDirectory()}
resetProjectFilter={() => state.worktreeFilterReset}
headerAction={<SettingsServerSwitcher />}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders
directory={undefined}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels
active={surface.view().tab === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
<Tabs.Content value="servers" class="settings-panel">
<Show
when={multiple() ? undefined : inventory()[0]}
fallback={
<SettingsServerList
servers={inventory()}
selected={surface.server()?.key}
onAddServer={addServer}
onServerChange={selectServer}
/>
}
>
{(entry) => <SettingsServerGeneral entry={entry()} onAddServer={addServer} />}
</Show>
</Tabs.Content>
</SettingsNavigation>
)
}
@@ -558,7 +456,7 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
<SettingsProjectGeneral
server={props.server}
project={props.project}
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
onOpenServer={() => surface.openServers(ServerConnection.key(props.server))}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
@@ -573,10 +471,6 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
)
}
function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) {
return list.find((item) => ServerConnection.key(item) === key)
}
function useSettingsDirectory(server: Accessor<ServerConnection.Any | undefined>) {
const surface = useSettingsSurface()
const tabs = useTabs()
+46 -25
View File
@@ -1,8 +1,10 @@
import { useLocation, useNavigate } from "@solidjs/router"
import { batch, createEffect, on } from "solid-js"
import { batch, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { ServerConnection } from "@/runtime/server/registry"
import { useCommand } from "@/shell/commands/command"
import { createSettingsPages } from "@/runtime/extension/settings-pages"
import { useSettingsServers } from "./servers/inventory"
@@ -10,13 +12,11 @@ import {
isExtensionTab,
isProjectTab,
isRootTab,
isServerTab,
parseSettingsView,
settingsViewUrl,
type SettingsExtensionTab,
type SettingsProjectTab,
type SettingsRootTab,
type SettingsServerTab,
type SettingsTransientView,
type SettingsView,
} from "./route"
@@ -38,6 +38,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const layout = useLayout()
const command = useCommand()
const servers = useSettingsServers()
const tabs = useTabs()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view?: SettingsTransientView }
@@ -47,8 +48,30 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
const view = () =>
parseSettingsView(location.search, servers().length > 1, location.state?.settings?.view, extensions.tabs())
const view = () => parseSettingsView(location.search, location.state?.settings?.view, extensions.tabs())
// The server of the tab settings opened from, which per-server pages show until another is selected.
const sourceServer = createMemo(() => {
const route = source()
if (route.type === "session") return route.server
if (route.type === "draft")
return tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)?.server
return layout.home.selection().server
})
const server = createMemo(() => {
const list = servers()
const key = view().server ?? sourceServer()
return (
list.find((item) => item.key === key) ??
list.find((item) => !!item.connection && ServerConnection.local(item.connection)) ??
list[0]
)
})
const [search, setSearch] = createStore<{
query: string
@@ -103,6 +126,9 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
active: open,
route: source,
view,
/** The server per-server pages show; undefined only while no server is listed. */
server,
sourceServer,
extensions,
search: {
state: search,
@@ -141,19 +167,21 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
open(tab: SettingsRootTab | SettingsExtensionTab = "general") {
show({ type: "root", tab })
},
openServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab })
/** Shows another server on the per-server pages, staying on the current page. */
selectServer(server: string) {
const current = view()
if (current.type !== "root") return
show({ ...current, server, target: undefined })
},
replaceServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab })
openServers(server?: string) {
show({ type: "root", tab: "servers", server })
},
replace(view: SettingsView) {
show(view)
},
openProject(input: { server: string; project: string; tab?: SettingsProjectTab }) {
show({
type: "project",
...input,
parent: servers().length > 1 ? "server" : "root",
tab: input.tab ?? "general",
})
show({ type: "project", ...input, tab: input.tab ?? "general" })
},
select(tab: string) {
const current = view()
@@ -161,11 +189,9 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const next: SettingsView =
current.type === "root" && (isRootTab(tab) || isExtensionTab(tab, extensions.tabs()))
? { ...current, tab }
: current.type === "server" && isServerTab(tab)
: current.type === "project" && isProjectTab(tab)
? { ...current, tab }
: current.type === "project" && isProjectTab(tab)
? { ...current, tab }
: current
: current
show({ ...next, target: undefined, subtab: undefined })
},
@@ -181,12 +207,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
return
}
const parent: SettingsView =
current.type === "server" || current.parent === "root"
? { type: "root", tab: current.type === "server" ? "general" : "projects" }
: { type: "server", server: current.server, tab: "projects" }
show(parent)
show({ type: "root", tab: "projects", server: current.server })
},
close() {
if (open()) command.trigger("common.goBack")
@@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, type Component } from "solid-js"
import { Show, createEffect, createMemo, type Component, type JSX } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
@@ -21,6 +21,7 @@ export const SettingsProjects: Component<{
server: ServerConnection.Any
active: boolean
onOpenProject: (project: LocalProject) => void
headerAction?: JSX.Element
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
@@ -58,15 +59,18 @@ export const SettingsProjects: Component<{
<>
<div class="settings-tab-header" classList={{ "settings-tab-header--stacked": searchable() }}>
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Show when={projects().length > 0}>
<Button variant="ghost-muted" icon="plus" onClick={addProject}>
{language.t("home.project.add")}
</Button>
</Show>
<div class="settings-tab-header-actions">
<Show when={projects().length > 0}>
<Button variant="ghost-muted" icon="plus" onClick={addProject}>
{language.t("home.project.add")}
</Button>
</Show>
{props.headerAction}
</div>
</div>
<Show when={searchable()}>
<SettingsSearchField
@@ -1,4 +1,4 @@
import type { Component } from "solid-js"
import type { Component, JSX } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -49,6 +49,7 @@ export const SettingsWorkspaces: Component<{
activeDirectory?: string
resetProjectFilter?: () => number
projectID?: string
headerAction?: JSX.Element
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
@@ -387,10 +388,11 @@ export const SettingsWorkspaces: Component<{
<>
<div class="settings-tab-header settings-workspaces-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<div class="settings-tab-header-actions">{props.headerAction}</div>
</div>
</div>
+39 -2
View File
@@ -34,7 +34,7 @@ test("SSH health is checked only with an active tunnel, and cancellation clears
},
)
return { dispose, setState, health: () => health[ServerConnection.key(connection)] }
return { dispose, setState, health: () => health.status[ServerConnection.key(connection)] }
})
try {
@@ -94,7 +94,7 @@ test("a late failure from the old endpoint cannot overwrite the new endpoint che
},
)
return { dispose, setState, health: () => health[ServerConnection.key(connection)] }
return { dispose, setState, health: () => health.status[ServerConnection.key(connection)] }
})
try {
@@ -109,3 +109,40 @@ test("a late failure from the old endpoint cannot overwrite the new endpoint che
app.dispose()
}
})
test("retry probes again before the next poll and resolves once that round answers", async () => {
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
const connection: ServerConnection.Http = { type: "http", http: { url: "http://127.0.0.1:12346" } }
const app = createRoot((dispose) => {
const health = createServerHealth(
() => [connection],
() => true,
() => {
const request = Promise.withResolvers<ServerHealth>()
requests.push(request)
return request.promise
},
)
return { dispose, retry: health.retry, health: () => health.status[ServerConnection.key(connection)] }
})
try {
requests[0]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()?.healthy).toBe(false)
const state = { done: false }
const retried = app.retry().then(() => (state.done = true))
expect(requests).toHaveLength(2)
await Promise.resolve()
expect(state.done).toBe(false)
requests[1]?.resolve({ healthy: true })
await retried
expect(app.health()?.healthy).toBe(true)
} finally {
app.dispose()
}
})