From 5d1841e0aa0d3dd330c97e8cde6e5c12a9ae6656 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:05:48 +0200 Subject: [PATCH] feat(desktop): redesign settings with search (#48174) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- .../remote-session-settings.spec.ts | 2 +- .../e2e/regression/settings-loading.spec.ts | 2 +- .../e2e/regression/settings-padding.spec.ts | 6 +- .../e2e/regression/settings-scroll.spec.ts | 2 +- .../e2e/regression/settings-search.spec.ts | 387 ++++++++++++++++++ .../regression/tab-navigate-mousedown.spec.ts | 2 +- packages/app/src/runtime/i18n/en.ts | 8 + packages/app/src/servers/ssh/settings.tsx | 6 +- packages/app/src/settings/general/general.tsx | 9 +- .../app/src/settings/keybinds/keybinds.tsx | 23 +- packages/app/src/settings/models/models.tsx | 19 +- packages/app/src/settings/navigation.tsx | 100 +++-- packages/app/src/settings/pages.ts | 33 ++ .../app/src/settings/providers/extensions.tsx | 15 +- packages/app/src/settings/search-catalog.ts | 241 +++++++++++ packages/app/src/settings/search-index.ts | 118 ++++++ .../app/src/settings/search-results.test.ts | 145 +++++++ packages/app/src/settings/search-results.ts | 71 ++++ packages/app/src/settings/search-reveal.ts | 58 +++ packages/app/src/settings/search.css | 157 +++++++ packages/app/src/settings/search.tsx | 265 ++++++++++++ .../src/settings/servers/inventory.test.ts | 48 +++ .../app/src/settings/servers/inventory.ts | 42 +- packages/app/src/settings/servers/servers.tsx | 65 +-- packages/app/src/settings/settings.css | 103 ++++- packages/app/src/settings/shell.tsx | 87 ++-- packages/app/src/settings/surface.tsx | 57 ++- .../workspaces/project-extensions.tsx | 14 +- .../app/src/settings/workspaces/project.tsx | 4 +- .../app/src/settings/workspaces/projects.tsx | 60 ++- 30 files changed, 2009 insertions(+), 140 deletions(-) create mode 100644 packages/app/e2e/regression/settings-search.spec.ts create mode 100644 packages/app/src/settings/pages.ts create mode 100644 packages/app/src/settings/search-catalog.ts create mode 100644 packages/app/src/settings/search-index.ts create mode 100644 packages/app/src/settings/search-results.test.ts create mode 100644 packages/app/src/settings/search-results.ts create mode 100644 packages/app/src/settings/search-reveal.ts create mode 100644 packages/app/src/settings/search.css create mode 100644 packages/app/src/settings/search.tsx create mode 100644 packages/app/src/settings/servers/inventory.test.ts diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index b1afe485927..a62130a79ec 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -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) diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index 37212b5071f..e9a1b7f4fd6 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -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=.+$/) diff --git a/packages/app/e2e/regression/settings-padding.spec.ts b/packages/app/e2e/regression/settings-padding.spec.ts index ef9f4547ba6..1ad9bc31604 100644 --- a/packages/app/e2e/regression/settings-padding.spec.ts +++ b/packages/app/e2e/regression/settings-padding.spec.ts @@ -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() diff --git a/packages/app/e2e/regression/settings-scroll.spec.ts b/packages/app/e2e/regression/settings-scroll.spec.ts index 10bb455f23b..14643d94ed1 100644 --- a/packages/app/e2e/regression/settings-scroll.spec.ts +++ b/packages/app/e2e/regression/settings-scroll.spec.ts @@ -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) diff --git a/packages/app/e2e/regression/settings-search.spec.ts b/packages/app/e2e/regression/settings-search.spec.ts new file mode 100644 index 00000000000..2de4f271595 --- /dev/null +++ b/packages/app/e2e/regression/settings-search.spec.ts @@ -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() + 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) + }) + }) +} diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index c4cb79d2951..22b0b0c8fbd 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -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") diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 3529c20d5d5..897de1347ca 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/servers/ssh/settings.tsx b/packages/app/src/servers/ssh/settings.tsx index 05c53fda797..3b820724a04 100644 --- a/packages/app/src/servers/ssh/settings.tsx +++ b/packages/app/src/servers/ssh/settings.tsx @@ -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 ( - 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) => { diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 34ac8216ed4..acf4fb32051 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -83,6 +83,7 @@ const WorkspaceDestinationSetting: Component = () => { description={language.t("settings.workspaces.default.description")} > String(option.value)} @@ -74,44 +76,43 @@ export const SettingsServerGeneral: Component<{

{language.t("settings.server.section.connection")}

- {(server) => { - const ssh = () => { - const connection = server() - if (connection.type === "ssh") return connection - } - return ( -
-
- -
- - {serverName(server()) || props.entry.key} - - - {ssh()?.host ?? server().http.url} - + + {(server) => ( +
+
+ +
+ + {serverName(server()) || props.entry.key} + + + {server().http.url} + +
+
+
+ + {language.t("dialog.server.status.default")} + +
-
- - {language.t("dialog.server.status.default")} - - -
-
- ) - }} + )} + + } + > + {(item) => [item()]} />} } > - {(item) => [item()]} />} + {(item) => } diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 873c2a9cf9e..6ee597d338c 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -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) { diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 9d66757ebdd..39a3c56024c 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -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(".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(".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() { - + @@ -262,6 +296,8 @@ function RootSettings() { surface.openProject({ server: ServerConnection.key(server), @@ -280,10 +316,10 @@ function RootSettings() { surface.select("providers")} /> - + - + surface.subtab(value)} /> )} @@ -344,6 +380,7 @@ function ServerSettings(props: { entry: SettingsServer }) { surface.openProject({ server: props.entry.key, @@ -362,10 +399,10 @@ function ServerSettings(props: { entry: SettingsServer }) { surface.select("providers")} /> - + - + surface.subtab(value)} /> )} @@ -414,7 +451,7 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr - + surface.subtab(value)} /> diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index 07b4a856fae..3c22031ef07 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -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 = { 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() diff --git a/packages/app/src/settings/workspaces/project-extensions.tsx b/packages/app/src/settings/workspaces/project-extensions.tsx index 1042c20e508..3de7c9ac078 100644 --- a/packages/app/src/settings/workspaces/project-extensions.tsx +++ b/packages/app/src/settings/workspaces/project-extensions.tsx @@ -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 = () => {
- + { + if (value === "mcps" || value === "plugins" || value === "skills" || value === "lsps") props.onSubtab(value) + }} + class="project-settings-extension-tabs settings-subtabs" + > {language.t("settings.extensions.tab.mcps")} {language.t("status.popover.tab.plugins")} diff --git a/packages/app/src/settings/workspaces/project.tsx b/packages/app/src/settings/workspaces/project.tsx index e2b84c394cc..5d5d88308bf 100644 --- a/packages/app/src/settings/workspaces/project.tsx +++ b/packages/app/src/settings/workspaces/project.tsx @@ -48,6 +48,7 @@ export const SettingsProjectGeneral: Component<{ >