mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(desktop): redesign settings with search (#48174)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
2253d4c31d
commit
5d1841e0aa
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user