feat(desktop): redesign settings with search (#48174)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1
2026-09-12 07:05:48 +02:00
committed by GitHub
Unverified
parent 2253d4c31d
commit 5d1841e0aa
30 changed files with 2009 additions and 140 deletions
@@ -35,7 +35,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeHidden()
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
@@ -192,7 +192,7 @@ test("clearing project fields sends explicit removal values", async ({ page }) =
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
@@ -22,7 +22,9 @@ test.beforeEach(async ({ page }) => {
)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeFocused()
await expect(
page.getByTestId("settings-screen").getByRole("combobox", { name: "Search settings", exact: true }),
).toBeFocused()
})
for (const viewport of [
@@ -38,7 +40,7 @@ for (const viewport of [
test("every settings page leaves room below its final content", async ({ page }) => {
await page.setViewportSize(viewport)
const settings = page.getByTestId("settings-screen")
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
const panel = settings.locator(".settings-content > .settings-panel:visible")
if (viewport.bottom) {
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
await toggle.locator('[data-slot="switch-control"]').click()
@@ -31,7 +31,7 @@ for (const viewport of [
const panel = settings.getByRole("tabpanel")
const main = page.getByRole("main")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(settings).toBeFocused()
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
await page.setViewportSize(viewport)
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
@@ -0,0 +1,387 @@
import { expect, test, type Page } from "@playwright/test"
import en from "../../src/runtime/i18n/en"
import { clientSettings } from "../../src/settings/search-catalog"
import { createMockServerHandler, mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const directory = "/projects/opencode"
const config = {
directory,
project: {
id: "proj_search",
canonical: directory,
name: "OpenCode",
icon: { color: "orange" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
}
function projectList(count: number) {
return Array.from({ length: count }, (_, index) => ({
...config.project,
id: `proj_search_${index}`,
canonical: `${directory}-${index}`,
name: `OpenCode ${String(index).padStart(2, "0")}`,
}))
}
function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
settings,
search: settings.getByRole("combobox", { name: "Search settings", exact: true }),
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
}
}
async function findShortcut(page: Page) {
// Browser emulation can report a different OS than the machine running Playwright.
const key = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f"))
await page.keyboard.press(key)
}
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, config)
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
await page.goto("/")
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const view = ui(page)
await expect(view.search).toBeFocused()
// Readiness includes the server-backed project inventory, not just the settings shell.
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.search.clear()
})
test("autofocus, pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
const terminal = view.results.getByRole("option", { name: "Terminal Font, Appearance", exact: true })
const font = view.results.getByRole("option", { name: "UI Font, Appearance", exact: true })
await expect(view.results.getByRole("option")).toHaveText([
"Code FontAppearance",
"Terminal FontAppearance",
"UI FontAppearance",
])
await code.click()
await expect(code).toBeFocused()
await expect(view.settings.getByRole("textbox", { name: "Code Font", exact: true })).toBeInViewport()
await code.press("ArrowDown")
await expect(terminal).toBeFocused()
await expect(terminal).toHaveAttribute("aria-selected", "true")
await terminal.press("Enter")
await expect(terminal).toBeFocused()
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
await terminal.press("End")
await expect(font).toBeFocused()
await font.press("Home")
await expect(code).toBeFocused()
await terminal.focus()
await expect(terminal).toHaveAttribute("aria-selected", "true")
await terminal.press("Enter")
await expect(view.settings.locator('[data-search-target="row"]')).toContainText("Terminal Font")
await findShortcut(page)
await expect(view.search).toBeFocused()
expect(await view.search.evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd])).toEqual([
0, 4,
])
await view.settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(view.settings).toBeHidden()
await findShortcut(page)
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeFocused()
})
test("page priority, compact icons, section context, and the minimal empty state", async ({ page }) => {
const view = ui(page)
await view.search.fill("work")
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
await expect(pageResult).toHaveCSS("min-height", "32px")
await expect(pageResult.locator("svg")).toHaveCount(1)
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
await view.search.fill("Agent")
await expect(view.results.getByRole("option", { name: "Agent, Desktop notifications", exact: true })).toHaveText(
"AgentDesktop notifications",
)
await expect(view.results.getByRole("option", { name: "Agent, Sound effects", exact: true })).toHaveText(
"AgentSound effects",
)
await view.search.fill("zzzzzzzzzz")
await expect(view.results.getByRole("option")).toHaveCount(0)
await expect(view.settings.getByRole("status")).toHaveText("No setting found")
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Escape")
await expect(view.search).toHaveValue("")
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
})
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
const view = ui(page)
for (const entry of [
{ tab: "Models", search: "Search models" },
{ tab: "Shortcuts", search: "Search shortcuts" },
{ tab: "Models", search: "Search models" },
]) {
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
}
await view.search.fill("shortcuts")
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
await result.click()
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
await view.search.fill(en[entry.label as keyof typeof en])
const result = view.results.locator(`[data-setting-target="${entry.target}"]`)
await expect(result).toHaveCount(1)
await result.click()
await expect(view.settings.locator(`[data-action="${entry.target}"]`)).toBeInViewport()
}
})
test("qualified project results preserve query and selection on return", async ({ page }) => {
const view = ui(page)
await view.search.fill("project name")
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
await view.search.fill("OpenCode")
const project = view.results.getByRole("option")
await expect(project).toHaveText("OOpenCodeProjects")
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "14px")
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
await view.search.fill("OpenCode name")
const name = view.results.getByRole("option")
await expect(name).toHaveText("Project nameGeneral")
await name.click()
await expect(view.search).toHaveCount(0)
await expect(view.settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(view.settings.getByRole("heading", { name: "OpenCode", exact: true })).toHaveCSS("line-height", "20px")
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
await page.keyboard.press("Escape")
await expect(view.search).toBeFocused()
await expect(view.search).toHaveValue("OpenCode name")
await expect(name).toHaveAttribute("aria-selected", "true")
})
test("returning from a project restores a scrolled result list", async ({ page }) => {
await page.route("**/api/project", (route) =>
route.fulfill({
json: projectList(30),
}),
)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(30)
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
await project.scrollIntoViewIfNeeded()
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const scroll = await view.results.evaluate((list) => list.scrollTop)
await project.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toBeFocused()
await expect(project).toHaveAttribute("aria-selected", "true")
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBe(scroll)
})
test("IME confirmation does not activate a search result", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
await expect(view.results.getByRole("option")).toHaveCount(3)
await view.search.dispatchEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Enter")
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
})
test("header highlight runs once per search activation and finishes cleanly", async ({ page }) => {
const view = ui(page)
await view.settings.evaluate((root) => {
root.setAttribute("data-search-flashes", "0")
root.addEventListener("animationstart", (event) => {
if (!(event instanceof AnimationEvent) || event.animationName !== "settings-search-reveal") return
root.setAttribute("data-search-flashes", String(Number(root.getAttribute("data-search-flashes")) + 1))
})
})
await view.search.fill("skills")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("tab", { name: "Skills", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
await view.settings.evaluate(async (root) => {
await Promise.all(
root
.getAnimations({ subtree: true })
.filter(
(animation) => animation instanceof CSSAnimation && animation.animationName === "settings-search-reveal",
)
.map((animation) => animation.finished),
)
})
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
for (const tab of ["MCPs", "Plugins", "Skills"]) {
await view.settings.getByRole("tab", { name: tab, exact: true }).click()
await expect(view.settings.getByRole("tab", { name: tab, exact: true })).toHaveAttribute("aria-selected", "true")
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
}
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
await view.results.getByRole("option").click()
await expect(view.settings).toHaveAttribute("data-search-flashes", "2")
await view.search.fill("about")
await view.results.getByRole("option", { name: "About", exact: true }).click()
await expect(view.settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(view.settings).toHaveAttribute("data-search-flashes", "3")
})
test("multi-server results navigate to the named server and hide search in nested views", async ({ page }) => {
const server = "http://127.0.0.1:4097"
const remote = createMockServerHandler({
...config,
directory: "/remote/opencode",
project: { ...config.project, canonical: "/remote/opencode" },
})
page.on("close", () => void remote.dispose())
await installSseTransport(page, { server })
await page.route(`${server}/api/**`, async (route) => {
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
const response = await remote.handler(
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
)
await route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
await page.addInitScript(
(server) =>
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
),
server,
)
await page.reload()
const view = ui(page)
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("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 models")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server OpenCode 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")
})
for (const direction of ["ltr", "rtl"] as const) {
test.describe(`search layout ${direction}`, () => {
test.use({
viewport: { width: 390, height: 844 },
colorScheme: direction === "rtl" ? "dark" : "light",
contextOptions: { reducedMotion: "reduce" },
})
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
const view = ui(page)
await page.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
const input = await view.search.boundingBox()
const content = await view.settings.locator(".settings-content").boundingBox()
await view.search.fill("e")
await expect.poll(() => view.search.boundingBox()).toEqual(input)
await expect.poll(() => view.settings.locator(".settings-content").boundingBox()).toEqual(content)
await view.search.fill("terminal font")
await view.results.getByRole("option").click()
await expect(view.results).toBeHidden()
await expect(view.search).toHaveAttribute("aria-expanded", "false")
await expect(view.search).not.toHaveAttribute("aria-activedescendant", /.+/)
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
await findShortcut(page)
await expect(view.search).toBeFocused()
await expect(view.results).toBeVisible()
await expect(view.search).toHaveAttribute("aria-activedescendant", /.+/)
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
})
})
}
@@ -501,7 +501,7 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
await expect(projectNameSwitch).toBeChecked()
await expect(projectNames).toHaveText(["tab-project"])
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
await expect(settings.getByRole("complementary")).toHaveCSS("width", "240px")
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
+8
View File
@@ -999,6 +999,13 @@ export const dict = {
"settings.backToApp": "Back to app",
"settings.backToSettings": "Back to settings",
"settings.backToProjects": "Back to projects",
"settings.search.placeholder": "Search settings",
"settings.search.results": "Settings results",
"settings.search.result": "{{title}}, {{scope}}, {{page}}",
"settings.search.page": "{{title}}, {{scope}}",
"settings.search.result.unscoped": "{{title}}, {{page}}",
"settings.search.empty": "No setting found",
"settings.search.refine": "Narrow your search to see more specific results.",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
@@ -1043,6 +1050,7 @@ export const dict = {
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
"settings.projects.search.placeholder": "Search projects",
"settings.projects.server.all": "All servers",
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
"settings.extensions.description": "Manage extensions available on this server",
+4 -2
View File
@@ -11,14 +11,16 @@ import { Spinner } from "@opencode/ui/spinner"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
export function SshServerSettings(props: { filter: string; id?: string; domain: ServerCollectionController }) {
const ssh = useSsh()
const language = useLanguage()
return (
<For
each={ssh.servers.filter(
(item) =>
item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
item.saved &&
(!props.id || item.config.id === props.id) &&
`${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
)}
>
{(item) => {
@@ -83,6 +83,7 @@ const WorkspaceDestinationSetting: Component = () => {
description={language.t("settings.workspaces.default.description")}
>
<Select
data-action="settings-workspace-destination"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
@@ -467,7 +468,13 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
<Button
data-action="settings-check-updates"
size="normal"
variant="neutral"
disabled={!updater.action().run}
onClick={() => updater.run()}
>
{language.t(updater.action().label)}
</Button>
</SettingsRow>
@@ -1,4 +1,4 @@
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode/ui/button"
@@ -342,7 +342,7 @@ export function createKeybindSettingsController(
}
}
export function SettingsKeybinds() {
export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean }) {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
@@ -352,6 +352,8 @@ export function SettingsKeybinds() {
return (
<SettingsKeybindsView
visible={props.active}
autofocus={props.autofocus}
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
@@ -365,6 +367,8 @@ export function SettingsKeybinds() {
}
function SettingsKeybindsView(props: {
visible?: boolean
autofocus?: boolean
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
@@ -375,6 +379,20 @@ function SettingsKeybindsView(props: {
onReset: () => void
}) {
const language = useLanguage()
let search: HTMLInputElement | undefined
createEffect(
on(
() => props.visible ?? true,
(visible) => {
if (!visible) return
const frame = requestAnimationFrame(() => {
if (props.visible !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
@@ -393,6 +411,7 @@ function SettingsKeybindsView(props: {
</div>
<div class="settings-tab-search">
<TextInput
ref={search}
type="search"
appearance="base"
value={store.filter}
+17 -2
View File
@@ -4,7 +4,7 @@ import { Switch } from "@opencode/ui/switch"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, For, Show } from "solid-js"
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
@@ -24,10 +24,24 @@ export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component = () => {
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
let search: HTMLInputElement | undefined
createEffect(
on(
() => props.active ?? true,
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
ModelProvidersSchema,
@@ -67,6 +81,7 @@ export const SettingsModels: Component = () => {
</div>
<div class="settings-tab-search">
<TextInput
ref={search}
type="search"
appearance="base"
value={list.filter()}
+61 -39
View File
@@ -3,6 +3,10 @@ import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettingsSurface } from "./surface"
import { SettingsSearch } from "./search"
import "./search.css"
export type SettingsNavItem = {
value: string
@@ -27,14 +31,27 @@ export function SettingsNavigation(props: {
mobileAction?: JSX.Element
children: JSX.Element
}) {
const surface = useSettingsSurface()
const searchable = () => surface.view().type === "root"
const language = useLanguage()
const back = () => {
if (!searchable() && surface.search.back()) return
props.onBack()
}
const backLabel = () =>
!searchable() && surface.search.state.selected ? language.t("settings.backToSettings") : props.backLabel
const current = () => props.groups.flatMap((group) => group.items).find((item) => item.value === props.value)
const change = (value: string) => {
surface.search.clear()
props.onChange(value)
}
return (
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={props.onChange} class="settings">
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={change} class="settings">
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<button type="button" class="settings-back" onClick={back}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
<span>{backLabel()}</span>
</button>
<div class="settings-mobile-actions">
{props.mobileAction}
@@ -45,7 +62,7 @@ export function SettingsNavigation(props: {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={props.value} onChange={props.onChange}>
<Menu.RadioGroup value={props.value} onChange={change}>
<For each={props.groups}>
{(group, index) => (
<>
@@ -78,45 +95,50 @@ export function SettingsNavigation(props: {
</Menu>
</div>
</div>
<Tabs.List>
<aside class="settings-sidebar" data-searchable={searchable()}>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<button type="button" class="settings-back" onClick={back}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
<span>{backLabel()}</span>
</button>
<div class="settings-nav-groups">
<For each={props.groups}>
{(group) => (
<div class="settings-nav-group">
<Show when={group.label || group.action}>
<div class="settings-nav-group-header" data-component="settings-nav-group-header">
<span>{group.label}</span>
{group.action}
</div>
</Show>
<For each={group.items}>
{(item) => (
<Tabs.Trigger
value={item.value}
disabled={item.disabled}
onPointerEnter={(event: PointerEvent) => {
if (item.disabled || event.pointerType === "touch") return
item.onPrefetch?.()
}}
onFocus={() => !item.disabled && item.onPrefetch?.()}
>
<Icon name={item.icon} />
{item.label}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
<Show when={searchable()}>
<SettingsSearch />
</Show>
<Show when={!searchable() || !surface.search.state.query.trim()}>
<Tabs.List class="settings-nav-groups">
<For each={props.groups}>
{(group) => (
<div class="settings-nav-group">
<Show when={group.label || group.action}>
<div class="settings-nav-group-header" data-component="settings-nav-group-header">
<span>{group.label}</span>
{group.action}
</div>
</Show>
<For each={group.items}>
{(item) => (
<Tabs.Trigger
value={item.value}
disabled={item.disabled}
onPointerEnter={(event: PointerEvent) => {
if (item.disabled || event.pointerType === "touch") return
item.onPrefetch?.()
}}
onFocus={() => !item.disabled && item.onPrefetch?.()}
>
<Icon name={item.icon} />
{item.label}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</Tabs.List>
</Show>
</div>
</Tabs.List>
{props.children}
</aside>
<div class="settings-content">{props.children}</div>
</Tabs>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { IconProps } from "@opencode/ui/icon"
import type { useLanguage } from "@/runtime/i18n/language"
import type { SettingsRootTab } from "./surface"
export const pageIcons = {
general: "sliders",
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
models: "models",
extensions: "extensions",
servers: "server",
experimental: "flask",
about: "info",
} as const satisfies Record<SettingsRootTab, IconProps["name"]>
export const pageLabels = {
general: "settings.tab.preferences",
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
models: "settings.models.title",
extensions: "settings.tab.extensions",
servers: "settings.section.server",
experimental: "settings.tab.experimental",
about: "settings.tab.about",
} as const satisfies Record<SettingsRootTab, Parameters<ReturnType<typeof useLanguage>["t"]>[0]>
@@ -9,6 +9,7 @@ import { useMcpToggle } from "@/providers/connect/mcp"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import type { SettingsView } from "@/settings/surface"
import "@/settings/settings.css"
interface McpRowItem {
@@ -20,7 +21,10 @@ interface PluginRowItem {
name: string
}
export const SettingsExtensions: Component = () => {
export const SettingsExtensions: Component<{
subtab?: SettingsView["subtab"]
onSubtab: (value: SettingsView["subtab"]) => void
}> = (props) => {
const language = useLanguage()
const serverSdk = useServerSDK()
const data = useData()
@@ -67,7 +71,14 @@ export const SettingsExtensions: Component = () => {
</div>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs settings-subtabs">
<Tabs
variant="pill"
value={props.subtab ?? "mcps"}
onChange={(value) => {
if (value === "mcps" || value === "plugins" || value === "skills") props.onSubtab(value)
}}
class="settings-extensions-tabs settings-subtabs"
>
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
+241
View File
@@ -0,0 +1,241 @@
import type { useLanguage } from "@/runtime/i18n/language"
import type { SettingsProjectTab, SettingsRootTab, SettingsServerTab } from "./surface"
type Label = Parameters<ReturnType<typeof useLanguage>["t"]>[0]
// Metadata only: search must not mount settings pages or fetch their catalogs.
type Entry<Tab> = {
tab: Tab
label: Label
target?: string
keywords?: string
description?: Label
section?: Label
subtab?: "mcps" | "plugins" | "skills" | "lsps"
available?: "desktop" | "browser" | "dev" | "mobile-dev"
}
export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "general", label: "settings.tab.preferences" },
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
{
tab: "general",
label: "settings.workspaces.default.title",
target: "settings-workspace-destination",
description: "settings.workspaces.default.description",
keywords: "worktree workspace default destination",
},
{
tab: "general",
label: "command.permissions.autoaccept.enable",
target: "settings-auto-accept-permissions",
keywords: "permissions approve allow auto accept",
},
{
tab: "general",
label: "settings.general.row.terminalPlacement.title",
target: "settings-terminal-placement",
keywords: "terminal side bottom position",
},
{
tab: "general",
label: "settings.general.row.followUpBehavior.title",
target: "settings-follow-up-behavior",
keywords: "queue steer follow up",
},
{
tab: "general",
label: "settings.general.row.pinchZoom.title",
target: "settings-pinch-zoom",
available: "desktop",
},
{
tab: "general",
label: "session.review.wrapLines",
target: "settings-mobile-diff-wrap",
keywords: "diff wrap lines",
},
{
tab: "general",
label: "settings.general.row.mobileTitlebarBottom.title",
target: "settings-mobile-titlebar-bottom",
available: "mobile-dev",
},
{
tab: "general",
label: "settings.timeline.detail",
target: "settings-timeline-detail",
section: "settings.timeline.title",
keywords: "thinking reasoning tools timeline summary detailed",
},
{
tab: "general",
label: "settings.general.row.releaseNotes.title",
target: "settings-release-notes",
section: "settings.general.section.updates",
available: "desktop",
},
{
tab: "general",
label: "settings.updates.row.check.title",
target: "settings-check-updates",
section: "settings.general.section.updates",
available: "desktop",
},
{
tab: "general",
label: "settings.general.row.showStatus.title",
target: "settings-show-status",
section: "settings.general.section.advanced",
},
{
tab: "general",
label: "settings.general.row.showCustomAgents.title",
target: "settings-show-custom-agents",
section: "settings.general.section.general",
},
{
tab: "appearance",
label: "settings.general.row.colorScheme.title",
target: "settings-color-scheme",
keywords: "dark mode light mode system",
},
{ tab: "appearance", label: "settings.general.row.theme.title", target: "settings-theme" },
{
tab: "appearance",
label: "settings.general.row.uiFont.title",
target: "settings-ui-font",
keywords: "interface typeface",
},
{
tab: "appearance",
label: "settings.general.row.font.title",
target: "settings-code-font",
keywords: "code typeface",
},
{
tab: "appearance",
label: "settings.general.row.terminalFont.title",
target: "settings-terminal-font",
keywords: "terminal typeface",
},
{
tab: "notifications",
label: "settings.general.notifications.agent.title",
target: "settings-notifications-agent",
section: "settings.general.section.notifications",
description: "settings.general.notifications.agent.description",
keywords: "desktop notifications agent",
},
{
tab: "notifications",
label: "settings.general.notifications.permissions.title",
target: "settings-notifications-permissions",
section: "settings.general.section.notifications",
description: "settings.general.notifications.permissions.description",
keywords: "desktop notifications permissions",
},
{
tab: "notifications",
label: "settings.general.notifications.errors.title",
target: "settings-notifications-errors",
section: "settings.general.section.notifications",
description: "settings.general.notifications.errors.description",
keywords: "desktop notifications errors",
},
{
tab: "notifications",
label: "settings.general.sounds.agent.title",
target: "settings-sounds-agent",
section: "settings.general.section.sounds",
description: "settings.general.sounds.agent.description",
keywords: "sound audio agent",
},
{
tab: "notifications",
label: "settings.general.sounds.permissions.title",
target: "settings-sounds-permissions",
section: "settings.general.section.sounds",
description: "settings.general.sounds.permissions.description",
keywords: "sound audio permissions",
},
{
tab: "notifications",
label: "settings.general.sounds.errors.title",
target: "settings-sounds-errors",
section: "settings.general.section.sounds",
description: "settings.general.sounds.errors.description",
keywords: "sound audio errors",
},
{
tab: "experimental",
label: "settings.general.row.browserPane.title",
target: "settings-experimental-browser",
available: "browser",
},
{
tab: "experimental",
label: "settings.appearance.row.tabs.title",
target: "settings-tab-layout",
keywords: "vertical horizontal tabs",
},
{ tab: "experimental", label: "settings.appearance.row.projectName.title", target: "settings-show-project-name" },
{
tab: "experimental",
label: "settings.general.row.showProjectIcon.title",
target: "settings-show-project-icon",
available: "dev",
},
]
export const serverSettings: Entry<SettingsServerTab>[] = [
{ 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" },
{ tab: "models", label: "settings.models.title", keywords: "model picker visibility" },
{ tab: "extensions", label: "settings.tab.extensions" },
{
tab: "extensions",
subtab: "mcps",
label: "settings.extensions.tab.mcps",
keywords: "model context protocol tools",
},
{ tab: "extensions", subtab: "plugins", label: "status.popover.tab.plugins" },
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
{
tab: "general",
label: "settings.general.row.shell.title",
target: "settings-shell",
description: "settings.general.row.shell.description",
keywords: "bash zsh powershell",
},
{
tab: "general",
label: "settings.server.preferences.websearch.title",
target: "settings-websearch",
description: "settings.server.preferences.websearch.description",
keywords: "web search provider",
},
]
export const projectSettings: Entry<SettingsProjectTab>[] = [
{ tab: "general", label: "project.settings.name.title", target: "settings-project-name", keywords: "rename" },
{ tab: "general", label: "dialog.project.edit.icon", target: "settings-project-icon" },
{ tab: "general", label: "dialog.project.edit.color", target: "settings-project-color" },
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
{ tab: "extensions", label: "settings.tab.extensions" },
{
tab: "extensions",
subtab: "mcps",
label: "settings.extensions.tab.mcps",
keywords: "model context protocol tools",
},
{ tab: "extensions", subtab: "plugins", label: "status.popover.tab.plugins" },
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
{ tab: "extensions", subtab: "lsps", label: "project.settings.extensions.tab.lsps", keywords: "language servers" },
]
+118
View File
@@ -0,0 +1,118 @@
import type { useLanguage } from "@/runtime/i18n/language"
import type { LocalProject } from "@/shell/state/layout"
import { displayName } from "@/shell/layout/helpers"
import { clientSettings, projectSettings, serverSettings } from "./search-catalog"
import { pageLabels } from "./pages"
import type { SettingsSearchResult } from "./search-results"
import type { SettingsServerTab, SettingsView } from "./surface"
export type SettingsSearchServer = {
key: string
name: string
connected: boolean
projects: readonly LocalProject[]
}
export function settingsSearchIndex(input: {
servers: readonly SettingsSearchServer[]
desktop: boolean
browser: boolean
dev: boolean
mobile: boolean
translate: ReturnType<typeof useLanguage>["t"]
}) {
const items: SettingsSearchResult[] = []
const add = (
entry: (typeof clientSettings)[number],
view: SettingsView,
owner: string,
server?: string,
project?: string,
projectName?: string,
) => {
const page =
!server && view.tab === "general" && view.target
? `${input.translate(pageLabels.general)} / ${input.translate(entry.section ?? "settings.general.section.general")}`
: input.translate(
entry.section ??
(view.type !== "root" && view.tab === "general"
? "settings.general.section.general"
: pageLabels[view.tab]),
)
items.push({
id: JSON.stringify([server, project, view.tab, view.target, view.subtab, entry.label]),
title: input.translate(entry.label),
description: entry.description ? input.translate(entry.description) : "",
keywords: entry.keywords ?? "",
owner,
page,
server,
project,
projectName,
topLevel: !view.target && !view.subtab && !project,
view,
})
}
clientSettings.forEach((entry) => {
if (entry.available === "desktop" && !input.desktop) return
if (entry.available === "browser" && !input.browser) return
if ((entry.available === "dev" || entry.available === "mobile-dev") && !input.dev) return
if (entry.available === "mobile-dev" && !input.mobile) return
add(entry, { type: "root", tab: entry.tab, target: entry.target }, "")
})
input.servers.forEach((server) => {
const view = (tab: SettingsServerTab, target?: string, subtab?: SettingsView["subtab"]): SettingsView => {
if (input.servers.length === 1) return { type: "root", tab: tab === "general" ? "servers" : tab, target, subtab }
return { type: "server", server: server.key, tab, target, subtab }
}
items.push({
id: `server:${server.key}`,
entity: true,
title: server.name,
description: "",
keywords: "",
owner: input.translate("status.popover.tab.servers"),
page: input.translate("settings.server.section.connection"),
server: server.key,
view: view("general"),
})
if (!server.connected) return
serverSettings.forEach((entry) => add(entry, view(entry.tab, entry.target, entry.subtab), server.name, server.key))
server.projects.forEach((project) => {
const destination: SettingsView = {
type: "project",
server: server.key,
project: project.worktree,
tab: "general",
parent: input.servers.length > 1 ? "server" : "root",
}
const name = displayName(project)
items.push({
id: `project:${server.key}:${project.worktree}`,
entity: true,
title: name,
description: "",
keywords: "",
owner: server.name,
page: input.translate("settings.tab.projects"),
server: server.key,
project: project.worktree,
projectInfo: project,
view: destination,
})
projectSettings.forEach((entry) => {
if (entry.target === "settings-project-color" && project.icon?.override) return
add(
entry,
{ ...destination, tab: entry.tab, target: entry.target, subtab: entry.subtab },
`${server.name} · ${name}`,
server.key,
project.worktree,
name,
)
})
})
})
return items
}
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test"
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: Record<string, string> = { ...dict, ...en }
const project = { id: "proj_opencode", name: "OpenCode", worktree: "/projects/opencode", expanded: false }
const servers: SettingsSearchServer[] = [
{ key: "local", name: "Local server", connected: true, projects: [project] },
{ 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,
desktop: false,
browser: false,
dev: false,
mobile: false,
translate: (key) => strings[key],
...input,
})
}
describe("settings search index", () => {
test("uses concrete server identity and adapts single-server destinations", () => {
const multi = rankSettings("models", index(), root).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 },
])
const single = rankSettings("models", index({ servers: [servers[0]] }), root).find((item) => item.topLevel)!
expect(single.server).toBe("local")
expect(single.view).toEqual({ type: "root", tab: "models", target: undefined, subtab: undefined })
})
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({
type: "root",
tab: "servers",
target: undefined,
subtab: undefined,
})
expect(rankSettings("font", items, root)).toHaveLength(3)
})
test("only advertises settings supported by this platform and channel", () => {
const targets = (input: Parameters<typeof index>[0]) => index(input).map((item) => item.view.target)
expect(targets({})).not.toContain("settings-pinch-zoom")
expect(targets({})).not.toContain("settings-experimental-browser")
expect(targets({})).not.toContain("settings-show-project-icon")
expect(targets({ desktop: true })).toContain("settings-pinch-zoom")
expect(targets({ browser: true })).toContain("settings-experimental-browser")
expect(targets({ dev: true })).toContain("settings-show-project-icon")
expect(targets({ dev: true })).not.toContain("settings-mobile-titlebar-bottom")
expect(targets({ dev: true, mobile: true })).toContain("settings-mobile-titlebar-bottom")
})
test("uses section labels and stable identities independent of translated text", () => {
const items = index()
expect(new Set(items.map((item) => item.id)).size).toBe(items.length)
expect(index({ translate: (key) => `translated ${strings[key]}` }).map((item) => item.id)).toEqual(
items.map((item) => item.id),
)
expect(
rankSettings("agent", items, root)
.filter((item) => item.title === "Agent")
.map((item) => item.page),
).toEqual(["Desktop notifications", "Sound effects"])
expect(rankSettings("terminal placement", items, root)[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([])
})
})
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([
"Worktrees",
"Default environment",
])
expect(rankSettings("mcps", index(), root).map((item) => item.view.type)).toEqual(["server", "server"])
expect(rankSettings("project name", index(), root).map((item) => item.title)).toEqual(["Show project names"])
expect(rankSettings("startup", index(), root)).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("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)
.filter((item) => item.project)
.map((item) => item.entity),
).toEqual([true])
expect(rankSettings("opencode", index(), root).map((item) => item.entity)).toEqual([true, 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([
"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"])
})
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([])
})
})
@@ -0,0 +1,71 @@
import fuzzysort from "fuzzysort"
import type { SettingsView } from "./surface"
import type { LocalProject } from "@/shell/state/layout"
export type SettingsSearchResult = {
id: string
title: string
keywords: string
description: string
owner: string
page: string
server?: string
project?: string
projectName?: string
projectInfo?: LocalProject
entity?: boolean
topLevel?: boolean
view: SettingsView
}
export function rankSettings(query: string, items: SettingsSearchResult[], origin: SettingsView) {
const value = normalize(query)
if (!value) return []
return items
.flatMap((item) => {
const name = item.projectName ? normalize(item.projectName) : undefined
if (name && !` ${value} `.includes(` ${name} `)) return []
const query = name ? normalize(` ${value} `.replace(` ${name} `, " ")) : value
if (!query) return []
const tokens = query.split(" ")
const title = normalize(item.title)
const primary = normalize(`${title} ${item.keywords}`)
const description = normalize(item.description)
const context = normalize(`${item.owner} ${item.entity ? "" : item.page}`)
const fuzzy = fuzzysort.single(query, title)?.score ?? 0
// Context qualifies a setting match; a project name alone should not return all its controls.
if (!tokens.some((token) => `${primary} ${description}`.includes(token)) && fuzzy < 0.6) return []
const score =
title === query
? 5
: tokens.every((token) => title.includes(token))
? 4
: tokens.every((token) => primary.includes(token))
? 3
: tokens.every((token) => `${primary} ${description} ${context}`.includes(token))
? 2
: fuzzy >= 0.6
? 1
: 0
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 }]
})
.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)
}
function normalize(value: string) {
return value.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " ")
}
@@ -0,0 +1,58 @@
import type { SettingsView } from "./surface"
/** Reveal one explicit search activation, including targets mounted by an asynchronous scoped page. */
export function revealSettingsSearch(root: HTMLElement, view: SettingsView) {
const state = { disposed: false, row: undefined as HTMLElement | undefined, tabIndex: null as string | null }
const restore = () => {
const row = state.row
if (!row) return
row.removeAttribute("data-search-target")
if (state.tabIndex === null) row.removeAttribute("tabindex")
if (state.tabIndex !== null) row.setAttribute("tabindex", state.tabIndex)
state.row = undefined
}
const finish = (event: AnimationEvent) => {
if (event.target === state.row && event.animationName === "settings-search-reveal") restore()
}
const reveal = () => {
if (state.disposed) return true
const panel = root.querySelector<HTMLElement>(".settings-panel:not([hidden])")
const control = panel?.querySelector<HTMLElement>(
view.target ? `[data-action="${CSS.escape(view.target)}"]` : ".settings-tab-header-row, .settings-about-intro",
)
if (!panel || !control || !control.getClientRects().length) return false
const row =
control.closest<HTMLElement>('[data-component="settings-row"], [data-component="settings-list"] > *') ?? control
if (!view.target) panel.scrollTop = 0
if (view.target) row.scrollIntoView({ block: "center", inline: "nearest" })
state.row = row
state.tabIndex = row.getAttribute("tabindex")
row.setAttribute("data-search-target", view.target ? "row" : "header")
// Selecting another result can reuse the same header before the browser paints the removal.
row.getAnimations({ subtree: true }).forEach((animation) => {
if (!(animation instanceof CSSAnimation) || animation.animationName !== "settings-search-reveal") return
animation.currentTime = 0
animation.play()
})
if (view.type !== "root") {
const focus = view.target ? row : (panel.querySelector<HTMLInputElement>(".settings-tab-search input") ?? row)
if (focus === row) row.tabIndex = -1
focus.focus({ preventScroll: true })
}
return true
}
const observer = new MutationObserver(() => {
if (reveal()) observer.disconnect()
})
root.addEventListener("animationend", finish)
queueMicrotask(() => {
if (!reveal())
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "class"] })
})
return () => {
state.disposed = true
observer.disconnect()
root.removeEventListener("animationend", finish)
restore()
}
}
+157
View File
@@ -0,0 +1,157 @@
.settings-search,
.settings-search-matches {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.settings-search {
position: relative;
}
.settings-search[data-empty="true"] [data-slot="text-input-v2-icon-button"] {
visibility: hidden;
}
.settings-search > [data-component="text-input-v2"] {
width: 100%;
flex-shrink: 0;
}
.settings-search-group,
.settings-search-detail,
.settings-search-note {
color: var(--v2-text-text-muted);
font-size: 12px;
line-height: var(--line-height-compact);
}
.settings-search-results {
overflow-y: auto;
overscroll-behavior: contain;
min-height: 0;
padding: 0;
}
.settings-search-group {
padding: 16px 6px 6px;
overflow-wrap: anywhere;
font-weight: 530;
}
.settings-search-group:first-child {
padding-top: 4px;
}
.settings-search-result {
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
width: 100%;
min-height: 48px;
padding: 8px 6px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: start;
cursor: pointer;
}
.settings-search-title {
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
overflow-wrap: anywhere;
}
.settings-search-result[data-compact="true"] {
min-height: 32px;
}
.settings-search-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--v2-icon-icon-muted);
}
.settings-search-project-icon[data-component="project-avatar-v2"] {
width: 14px;
height: 14px;
}
.settings-search-result[data-highlighted="true"],
.settings-search-result[aria-current="location"],
.settings-search-result:active {
background: var(--v2-overlay-simple-overlay-pressed);
}
.settings-search-result:focus-visible {
outline: none;
background: var(--v2-overlay-simple-overlay-pressed);
}
@media (hover: hover) {
.settings-search-result:hover:not([data-highlighted="true"]):not([aria-current="location"]):not(:active) {
background: var(--v2-overlay-simple-overlay-hover);
}
}
.settings-search-empty {
padding: 20px 6px;
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
.settings-search-note {
padding: 12px 6px 0;
}
[data-component="settings-list"] {
--settings-search-inset: 20px;
}
[data-component="settings-list"][data-variant="catalog"] {
--settings-search-inset: 16px;
}
[data-search-target] {
position: relative;
isolation: isolate;
outline: none;
}
[data-search-target]::before {
content: "";
position: absolute;
inset-block: 0;
inset-inline: calc(-1 * var(--settings-search-inset, 0px));
z-index: -1;
border-radius: 6px;
background: var(--v2-background-bg-layer-03);
pointer-events: none;
animation: settings-search-reveal 1.8s ease-out forwards;
}
[data-search-target="header"]::before {
border-radius: 8px;
}
@keyframes settings-search-reveal {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-search-target]::before {
animation-timing-function: steps(1, end);
}
}
+265
View File
@@ -0,0 +1,265 @@
import { createEffect, createMemo, createUniqueId, For, on, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { useCommand } from "@/shell/commands/command"
import { settingsProjects, useSettingsServers } from "./servers/inventory"
import { useSettingsSurface } from "./surface"
import { pageIcons } from "./pages"
import { rankSettings, type SettingsSearchResult } from "./search-results"
import { settingsSearchIndex } from "./search-index"
export function SettingsSearch() {
const language = useLanguage()
const command = useCommand()
const platform = usePlatform()
const global = useGlobal()
const servers = useSettingsServers()
const surface = useSettingsSurface()
const search = surface.search
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({ narrow: false })
const listID = `settings-results-${createUniqueId()}`
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let results: HTMLDivElement | undefined
onMount(() => {
const screen = root?.closest<HTMLElement>(".settings-screen")
if (!screen) return
setState("narrow", screen.clientWidth < 800)
createResizeObserver(screen, (rect) => setState("narrow", rect.width < 800))
})
command.register("settings.search", () => [
{
id: "settings.search.focus",
title: language.t("settings.search.placeholder"),
keybind: "mod+f",
hidden: true,
onSelect: () => {
input?.focus({ preventScroll: true })
input?.select()
},
},
])
const inventory = createMemo(() =>
servers().map((server) => {
const context = server.connection ? global.ensureServerCtx(server.connection) : undefined
return {
...server,
connected: context?.sdk.connection.status() === "connected",
projects: context ? settingsProjects(context) : [],
}
}),
)
const origin = () => search.state.origin ?? surface.view()
const catalog = createMemo(() =>
settingsSearchIndex({
servers: inventory(),
desktop: platform.platform === "desktop",
browser: !!platform.browserPane,
dev: import.meta.env.VITE_OPENCODE_CHANNEL !== "prod",
mobile: mobile(),
translate: language.t,
}),
)
const matches = createMemo(() => rankSettings(search.state.query, catalog(), origin()))
const shown = createMemo(() => matches().slice(0, 60))
const expanded = () => !!search.state.query.trim() && (!state.narrow || search.state.expanded)
const highlighted = () => shown().find((item) => item.id === search.state.highlighted) ?? shown()[0]
const optionID = (id: string) => `${listID}-${encodeURIComponent(id)}`
createEffect(
on(
() => search.state.query,
() => {
if (results) results.scrollTop = 0
},
{ defer: true },
),
)
const group = (item: SettingsSearchResult) => {
if (!item.server || servers().length > 1) return item.owner
return item.projectName ?? ""
}
const select = (item: SettingsSearchResult) => {
surface.search.open(item.view, item.id)
if (state.narrow && item.view.type === "root") {
input?.blur()
root?.closest<HTMLElement>(".settings-screen")?.focus({ preventScroll: true })
}
}
const clear = () => {
search.clear()
input?.focus()
}
return (
<div
ref={root}
class="settings-search"
data-expanded={search.state.expanded}
data-empty={!search.state.query.trim()}
onKeyDown={(event) => {
if (
event.defaultPrevented ||
event.isComposing ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
return
if (
event.target !== input &&
!(event.target instanceof Element && event.target.closest(".settings-search-result"))
)
return
if (event.key === "Escape" && search.state.query) {
event.preventDefault()
event.stopPropagation()
clear()
return
}
if (event.key === "Enter" && expanded() && highlighted()) {
event.preventDefault()
select(highlighted()!)
return
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
if ((event.key === "Home" || event.key === "End") && event.target === input) return
event.preventDefault()
search.expand()
const index = shown().findIndex((item) => item.id === highlighted()?.id)
const next =
shown()[
event.key === "Home"
? 0
: event.key === "End"
? shown().length - 1
: Math.max(0, Math.min(shown().length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))
]
if (!next) return
search.highlight(next.id)
const row = results?.querySelector<HTMLElement>(`#${CSS.escape(optionID(next.id))}`)
if (event.target !== input) row?.focus({ preventScroll: true })
row?.scrollIntoView({ block: "nearest", inline: "nearest" })
}}
>
<TextInput
ref={input}
type="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={expanded()}
aria-activedescendant={expanded() && highlighted() ? optionID(highlighted()!.id) : undefined}
value={search.state.query}
leadingIcon={<Icon name="magnifying-glass" size="small" />}
placeholder={language.t("settings.search.placeholder")}
aria-label={language.t("settings.search.placeholder")}
aria-controls={search.state.query.trim() ? listID : undefined}
showClearButton
onClearClick={clear}
onFocus={() => search.expand()}
onInput={(event) => {
search.input(event.currentTarget.value)
}}
spellcheck={false}
autocomplete="off"
/>
<Show when={search.state.query.trim()}>
<div class="settings-search-matches">
<div
ref={(element) => {
results = element
queueMicrotask(() => {
if (element.isConnected) element.scrollTop = search.state.scrollTop
})
}}
id={listID}
class="settings-search-results"
role="listbox"
aria-label={language.t("settings.search.results")}
onScroll={(event) => search.scroll(event.currentTarget.scrollTop)}
>
<For each={shown()}>
{(item, index) => (
<>
<Show when={group(item) && (index() === 0 || group(shown()[index() - 1]) !== group(item))}>
<div class="settings-search-group" role="presentation">
<bdi dir="auto">{group(item)}</bdi>
</div>
</Show>
<button
id={optionID(item.id)}
role="option"
aria-selected={highlighted()?.id === item.id}
aria-label={
item.page === item.title && !item.owner
? item.title
: language.t(
item.page === item.title
? "settings.search.page"
: item.owner
? "settings.search.result"
: "settings.search.result.unscoped",
{ title: item.title, scope: item.owner, page: item.page },
)
}
type="button"
class="settings-search-result"
data-compact={item.page === item.title}
data-setting-target={item.view.target}
data-result-id={item.id}
data-highlighted={highlighted()?.id === item.id}
aria-current={search.state.selected === item.id ? "location" : undefined}
title={[item.owner, item.page].filter(Boolean).join(" › ")}
tabIndex={highlighted()?.id === item.id ? 0 : -1}
onFocus={() => search.highlight(item.id)}
onClick={(event) => {
if (item.view.type === "root" && !state.narrow) event.currentTarget.focus({ preventScroll: true })
select(item)
}}
>
<span class="settings-search-label">
<Show
when={item.projectInfo}
fallback={
<Show when={item.topLevel}>
<Icon name={pageIcons[item.view.tab]} />
</Show>
}
>
{(project) => (
<ProjectIcon project={project()} class="settings-search-project-icon" aria-hidden="true" />
)}
</Show>
<bdi dir="auto" class="settings-search-title">
{item.title}
</bdi>
</span>
<Show when={item.page !== item.title}>
<span class="settings-search-detail">{item.page}</span>
</Show>
</button>
</>
)}
</For>
</div>
<Show when={!matches().length}>
<div class="settings-search-empty" role="status">
{language.t("settings.search.empty")}
</div>
</Show>
<Show when={matches().length > shown().length}>
<p class="settings-search-note">{language.t("settings.search.refine")}</p>
</Show>
</div>
</Show>
</div>
)
}
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import type { SshItem } from "@/servers/ssh/types"
import { settingsServers } from "./inventory"
const ssh: SshItem = {
config: { id: "build", target: "dev@example.com", name: "Build server" },
saved: true,
stage: "disconnected",
detail: "",
}
const connection: ServerConnection.Ssh = {
type: "ssh",
id: ssh.config.id,
host: ssh.config.target,
displayName: ssh.config.name,
http: { url: "http://127.0.0.1:4000", password: "secret" },
}
describe("settings server inventory", () => {
test("includes saved SSH servers before they connect", () => {
expect(settingsServers([], [], [ssh])).toEqual([
{
key: ServerConnection.Key.make("ssh:build"),
name: "Build server",
ssh,
},
])
})
test("joins ready SSH state to its live connection", () => {
const ready = { ...ssh, stage: "ready" as const }
expect(settingsServers([connection], [], [ready])).toEqual([
{
key: ServerConnection.Key.make("ssh:build"),
name: "Build server",
connection,
ssh: ready,
wsl: undefined,
},
])
})
test("omits unsaved SSH state and withholds stale connections while disconnected", () => {
expect(settingsServers([], [], [{ ...ssh, saved: false }])).toEqual([])
expect(settingsServers([connection], [], [ssh])[0].connection).toBeUndefined()
})
})
+38 -4
View File
@@ -2,26 +2,50 @@ import { createMemo } from "solid-js"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useWslServers } from "@/servers/wsl/context"
import type { WslServerItem } from "@/servers/wsl/types"
import { useSsh } from "@/servers/ssh/context"
import { sshName, type SshItem } from "@/servers/ssh/types"
import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: ServerCtx) {
const tracked = context.projects.list()
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
return [
...tracked,
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
]
}
export type SettingsServer = {
key: ServerConnection.Key
name: string
connection?: ServerConnection.Any
wsl?: WslServerItem
ssh?: SshItem
}
export function settingsServers(connections: readonly ServerConnection.Any[], wsl: readonly WslServerItem[]) {
export function settingsServers(
connections: readonly ServerConnection.Any[],
wsl: readonly WslServerItem[],
ssh: readonly SshItem[],
) {
const configured = new Map(wsl.map((item) => [item.config.id, item]))
const saved = new Map(ssh.filter((item) => item.saved).map((item) => [`ssh:${item.config.id}`, item]))
const connected = new Set(connections.map(ServerConnection.key))
return [
...connections.map((connection): SettingsServer => {
const key = ServerConnection.key(connection)
const item = configured.get(key)
const remote = saved.get(key)
return {
key,
name: item?.config.distro ?? (serverName(connection) || key),
connection: item && item.runtime.kind !== "ready" ? undefined : connection,
name: item?.config.distro ?? (remote ? sshName(remote.config) : serverName(connection) || key),
connection:
(item && item.runtime.kind !== "ready") || (remote && remote.stage !== "ready") ? undefined : connection,
wsl: item,
ssh: remote,
}
}),
...wsl
@@ -33,11 +57,21 @@ export function settingsServers(connections: readonly ServerConnection.Any[], ws
wsl: item,
}),
),
...ssh
.filter((item) => item.saved && !connected.has(ServerConnection.Key.make(`ssh:${item.config.id}`)))
.map(
(item): SettingsServer => ({
key: ServerConnection.Key.make(`ssh:${item.config.id}`),
name: sshName(item.config),
ssh: item,
}),
),
]
}
export function useSettingsServers() {
const servers = useServers()
const wsl = useWslServers()
return createMemo(() => settingsServers(servers.list, wsl.data?.servers ?? []))
const ssh = useSsh()
return createMemo(() => settingsServers(servers.list, wsl.data?.servers ?? [], ssh.servers))
}
+33 -32
View File
@@ -9,6 +9,7 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { AddServerMenu, WslServerSettings } from "@/servers/wsl/settings"
import { SshServerSettings } from "@/servers/ssh/settings"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { ShellSetting } from "@/settings/general/general"
@@ -26,6 +27,7 @@ const WebSearchSetting: Component<{
description={language.t("settings.server.preferences.websearch.description")}
>
<Select
data-action="settings-websearch"
options={props.controller.options()}
current={props.controller.current()}
value={(option) => String(option.value)}
@@ -74,44 +76,43 @@ export const SettingsServerGeneral: Component<{
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
<SettingsList>
<Show
when={props.entry.wsl}
when={props.entry.ssh}
fallback={
<Show when={props.entry.connection}>
{(server) => {
const ssh = () => {
const connection = server()
if (connection.type === "ssh") return connection
}
return (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator
health={health()}
connecting={ssh()?.connecting}
authenticationRequired={ssh()?.authenticationRequired}
/>
<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">
{ssh()?.host ?? server().http.url}
</bdi>
<Show
when={props.entry.wsl}
fallback={
<Show when={props.entry.connection}>
{(server) => (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} />
<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={controller.defaults.available() && controller.defaults.key() === props.entry.key}
>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && controller.defaults.key() === props.entry.key}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
</div>
)
}}
)}
</Show>
}
>
{(item) => <WslServerSettings domain={controller} servers={() => [item()]} />}
</Show>
}
>
{(item) => <WslServerSettings domain={controller} servers={() => [item()]} />}
{(item) => <SshServerSettings filter="" id={item().config.id} domain={controller} />}
</Show>
</SettingsList>
</section>
+89 -14
View File
@@ -37,22 +37,25 @@
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
> .settings-sidebar {
min-height: 0;
flex-shrink: 0;
width: 328px;
min-width: 328px;
padding-block: 48px;
padding-inline-start: 24px;
padding-inline-end: 104px;
padding-inline-end: 64px;
border-inline-end: none;
background: transparent;
}
.settings-screen > .settings > .settings-panel {
.settings-screen > .settings > .settings-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
max-width: 720px;
/* Leave room for raised card shadows inside the scrollport. */
padding-inline: 4px;
}
.settings-screen .settings-tab-header {
@@ -65,7 +68,9 @@
.settings-nav {
display: flex;
width: 200px;
width: 240px;
max-width: 100%;
height: 100%;
flex-shrink: 0;
flex-direction: column;
gap: 16px;
@@ -76,6 +81,42 @@
width: 100%;
flex-direction: column;
gap: 16px;
overflow-y: auto;
min-height: 0;
}
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"] {
width: 100%;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
color: var(--v2-text-text-muted);
}
.settings-nav-groups [data-slot="tabs-v2-trigger"] {
width: 100%;
height: 100%;
justify-content: flex-start;
gap: 6px;
padding: 0 6px;
}
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
background: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
}
@media (hover: hover) {
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:hover {
background: var(--v2-background-bg-layer-03);
}
}
.settings-content > .settings-panel {
min-height: 0;
flex: 1;
/* Leave room for raised card shadows inside the scrollport. */
padding-inline: 4px;
}
.settings-nav-group {
@@ -247,6 +288,7 @@
.settings-back {
display: flex;
flex-shrink: 0;
height: 28px;
align-items: center;
gap: 6px;
@@ -332,7 +374,7 @@
.settings-tab-title {
font-size: 15px;
font-weight: 640;
line-height: 1;
line-height: var(--line-height-base);
color: var(--v2-text-text-base);
}
@@ -497,7 +539,7 @@
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
> .settings-sidebar {
width: 240px;
min-width: 240px;
padding-inline-start: 16px;
@@ -537,7 +579,7 @@
flex-shrink: 0;
}
.settings-screen > .settings > .settings-panel {
.settings-screen > .settings > .settings-content {
min-height: 0;
max-width: none;
}
@@ -551,15 +593,48 @@
padding-top: 12px;
}
.settings-screen .settings-tab-title {
line-height: var(--line-height-base);
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
> .settings-sidebar {
width: 100%;
min-width: 0;
padding: 8px var(--settings-mobile-inner-inset, 16px);
flex-shrink: 0;
}
.settings-nav {
width: 100%;
height: auto;
}
.settings-nav > .settings-back,
.settings-sidebar .settings-nav-groups[data-slot="tabs-v2-list"] {
display: none;
}
.settings-search[data-expanded="false"] .settings-search-matches {
display: none;
}
.settings-search-matches {
position: absolute;
inset-block-start: calc(100% + 8px);
inset-inline: 0;
z-index: 12;
max-height: 45vh;
padding-block: 8px;
border-radius: 8px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
}
.settings-screen > .settings > .settings-sidebar[data-searchable="false"] {
display: none;
}
.settings-search [data-slot="text-input-v2-input"] {
font-size: 16px;
}
}
@container settings-panel (max-width: 520px) {
+62 -25
View File
@@ -1,6 +1,6 @@
import { Tabs } from "@opencode/ui/tabs"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
@@ -32,37 +32,39 @@ import { SettingsNavigation, type SettingsNavGroup } from "./navigation"
import { SettingsProjectGeneral } from "./workspaces/project"
import { ProjectSettingsExtensions } from "./workspaces/project-extensions"
import { useSettingsSurface } from "./surface"
import { pageIcons } from "./pages"
import { revealSettingsSearch } from "./search-reveal"
import "@/settings/settings.css"
const rootClientTabs = [
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
{ value: "general", icon: pageIcons.general, label: "settings.tab.preferences" },
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
] as const
const serverTabs = [
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
{ 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" },
] as const
const trailingTabs = [
{ value: "experimental", icon: "flask", label: "settings.tab.experimental" },
{ value: "about", icon: "info", label: "settings.tab.about" },
[{ value: "experimental", icon: pageIcons.experimental, label: "settings.tab.experimental" }],
[{ value: "about", icon: pageIcons.about, label: "settings.tab.about" }],
] as const
const nestedServerTabs = [
{ value: "general", icon: "server", label: "settings.general.section.general" },
{ value: "general", icon: pageIcons.servers, label: "settings.general.section.general" },
...serverTabs,
] as const
const nestedProjectTabs = [
{ value: "general", icon: "folder", label: "settings.general.section.general" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
{ value: "general", icon: pageIcons.projects, label: "settings.general.section.general" },
{ value: "workspaces", icon: pageIcons.workspaces, label: "settings.tab.workspaces" },
{ value: "extensions", icon: pageIcons.extensions, label: "settings.tab.extensions" },
] as const
export function SettingsScreen() {
@@ -72,15 +74,42 @@ export function SettingsScreen() {
const global = useGlobal()
let root: HTMLDivElement | undefined
let viewType = surface.view().type
let activation = 0
onMount(() => root?.focus({ preventScroll: true }))
onMount(() =>
(root?.querySelector<HTMLInputElement>(".settings-search input") ?? root)?.focus({ preventScroll: true }),
)
createEffect(() => {
const next = surface.view().type
if (next === viewType) return
viewType = next
queueMicrotask(() => root?.focus({ preventScroll: true }))
queueMicrotask(() => {
const target =
surface.search.state.query.trim() && surface.search.state.expanded
? root?.querySelector<HTMLInputElement>(".settings-search input")
: root
target?.focus({ preventScroll: true })
})
})
createEffect(
on(
() => [surface.view(), surface.search.state.selected] as const,
([view, selected]) => {
if (
!root ||
!selected ||
!view.searchActivation ||
view.searchActivation !== surface.search.state.activation ||
view.searchActivation === activation
)
return
activation = view.searchActivation
onCleanup(revealSettingsSearch(root, view))
},
),
)
const connection = (key: string) => servers().find((item) => item.key === key)
const project = (server: ServerConnection.Any, directory: string) => {
const context = global.ensureServerCtx(server)
@@ -125,6 +154,11 @@ export function SettingsScreen() {
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
event.preventDefault()
if (surface.view().type !== "root" && surface.search.back()) return
if (surface.search.state.query.trim()) {
surface.search.clear()
return
}
surface.back()
}}
>
@@ -210,7 +244,7 @@ function RootSettings() {
],
},
]),
{ items: trailingTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
...trailingTabs.map((items) => ({ items: items.map((item) => ({ ...item, label: language.t(item.label) })) })),
])
createEffect(() => {
@@ -248,7 +282,7 @@ function RootSettings() {
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
@@ -262,6 +296,8 @@ function RootSettings() {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
autofocus={!surface.search.state.selected}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -280,10 +316,10 @@ function RootSettings() {
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
</Tabs.Content>
</SettingsServerDataScope>
)}
@@ -344,6 +380,7 @@ function ServerSettings(props: { entry: SettingsServer }) {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
@@ -362,10 +399,10 @@ function ServerSettings(props: { entry: SettingsServer }) {
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
<SettingsModels active={surface.view().tab === "models"} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
</Tabs.Content>
</SettingsServerDataScope>
)}
@@ -414,7 +451,7 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr
<SettingsWorkspaces projectID={props.project.id} activeDirectory={activeDirectory()} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<ProjectSettingsExtensions />
<ProjectSettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
</Tabs.Content>
</SettingsNavigation>
</LocationProvider>
+54 -3
View File
@@ -1,5 +1,6 @@
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, on } from "solid-js"
import { batch, createEffect, 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 { useCommand } from "@/shell/commands/command"
@@ -22,7 +23,7 @@ export type SettingsRootTab =
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
export type SettingsView =
export type SettingsView = (
| { type: "root"; tab: SettingsRootTab }
| { type: "server"; server: string; tab: SettingsServerTab }
| {
@@ -32,6 +33,11 @@ export type SettingsView =
tab: SettingsProjectTab
parent: "root" | "server"
}
) & {
target?: string
subtab?: "mcps" | "plugins" | "skills" | "lsps"
searchActivation?: number
}
const rootTabs: Record<SettingsRootTab, true> = {
general: true,
@@ -87,6 +93,15 @@ 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 = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" }
const [search, setSearch] = createStore({
query: "",
origin: undefined as SettingsView | undefined,
selected: "",
highlighted: "",
scrollTop: 0,
activation: 0,
expanded: true,
})
let focus: HTMLElement | undefined
const show = (view: SettingsView, replace: boolean) => {
@@ -103,6 +118,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
open,
(value) => {
if (value) return
setSearch({ query: "", origin: undefined, selected: "", highlighted: "", scrollTop: 0, expanded: true })
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
},
@@ -114,6 +130,38 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
active: open,
route: source,
view,
search: {
state: search,
input(query: string) {
if (!search.query.trim() && query.trim()) setSearch("origin", { ...view(), target: undefined })
setSearch({ query, highlighted: "", scrollTop: 0, expanded: true })
if (!query.trim()) setSearch({ selected: "", origin: undefined })
},
expand() {
setSearch("expanded", true)
},
highlight(id: string) {
setSearch("highlighted", id)
},
scroll(scrollTop: number) {
setSearch("scrollTop", scrollTop)
},
open(destination: SettingsView, id: string) {
batch(() => {
show({ ...destination, searchActivation: search.activation + 1 }, true)
setSearch({ selected: id, highlighted: id, expanded: false, activation: search.activation + 1 })
})
},
clear() {
setSearch({ query: "", selected: "", highlighted: "", scrollTop: 0, origin: undefined, expanded: true })
},
back() {
if (!search.query.trim() || !search.selected || !search.origin) return false
show(search.origin, true)
setSearch({ selected: "", expanded: true })
return true
},
},
open(tab: SettingsRootTab = "general") {
show({ type: "root", tab }, open())
},
@@ -144,7 +192,10 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
: current.type === "project" && isProjectTab(tab)
? { ...current, tab }
: current
show(next, true)
show({ ...next, target: undefined, subtab: undefined }, true)
},
subtab(subtab: SettingsView["subtab"]) {
show({ ...view(), subtab, target: undefined }, true)
},
back() {
const current = view()
@@ -205,7 +205,10 @@ const ProjectLanguageServers: Component = () => {
)
}
export const ProjectSettingsExtensions: Component = () => {
export const ProjectSettingsExtensions: Component<{
subtab?: "mcps" | "plugins" | "skills" | "lsps"
onSubtab: (value: "mcps" | "plugins" | "skills" | "lsps") => void
}> = (props) => {
const language = useLanguage()
const serverSDK = useServerSDK()
const directorySDK = useWorkspaceLocation()
@@ -301,7 +304,14 @@ export const ProjectSettingsExtensions: Component = () => {
</div>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs settings-subtabs">
<Tabs
variant="pill"
value={props.subtab ?? "mcps"}
onChange={(value) => {
if (value === "mcps" || value === "plugins" || value === "skills" || value === "lsps") props.onSubtab(value)
}}
class="project-settings-extension-tabs settings-subtabs"
>
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
@@ -48,6 +48,7 @@ export const SettingsProjectGeneral: Component<{
>
<div class="project-settings-name">
<TextInput
data-action="settings-project-name"
type="text"
appearance="base"
value={model.store.name}
@@ -64,6 +65,7 @@ export const SettingsProjectGeneral: Component<{
description={language.t("project.settings.icon.description")}
>
<button
data-action="settings-project-icon"
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="project-settings-icon"
@@ -103,7 +105,7 @@ export const SettingsProjectGeneral: Component<{
title={language.t("dialog.project.edit.color")}
description={language.t("project.settings.color.description")}
>
<div class="project-settings-colors">
<div class="project-settings-colors" data-action="settings-project-color">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => {
const selected = () => getProjectAvatarVariant(model.store.color) === color
@@ -1,35 +1,85 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { settingsProjects } from "../servers/inventory"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
active?: boolean
autofocus?: boolean
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const projects = createMemo(() => global.ensureServerCtx(props.server).projects.list())
const [store, setStore] = createStore({ filter: "" })
let search: HTMLInputElement | undefined
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
})
createEffect(
on(
() => (props.active ?? true) && searchable(),
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header" classList={{ "settings-tab-header--stacked": searchable() }}>
<div class="settings-tab-header-row">
<div class="flex 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>
</div>
<Show when={searchable()}>
<div class="settings-tab-search">
<TextInput
ref={search}
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.projects.search.placeholder")}
aria-label={language.t("settings.projects.search.placeholder")}
showClearButton={!!store.filter}
onClearClick={() => {
setStore("filter", "")
search?.focus({ preventScroll: true })
}}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
</Show>
</div>
<div class="settings-tab-body">
<Show
when={projects().length > 0}
when={filtered().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
@@ -37,7 +87,7 @@ export const SettingsProjects: Component<{
}
>
<div class="flex w-full flex-col gap-2">
<For each={projects()}>
<For each={filtered()}>
{(project) => (
<button
type="button"