mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user