diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index e9a1b7f4fd6..37212b5071f 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -192,7 +192,7 @@ test("clearing project fields sends explicit removal values", async ({ page }) = test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => { const settings = page.getByTestId("settings-screen") - await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused() + await expect(settings).toBeFocused() await page.keyboard.press("Control+t") await expect(page).toHaveURL(/\/new-session\?draftId=.+$/) diff --git a/packages/app/e2e/regression/settings-padding.spec.ts b/packages/app/e2e/regression/settings-padding.spec.ts index 1ad9bc31604..0a51cc35bb2 100644 --- a/packages/app/e2e/regression/settings-padding.spec.ts +++ b/packages/app/e2e/regression/settings-padding.spec.ts @@ -22,9 +22,7 @@ test.beforeEach(async ({ page }) => { ) await page.goto("/") await page.getByRole("button", { name: "Settings", exact: true }).click() - await expect( - page.getByTestId("settings-screen").getByRole("combobox", { name: "Search settings", exact: true }), - ).toBeFocused() + await expect(page.getByTestId("settings-screen")).toBeFocused() }) for (const viewport of [ diff --git a/packages/app/e2e/regression/settings-scroll.spec.ts b/packages/app/e2e/regression/settings-scroll.spec.ts index 14643d94ed1..10bb455f23b 100644 --- a/packages/app/e2e/regression/settings-scroll.spec.ts +++ b/packages/app/e2e/regression/settings-scroll.spec.ts @@ -31,7 +31,7 @@ for (const viewport of [ const panel = settings.getByRole("tabpanel") const main = page.getByRole("main") const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true }) - await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused() + await expect(settings).toBeFocused() await page.setViewportSize(viewport) await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/) await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1) diff --git a/packages/app/e2e/regression/settings-search.spec.ts b/packages/app/e2e/regression/settings-search.spec.ts index 2de4f271595..e7851ab2379 100644 --- a/packages/app/e2e/regression/settings-search.spec.ts +++ b/packages/app/e2e/regression/settings-search.spec.ts @@ -34,8 +34,9 @@ function ui(page: Page) { const settings = page.getByTestId("settings-screen") return { settings, - search: settings.getByRole("combobox", { name: "Search settings", exact: true }), + search: settings.getByRole("combobox", { name: "Search", exact: true }), results: settings.getByRole("listbox", { name: "Settings results", exact: true }), + viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"), } } @@ -53,14 +54,14 @@ test.beforeEach(async ({ page }) => { 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() + await expect(view.settings).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 }) => { +test("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 }) @@ -104,7 +105,7 @@ test("page priority, compact icons, section context, and the minimal empty state 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).toHaveCSS("height", "28px") await expect(pageResult.locator("svg")).toHaveCount(1) await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0) await view.search.fill("Agent") @@ -116,13 +117,45 @@ test("page priority, compact icons, section context, and the minimal empty state ) 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("status")).toHaveText('No results for "zzzzzzzzzz"') 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("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => { + const view = ui(page) + const query = "zzzz 🧑🏽‍💻 ".repeat(40) + await view.search.fill(query) + const status = view.settings.getByRole("status") + const quoted = status.locator("bdi") + await expect(status).toHaveAccessibleName(`No results for "${query}"`) + await expect(quoted).toHaveText(/^".+…"$/) + await expect(status).toHaveCSS("height", "28px") + await expect + .poll(() => + quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth), + ) + .toBe(true) + + const text = await quoted.textContent() + await page.setViewportSize({ width: 390, height: 844 }) + await expect(quoted).toHaveText(/^".+…"$/) + await expect(quoted).not.toHaveText(text!) + await expect + .poll(() => + quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth), + ) + .toBe(true) + expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true) + + await view.search.fill("zzzzzzzzzz") + await expect(status).toHaveText('No results for "zzzzzzzzzz"') + await view.search.pressSequentially("x") + await expect(status).toHaveText('No results for "zzzzzzzzzzx"') +}) + test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => { const view = ui(page) for (const entry of [ @@ -214,8 +247,8 @@ test("qualified project results preserve query and selection on return", async ( 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(project).toHaveText("OOpenCode") + await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px") await expect(view.settings.locator(".settings-search-group")).toHaveCount(0) await view.search.fill("OpenCode name") const name = view.results.getByRole("option") @@ -241,16 +274,29 @@ test("returning from a project restores a scrolled result list", async ({ page } const view = ui(page) await view.search.fill("OpenCode") await expect(view.results.getByRole("option")).toHaveCount(30) + const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]') + await view.viewport.hover() + await expect(scrollbar).toHaveAttribute("data-visible", "true") + const bounds = (await scrollbar.boundingBox())! + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) + await page.mouse.down() + await expect(scrollbar).toHaveAttribute("data-dragging", "true") + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 }) + await page.mouse.up() + await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0) 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 expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0) + const scroll = await view.viewport.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) + await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll) + await view.search.fill("about") + await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible() + await expect(scrollbar).toHaveCount(0) }) test("IME confirmation does not activate a search result", async ({ page }) => { @@ -361,6 +407,44 @@ for (const direction of ["ltr", "rtl"] as const) { colorScheme: direction === "rtl" ? "dark" : "light", contextOptions: { reducedMotion: "reduce" }, }) + test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => { + const view = ui(page) + await page.setViewportSize({ width: 1280, height: 900 }) + await page.evaluate((direction) => { + document.documentElement.dir = direction + }, direction) + await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30)) + await view.search.press("End") + await expect(view.search).toHaveAttribute("data-overflow-start", "true") + await expect(view.search).toHaveAttribute("data-overflow-end", "false") + await expect(view.search).not.toHaveCSS("mask-image", "none") + + await view.search.press("Home") + await expect(view.search).toHaveAttribute("data-overflow-start", "false") + await expect(view.search).toHaveAttribute("data-overflow-end", "true") + await view.search.evaluate((input: HTMLInputElement, direction) => { + input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1) + }, direction) + await expect(view.search).toHaveAttribute("data-overflow-start", "true") + await expect(view.search).toHaveAttribute("data-overflow-end", "true") + + await view.search.fill("z".repeat(50)) + await expect(view.search).not.toHaveCSS("mask-image", "none") + await page.setViewportSize({ width: 600, height: 844 }) + await expect(view.search).toHaveAttribute("data-overflow-start", "false") + await expect(view.search).toHaveAttribute("data-overflow-end", "false") + await expect(view.search).toHaveCSS("mask-image", "none") + + await view.search.fill("zzzz ".repeat(30)) + await expect(view.search).not.toHaveCSS("mask-image", "none") + await view.settings.getByRole("button", { name: "Clear", exact: true }).click() + await expect(view.search).toHaveValue("") + await expect(view.search).toBeFocused() + await expect(view.search).toHaveAttribute("data-overflow-start", "false") + await expect(view.search).toHaveAttribute("data-overflow-end", "false") + await expect(view.search).toHaveCSS("mask-image", "none") + }) + test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => { const view = ui(page) await page.evaluate((direction) => { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 897de1347ca..8b86096b9e8 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -999,12 +999,17 @@ 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.placeholder": "Search", "settings.search.results": "Settings results", + "settings.search.group.pages": "Pages", + "settings.search.group.settings": "Settings", + "settings.search.group.projects": "Projects", + "settings.search.group.servers": "Servers", "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.empty": "No results for {{query}}", + "settings.search.empty.query": '"{{query}}"', "settings.search.refine": "Narrow your search to see more specific results.", "settings.tab.general": "General", "settings.tab.preferences": "Preferences", diff --git a/packages/app/src/runtime/i18n/language.tsx b/packages/app/src/runtime/i18n/language.tsx index b1599cdc226..cc3bc0ae8cc 100644 --- a/packages/app/src/runtime/i18n/language.tsx +++ b/packages/app/src/runtime/i18n/language.tsx @@ -221,6 +221,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont params?: Record, ) => string + const rich = (key: Parameters[0], params: Record) => + t(key) + .split(/(\{\{\w+\}\})/g) + .map((part, index) => (index % 2 ? (params[part.slice(2, -2)] ?? part) : part)) + const pluralForm = ( key: PluralKey, category: UiPluralCategory, @@ -262,6 +267,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont locales: LOCALES, label, t, + rich, plural, pluralForm, setLocale(next: Locale) { diff --git a/packages/app/src/settings/search-empty.tsx b/packages/app/src/settings/search-empty.tsx new file mode 100644 index 00000000000..c7d97c24457 --- /dev/null +++ b/packages/app/src/settings/search-empty.tsx @@ -0,0 +1,68 @@ +import { createEffect, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { useLanguage } from "@/runtime/i18n/language" + +export function SettingsSearchEmpty(props: { query: string }) { + const language = useLanguage() + const [state, setState] = createStore({ query: props.query }) + let quoted: HTMLSpanElement | undefined + let measure: HTMLSpanElement | undefined + const text = (query: string) => language.t("settings.search.empty.query", { query }) + const update = () => { + if (!quoted || !measure) return + const width = quoted.getBoundingClientRect().width + const fits = (query: string) => { + measure!.textContent = text(query) + return measure!.getBoundingClientRect().width <= width + } + if (fits(props.query)) { + setState("query", props.query) + return + } + + const characters = Array.from( + new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(props.query), + (item) => item.segment, + ) + let start = 0 + let end = characters.length + while (start < end) { + const middle = Math.ceil((start + end) / 2) + if (fits(`${characters.slice(0, middle).join("").trimEnd()}…`)) { + start = middle + continue + } + end = middle - 1 + } + setState("query", `${characters.slice(0, start).join("").trimEnd()}…`) + } + + createEffect(update) + onMount(() => { + if (!quoted || !measure) return + // The measuring text also observes font changes that do not resize the available space. + createResizeObserver([quoted, measure], update) + }) + + return ( + <> +
+ {language.rich("settings.search.empty", { + query: ( + + {text(state.query)} + + ), + })} +
+