Show matching settings as a results list in Settings search (#40852)

* Show matching settings as a results list in the Settings sidebar

Typing in the Settings search now replaces the nav with a ranked list of
individual settings (pane title on the second line, matched text marked).
The first result is selected and previewed: its pane opens unfiltered,
scrolled to the setting with the target highlight. Arrow keys move and
preview, Enter or a click picks and keeps the query, Esc or the clear
button returns to the nav while leaving the shown pane open.

The sidebar query no longer filters page content. The Back to app row is
now 48px so its border lines up with the Settings header.

* Highlight settings search targets with a full-bleed bar and find unanchored rows

Matches in the results list are marked with semibold text (the jump palette's
HighlightedText) instead of a grey box. The landed-on row gets a tint from card
edge to card edge with a left bar that fades after ~3s, replacing the inset
grey block that sat off-center in the card padding. Integration provider rows
get anchors, and rows not wrapped in SearchableSetting are found by their
visible label.

* Fit the search highlight to each row's enclosing card

Settings cards pad differently (groups, plain sections, shadcn cards, the
Appearance accordion), so the highlight now measures the gap from the row to
its nearest card's inner edges instead of assuming one gutter. A group title
match highlights that group's card rather than the heading text.

* Give the settings target highlight a single owner

Components applied the highlight through SettingsTargetHighlightContext while the
page effect applied it to whatever element the jump scrolled to; when those
differed (Jira's provider row vs. its card shell) two highlights showed. The
effect now owns it alone, and the context, its provider and the macOS
Permissions prop are removed. Jira and Linear reuse their existing anchor ids.

* Use a plain fading tint for the settings target highlight

Drops the left bar; the tint still spans the enclosing card and fades after ~3s.

* Mark settings result titles with the ranking fold

The title highlight lowercased by hand and skipped titles whose length changed,
so İstanbul matching "stan" (or a query of İ) ranked but was never marked. Fold
per code point with the ranking's own fold and map offsets back to the title.

* Make settings search list-only until a pick and reveal collapsed targets

Typing now only changes the result rows: nothing is selected and the page does
not move until Enter or a click (arrows move the selection only). This removes
previews that discarded the Open in draft, re-ran repo hook checks, mounted a
pane per keystroke and drifted after deep links.

- Rank once on a deferred query, cap the list at 50 with a 'keep typing' hint,
  and skip rendering the normal nav while searching.
- A settings jump dispatches a reveal event from its target; Appearance sections,
  Advanced disclosures and the network/mobile collapsibles (now kept mounted)
  open themselves instead of reading the search query.
- Nested scrollers (Shortcuts) scroll the target into view; General sub-page
  results open their page; mirror recovery deep-links to the mirror field; a
  target that never mounts drops its pending jump; group-title matches use
  data-settings-group.

* Fix follow-ups from the settings search repair review

- Two more e2e specs pick a result with ArrowDown + Enter instead of relying on
  typing to navigate.
- After a jump reveals a growing section, re-scroll once if the row is still cut off.
- Hide 'No settings match' while the deferred ranking catches up.
- The reveal hook is a ref callback so disclosures that mount later still open.
- The Advanced disclosure fixture shows the real forced-open case (unsaved edits).

* Give every settings search result a reachable row

* Type the General sub-page anchors as a list instead of casting map entries

* Keep the reveal callback ref out of render

* Match the e2e shortcut labels to main's sentence-case names

* Highlight the result's card, not its section title or a bare label
This commit is contained in:
Brennan Benson
2026-11-12 19:39:39 -08:00
committed by GitHub
parent 25eba813d3
commit a37ff2fa62
89 changed files with 1262 additions and 385 deletions
+32
View File
@@ -2172,6 +2172,38 @@ html.native-shell .app-layout {
}
}
/* Search and deep-link target: a tint out to the enclosing card's edges that fades.
An inset shadow carries the tint so a highlighted card keeps its own fill and corners.
The bleed vars are measured per target, since nested and sub-row targets sit further in. */
.settings-target-highlight {
--settings-target-tint: color-mix(in srgb, var(--foreground) 8%, transparent);
box-shadow:
inset 0 0 0 100vmax var(--settings-target-tint),
calc(-1 * var(--settings-target-bleed-left, 0px)) 0 0 var(--settings-target-tint),
var(--settings-target-bleed-right, 0px) 0 0 var(--settings-target-tint);
}
@media (prefers-reduced-motion: no-preference) {
.settings-target-highlight {
animation: settings-target-highlight-fade 3s ease-out forwards;
}
}
@keyframes settings-target-highlight-fade {
60% {
box-shadow:
inset 0 0 0 100vmax var(--settings-target-tint),
calc(-1 * var(--settings-target-bleed-left, 0px)) 0 0 var(--settings-target-tint),
var(--settings-target-bleed-right, 0px) 0 0 var(--settings-target-tint);
}
100% {
box-shadow:
inset 0 0 0 100vmax transparent,
calc(-1 * var(--settings-target-bleed-left, 0px)) 0 0 transparent,
var(--settings-target-bleed-right, 0px) 0 0 transparent;
}
}
@keyframes settings-shell-enter {
from {
opacity: 0;
@@ -4,6 +4,14 @@ import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { getAccountsClaudeSearchEntries } from './accounts-claude-search'
export const ACCOUNTS_SECTION_IDS = {
codex: 'accounts-codex',
minimax: 'accounts-minimax',
grok: 'accounts-grok',
antigravity: 'accounts-antigravity',
cursor: 'accounts-cursor'
} as const
export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.accounts.search.5e2101f61f', 'Account location'),
@@ -55,6 +63,7 @@ export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: ACCOUNTS_SECTION_IDS.codex,
title: translate('auto.components.settings.accounts.search.4518496456', 'Active Codex account'),
description: translate(
'auto.components.settings.accounts.search.87a4a8584e',
@@ -153,6 +162,7 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [
export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: ACCOUNTS_SECTION_IDS.minimax,
title: translate('auto.components.settings.accounts.search.9b6ce52c36', 'MiniMax usage'),
description: translate(
'auto.components.settings.accounts.search.f8374c3151',
@@ -177,6 +187,7 @@ export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [
export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: ACCOUNTS_SECTION_IDS.grok,
title: translate('auto.components.settings.accounts.search.f4a8c2e1b7', 'Grok (xAI) Usage'),
description: translate(
'auto.components.settings.accounts.search.e3b7d1f9a2',
@@ -198,6 +209,7 @@ export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [
export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: ACCOUNTS_SECTION_IDS.antigravity,
title: translate('accounts.antigravity.fd47b5a5dd', 'Antigravity accounts'),
description: translate(
'accounts.antigravity.searchDescription',
@@ -216,6 +228,7 @@ export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() =>
export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: ACCOUNTS_SECTION_IDS.cursor,
title: translate('auto.components.settings.accounts.search.cursor.39bbdebe13', 'Cursor usage'),
description: translate(
'auto.components.settings.accounts.search.cursor.description',
@@ -8,7 +8,7 @@ import { Label } from '../../../ui/label'
import { OpenAIIcon } from '../../../status-bar/icons'
import { CodexLoginLinkNotice } from './CodexLoginLinkNotice'
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { getAccountsCodexSearchEntries } from '../accounts-search'
import { ACCOUNTS_SECTION_IDS, getAccountsCodexSearchEntries } from '../accounts-search'
import { getCodexSystemDefaultSubtitle } from '../scope/accounts-pane-runtime'
import type { AccountsPaneSectionModel } from '../accounts-pane-types'
import { renderCodexAccountRow } from './accounts-pane-codex-account-row'
@@ -47,7 +47,7 @@ export function renderCodexAccountsSection(
wslCapabilitiesLoading
} = model
return (
<div key="codex-accounts" id="accounts-codex" className="scroll-mt-6">
<div key="codex-accounts" id={ACCOUNTS_SECTION_IDS.codex} className="scroll-mt-6">
<SettingsGroup
title={
<span className="flex items-center gap-2">
@@ -26,6 +26,7 @@ import { useAntigravityChatSignIn } from '../../../provider-accounts/use-antigra
import { ChatUnavailableOnWindowsNote } from '../../../agent/launch-settings/AgentChatChoiceControls'
import { structuredChatUnavailableOnPlatform } from '../../../../../../shared/agent-session/structured-chat-platform-support'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { ACCOUNTS_SECTION_IDS } from '../accounts-search'
import {
AntigravityTerminalAccountRows,
type AntigravityAccountAction
@@ -204,7 +205,7 @@ export function AntigravityAccountsSection({
) : null
return (
<div id="accounts-antigravity" className="scroll-mt-6 space-y-3">
<div id={ACCOUNTS_SECTION_IDS.antigravity} className="scroll-mt-6 space-y-3">
<SettingsGroup
title={
<span className="flex items-center gap-2">
@@ -11,6 +11,7 @@ import type { CursorAccountStatus } from '../../../../../../shared/rate-limit-ty
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { openAppHttpLinkFromAnchor } from '@/lib/browser/navigation/app-http-link-open'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { ACCOUNTS_SECTION_IDS } from '../accounts-search'
const CURSOR_USAGE_DASHBOARD_URL = 'https://cursor.com/dashboard/spending'
const SEARCH_KEYWORDS = ['cursor', 'usage', 'plan', 'spend', 'billing', 'rate limit']
@@ -105,7 +106,7 @@ export function CursorAccountsSection(): React.JSX.Element {
: null
return (
<div id="accounts-cursor" className="scroll-mt-6">
<div id={ACCOUNTS_SECTION_IDS.cursor} className="scroll-mt-6">
<SettingsGroup
title={
<span className="flex items-center gap-2">
@@ -10,6 +10,7 @@ import type { GrokAccountStatus } from '../../../../../../shared/rate-limit-type
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { openAppHttpLinkFromAnchor } from '@/lib/browser/navigation/app-http-link-open'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { ACCOUNTS_SECTION_IDS } from '../accounts-search'
const GROK_CLI_DOCS_URL = 'https://docs.x.ai/build/overview'
export function GrokAccountsSection(): React.JSX.Element {
@@ -65,7 +66,7 @@ export function GrokAccountsSection(): React.JSX.Element {
: null
return (
<div id="accounts-grok" className="scroll-mt-6">
<div id={ACCOUNTS_SECTION_IDS.grok} className="scroll-mt-6">
<SettingsGroup
title={
<span className="flex items-center gap-2">
@@ -17,6 +17,7 @@ import {
} from './usage-credential-host'
import type { UsageHostSettingsModel } from './use-usage-host-settings'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { ACCOUNTS_SECTION_IDS } from '../accounts-search'
export function MiniMaxAccountsSection({
host,
@@ -49,7 +50,7 @@ export function MiniMaxAccountsSection({
void usageSettings.update({ minimaxEndpoint: value })
}
return (
<div id="accounts-minimax" className="scroll-mt-6">
<div id={ACCOUNTS_SECTION_IDS.minimax} className="scroll-mt-6">
<SettingsGroup
title={
<span className="flex items-center gap-2">
@@ -18,6 +18,8 @@ import { Textarea } from '../../../ui/textarea'
import { getAdvancedNetworkSearchEntries } from './advanced-network-search'
import { NodeDistMirrorSettingField } from './NodeDistMirrorSettingField'
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { useSettingsTargetReveal } from '../../section-layout/settings-target-highlight'
import { NODE_DIST_MIRROR_SETTINGS_TARGET_ID } from '../../navigation/settings-navigation-types'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from '../../search/settings-search'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
@@ -152,6 +154,7 @@ export function AdvancedNetworkSettingsSection({
const t = useTranslate()
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const [proxyConfigOpen, setProxyConfigOpen] = useState(false)
const proxyRevealRef = useSettingsTargetReveal<HTMLDivElement>(() => setProxyConfigOpen(true))
const proxyConfigForcedOpen =
shouldOpenNetworkProxyConfig(searchQuery) || hasConfiguredNetworkProxy(settings)
const proxyConfigExpanded = proxyConfigOpen || proxyConfigForcedOpen
@@ -256,7 +259,11 @@ export function AdvancedNetworkSettingsSection({
</div>
</div>
<Collapsible open={proxyConfigExpanded} onOpenChange={setProxyConfigOpen}>
<Collapsible
ref={proxyRevealRef}
open={proxyConfigExpanded}
onOpenChange={setProxyConfigOpen}
>
<CollapsibleTrigger asChild>
<Button
type="button"
@@ -273,7 +280,8 @@ export function AdvancedNetworkSettingsSection({
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{/* Mounted while closed so a settings jump can find and reveal the proxy fields. */}
<CollapsibleContent forceMount className="data-[state=closed]:hidden">
<div className="mt-2 space-y-4 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="space-y-2">
<Label htmlFor="settings-http-proxy-url">
@@ -350,10 +358,12 @@ export function AdvancedNetworkSettingsSection({
</p>
</div>
<NodeDistMirrorSettingField
value={settings.nodeDistMirrorUrl}
onCommit={(nodeDistMirrorUrl) => updateSettings({ nodeDistMirrorUrl })}
/>
<div id={NODE_DIST_MIRROR_SETTINGS_TARGET_ID}>
<NodeDistMirrorSettingField
value={settings.nodeDistMirrorUrl}
onCommit={(nodeDistMirrorUrl) => updateSettings({ nodeDistMirrorUrl })}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
@@ -101,6 +101,7 @@ const AGENT_CHAT_CHOICE_SEARCH_ENTRY_ID = 'agent-chat-choice'
export const AGENTS_INSTALLED_SECTION_ID = 'agents-installed'
/** The top Chat or Terminal tiles that set this computer's default. */
export const AGENT_DEFAULT_SURFACE_SECTION_ID = 'agents-default-surface'
export const AGENT_PERMISSIONS_SECTION_ID = 'agents-permissions'
const AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID = 'agent-workspace-trust'
const AGENT_RUNTIME_SEARCH_ENTRY_ID = 'agent-runtime'
const CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID = 'codex-terminal-server-isolation'
@@ -212,6 +213,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [
keywords: getAgentAwakeSearchKeywords()
},
{
targetSectionId: AGENT_PERMISSIONS_SECTION_ID,
title: translate('auto.components.settings.agents.search.788559d398', 'Agent permissions'),
description: translate(
'settings.agents.permissionsSearchDescription',
@@ -1,5 +1,6 @@
import { useTranslate } from '@/i18n/use-translate'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { AGENT_PERMISSIONS_SECTION_ID } from '../agents-search'
export function AgentLaunchDefaultsGroup({
description,
@@ -14,7 +15,7 @@ export function AgentLaunchDefaultsGroup({
title={t('settings.agents.newAgentsTitle', 'New agents')}
description={description}
>
<div data-settings-row="" className="py-3">
<div id={AGENT_PERMISSIONS_SECTION_ID} data-settings-row="" className="py-3">
{permissions}
</div>
</SettingsGroup>
@@ -17,6 +17,7 @@ import { FontAutocomplete } from './fonts/FontAutocomplete'
import { SettingsRow, SettingsSwitchRow } from '../form-controls/SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../../shared/constants'
import {
APPEARANCE_EDITOR_THEME_SETTING_ID,
getCodeEditorThemeEntries,
getLanguageEntries,
getThemeEntries,
@@ -200,6 +201,8 @@ export function AppearanceInterfaceSection({
{editorThemeSlots.map((slot) => (
<SearchableSetting
key={slot.base}
// The light row comes first, so it stands for both editor theme rows.
id={slot.base === 'light' ? APPEARANCE_EDITOR_THEME_SETTING_ID : undefined}
title={slot.title}
description={editorThemeEntry?.description}
keywords={editorThemeEntry?.keywords ?? []}
@@ -32,8 +32,11 @@ export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[]
}
])
export const APPEARANCE_EDITOR_THEME_SETTING_ID = 'appearance-editor-theme'
export const getCodeEditorThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
targetSectionId: APPEARANCE_EDITOR_THEME_SETTING_ID,
title: translate('settings.appearance.editorTheme.title', 'Editor theme'),
description: translate(
'settings.appearance.editorTheme.description',
@@ -12,7 +12,10 @@ import '../../../../../../shared/constants'
import { BROWSER_FAMILY_LABELS } from '../../../../../../shared/browser/browser-family-labels'
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { matchesSettingsSearch } from '../../search/settings-search'
import { getBrowserUsePaneSearchEntries } from './browser-use-search'
import {
BROWSER_USE_SETTINGS_TARGET_ID,
getBrowserUsePaneSearchEntries
} from './browser-use-search'
import { BrowserUseExamples } from './BrowserUseExamples'
import { BrowserUseComputerUseNotice } from './BrowserUseComputerUseNotice'
import { BrowserUseEnableSwitch } from './BrowserUseEnableSwitch'
@@ -88,7 +91,10 @@ export function BrowserUseSetup({
if (!browserUseEnabled) {
return (
<div className="flex items-center justify-between gap-4 py-3">
<div
id={BROWSER_USE_SETTINGS_TARGET_ID}
className="flex items-center justify-between gap-4 py-3"
>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t('auto.components.settings.BrowserUsePane.507fdae102', 'Agent browser use')}
@@ -109,7 +115,7 @@ export function BrowserUseSetup({
}
return (
<div className="space-y-3 py-4">
<div id={BROWSER_USE_SETTINGS_TARGET_ID} className="space-y-3 py-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">
@@ -2,8 +2,12 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
// The steps render only after Agent browser use is on, so both results land on its block.
export const BROWSER_USE_SETTINGS_TARGET_ID = 'browser-use'
export const getBrowserUsePaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: BROWSER_USE_SETTINGS_TARGET_ID,
title: translate(
'auto.components.settings.browser.use.search.e72d500ca5',
'Install browser use skill'
@@ -38,6 +42,7 @@ export const getBrowserUsePaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: BROWSER_USE_SETTINGS_TARGET_ID,
title: translate(
'auto.components.settings.browser.use.search.9e2bfa1405',
'Import browser cookies'
@@ -22,6 +22,7 @@ import {
} from '../../../../lib/skills/runtime/skill-runtime-command'
import { WslCliRegistration } from './WslCliRegistration'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { GENERAL_SETTING_ANCHORS } from '../../general/general-setting-anchors'
import { useCliRegistrationActions } from './use-cli-registration-actions'
import { useLocalCliSkillFreshnessName } from '../../../../hooks/skills/freshness/use-local-cli-skill-freshness-name'
import { translate } from '@/i18n/i18n'
@@ -303,23 +304,25 @@ export function CliSection({
</div>
{!isBrowserManaged ? (
<AgentSkillSetupPanel
variant="row"
title={t('settings.general.cli.skillTitle', 'Orca skill for agents')}
description={t(
'auto.components.settings.CliSection.e8012c03a1',
'Enables agents to use Orca workspace, terminal, and progress commands.'
)}
skillNames={[ORCA_CLI_SKILL_NAME]}
target={skillRuntime.bundledSkillTarget}
// COMPAT(wsl-bridge): removed when WSL runs as an orcad host.
terminalRuntime={agentRuntime}
installed={cliSkillDetected}
loading={cliSkillLoading}
error={cliSkillError}
onRecheck={refreshCliSkill}
freshnessSkillName={cliSkillFreshnessName}
/>
<div id={GENERAL_SETTING_ANCHORS.cliSkill}>
<AgentSkillSetupPanel
variant="row"
title={t('settings.general.cli.skillTitle', 'Orca skill for agents')}
description={t(
'auto.components.settings.CliSection.e8012c03a1',
'Enables agents to use Orca workspace, terminal, and progress commands.'
)}
skillNames={[ORCA_CLI_SKILL_NAME]}
target={skillRuntime.bundledSkillTarget}
// COMPAT(wsl-bridge): removed when WSL runs as an orcad host.
terminalRuntime={agentRuntime}
installed={cliSkillDetected}
loading={cliSkillLoading}
error={cliSkillError}
onRecheck={refreshCliSkill}
freshnessSkillName={cliSkillFreshnessName}
/>
</div>
) : null}
<WslCliRegistration currentPlatform={currentPlatform} />
@@ -11,6 +11,7 @@ import { Button } from '../../ui/button'
import { Badge } from '../../ui/badge'
import { ComputerUseSkillSetupPanel } from './ComputerUseSkillSetupPanel'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { COMPUTER_USE_SETTINGS_TARGET_ID } from './computer-use-search'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
@@ -292,7 +293,7 @@ export function ComputerUsePane(): React.JSX.Element {
return (
<div className="space-y-8">
{isMac ? (
<SettingsGroup>
<SettingsGroup id={COMPUTER_USE_SETTINGS_TARGET_ID}>
<div className="flex flex-wrap items-start justify-between gap-4 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
@@ -384,7 +385,7 @@ export function ComputerUsePane(): React.JSX.Element {
</SettingsGroup>
) : null}
<SettingsGroup>
<SettingsGroup id={isMac ? undefined : COMPUTER_USE_SETTINGS_TARGET_ID}>
{/* The inline panel adds pt-1.5 of its own; this evens the top inset with the bottom. */}
<div className="pb-4 pt-2.5">
<ComputerUseSkillSetupPanel />
@@ -2,8 +2,12 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
// The pane overview lands on its first card: macOS permissions, else the skill setup.
export const COMPUTER_USE_SETTINGS_TARGET_ID = 'computer-use-overview'
export const getComputerUsePaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: COMPUTER_USE_SETTINGS_TARGET_ID,
title: translate('auto.components.settings.computer.use.search.c3c3d70b5e', 'Computer use'),
description: translate(
'auto.components.settings.computer.use.search.9210db582b',
@@ -30,10 +30,10 @@ import { showDeveloperPermissionRequestNotice } from './developer-permission-req
import { TerminalTccAttributionNotice } from './TerminalTccAttributionNotice'
import { SETTINGS_GROUP_ROW_BLEED_CLASS, SettingsGroup } from '../section-layout/SettingsGroup'
import { cn } from '@/lib/class-names'
type DeveloperPermissionsPaneProps = {
highlightedSettingId?: string | null
}
import {
DEVELOPER_PERMISSIONS_OVERVIEW_ID,
getDeveloperPermissionSettingId
} from './developer-permissions-search'
type PermissionDefinition = {
id: DeveloperPermissionId
@@ -223,9 +223,7 @@ const PERMISSIONS: PermissionDefinition[] = [
}
]
export function DeveloperPermissionsPane({
highlightedSettingId = null
}: DeveloperPermissionsPaneProps): React.JSX.Element {
export function DeveloperPermissionsPane(): React.JSX.Element {
const t = useTranslate()
const [states, setStates] = useState<DeveloperPermissionState[]>([])
const [loading, setLoading] = useState(true)
@@ -318,6 +316,7 @@ export function DeveloperPermissionsPane({
<div className="space-y-8">
<TerminalTccAttributionNotice />
<SettingsGroup
id={DEVELOPER_PERMISSIONS_OVERVIEW_ID}
title={
<span className="flex items-center gap-2">
<ShieldCheck className="size-4" />
@@ -342,16 +341,15 @@ export function DeveloperPermissionsPane({
const state = stateById.get(permission.id)
const status = state?.status
const pending = pendingId === permission.id
const settingId = `developer-permissions-${permission.id}`
const settingId = getDeveloperPermissionSettingId(permission.id)
return (
<div key={permission.id} data-settings-row="">
<div
data-settings-section={settingId}
data-highlighted={highlightedSettingId === settingId ? 'true' : undefined}
className={cn(
SETTINGS_GROUP_ROW_BLEED_CLASS,
'flex items-center justify-between gap-4 py-3 transition-[background-color,box-shadow] duration-500 data-[highlighted=true]:bg-accent data-[highlighted=true]:ring-2 data-[highlighted=true]:ring-inset data-[highlighted=true]:ring-ring/50 motion-reduce:transition-none'
'flex items-center justify-between gap-4 py-3'
)}
>
<div className="flex min-w-0 items-start gap-3">
@@ -1,9 +1,17 @@
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import type { DeveloperPermissionId } from '../../../../../shared/developer-permissions-types'
export const DEVELOPER_PERMISSIONS_OVERVIEW_ID = 'developer-permissions-overview'
export function getDeveloperPermissionSettingId(id: DeveloperPermissionId): string {
return `developer-permissions-${id}`
}
export const getDeveloperPermissionsPaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: DEVELOPER_PERMISSIONS_OVERVIEW_ID,
title: translate(
'auto.components.settings.developer.permissions.search.ad085fa291',
'Developer permissions'
@@ -36,6 +44,7 @@ export const getDeveloperPermissionsPaneSearchEntries = createLocalizedCatalog((
]
},
{
targetSectionId: getDeveloperPermissionSettingId('microphone'),
title: translate(
'auto.components.settings.developer.permissions.search.16490ec4cd',
'Microphone and camera'
@@ -80,6 +89,7 @@ export const getDeveloperPermissionsPaneSearchEntries = createLocalizedCatalog((
]
},
{
targetSectionId: getDeveloperPermissionSettingId('screen'),
title: translate(
'auto.components.settings.developer.permissions.search.70ffdc218d',
'Screen recording and accessibility'
@@ -144,6 +154,7 @@ export const getDeveloperPermissionsPaneSearchEntries = createLocalizedCatalog((
]
},
{
targetSectionId: getDeveloperPermissionSettingId('local-network'),
title: translate(
'auto.components.settings.developer.permissions.search.3363889768',
'LAN, USB, and Bluetooth'
@@ -17,6 +17,7 @@ type SettingsRowProps = {
className?: string
labelId?: string
alignTop?: boolean
id?: string
}
export function SettingsRow({
@@ -25,10 +26,12 @@ export function SettingsRow({
control,
className,
labelId,
alignTop
alignTop,
id
}: SettingsRowProps): React.JSX.Element {
return (
<div
id={id}
data-settings-row=""
className={cn(
'flex gap-4',
@@ -81,11 +81,6 @@ function NavigationHarness({
)
return (
<TooltipProvider>
<input
aria-label="Search settings"
value={useAppStore((state) => state.settingsSearchInputQuery)}
onChange={(event) => setQuery(event.target.value)}
/>
<button onClick={() => requestTarget('general-open-in-apps')}>Open app settings link</button>
<GeneralPane
settings={settings}
@@ -108,20 +103,13 @@ function NavigationHarness({
)
}
function renderNavigationHarness({
initialQuery = 'worktree',
...props
}: Parameters<typeof NavigationHarness>[0] & { initialQuery?: string } = {}) {
useAppStore.setState({
settingsSearchQuery: initialQuery,
settingsSearchInputQuery: initialQuery
})
function renderNavigationHarness(props: Parameters<typeof NavigationHarness>[0] = {}) {
return render(<NavigationHarness {...props} />)
}
it('opens an old subsection link from another child with a conflicting query, including repeated selection', async () => {
it('opens an old subsection link from another child, including repeated selection', async () => {
const scroll = vi.spyOn(HTMLElement.prototype, 'scrollIntoView')
const { container } = renderNavigationHarness({ initialQuery: 'font' })
const { container } = renderNavigationHarness()
fireEvent.click(screen.getByRole('button', { name: 'Open app settings link' }))
const add = await screen.findByRole('button', { name: 'Add app' })
const target = container.querySelector('#general-open-in-apps')
@@ -131,21 +119,3 @@ it('opens an old subsection link from another child with a conflicting query, in
fireEvent.click(screen.getByRole('button', { name: 'Open app settings link' }))
await waitFor(() => expect(scroll.mock.contexts).toContain(target))
})
it('keeps an incomplete Open in draft when search temporarily replaces its form', async () => {
const onWrite = vi.fn()
renderNavigationHarness({ initialPage: 'open-in', initialQuery: '', onWrite })
fireEvent.keyDown(screen.getByRole('button', { name: 'Add app' }), { key: 'ArrowDown' })
fireEvent.click(await screen.findByRole('menuitem', { name: /Custom/ }))
fireEvent.change(screen.getByPlaceholderText('App name'), { target: { value: 'Draft editor' } })
fireEvent.blur(screen.getByPlaceholderText('App name'))
fireEvent.change(screen.getByRole('textbox', { name: 'Search settings' }), {
target: { value: 'open in' }
})
await screen.findByRole('button', { name: /Open in apps/i })
fireEvent.change(screen.getByRole('textbox', { name: 'Search settings' }), {
target: { value: '' }
})
expect(await screen.findByDisplayValue('Draft editor')).toBeTruthy()
expect(onWrite).not.toHaveBeenCalled()
})
@@ -167,6 +167,7 @@ export const getGeneralCliSearchEntries = createLocalizedCatalog(() => [
targetSectionId: 'cli'
},
{
targetSectionId: GENERAL_SETTING_ANCHORS.cliSkill,
title: translate('settings.general.cli.skillTitle', 'Orca skill for agents'),
description: translate(
'auto.components.settings.general.search.244e3fb4c8',
@@ -6,6 +6,7 @@ export const GENERAL_SETTING_ANCHORS = {
nesting: 'general-workspace-nesting',
worktrees: 'general-global-worktree-visibility',
openIn: 'general-open-in-apps',
cliSkill: 'general-cli-skill',
autoSave: 'general-auto-save',
autoSaveDelay: 'general-editor-auto-save-delay',
formatOnSave: 'general-editor-format-on-save',
@@ -28,6 +29,14 @@ export const GENERAL_SETTING_ANCHORS = {
reviewNotes: 'general-editor-review-notes'
} as const
// A search result for a General sub-page opens that page at this row.
export const GENERAL_SUB_PAGE_ENTRY_ANCHORS: readonly (readonly [GeneralSubPage, string])[] = [
['folder', GENERAL_SETTING_ANCHORS.folder],
['worktrees', GENERAL_SETTING_ANCHORS.worktrees],
['open-in', GENERAL_SETTING_ANCHORS.openIn],
['editor', GENERAL_SETTING_ANCHORS.autoSaveDelay]
]
export const GENERAL_SETTING_OWNERS: Readonly<Record<string, GeneralSubPage | null>> = {
[GENERAL_SETTING_ANCHORS.folder]: 'folder',
[GENERAL_SETTING_ANCHORS.nesting]: 'folder',
@@ -12,12 +12,20 @@ import { LinearIntegrationCard } from '../../integrations/task-trackers/linear-i
import { IntegrationCardPresentationProvider } from '../../integrations/presentation/integration-card-presentation'
import { useIntegrationProviderStatusRefresh } from '../../../hooks/use-integration-provider-status-refresh'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { INTEGRATION_SETTING_TARGET_IDS } from './integrations-search'
import { useTranslate } from '@/i18n/use-translate'
// Flat presentation turns each provider into one row: -mx-4 cancels its inset, and inner rules are dropped so only rows divide.
function ProviderRow({ children }: { children: React.ReactNode }): React.JSX.Element {
function ProviderRow({
id,
children
}: {
/** Search anchor; task-provider cards carry their own. */
id?: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div data-settings-row="" className="[&_.border-t]:border-t-0 [&>*]:-mx-4">
<div id={id} data-settings-row="" className="scroll-mt-6 [&_.border-t]:border-t-0 [&>*]:-mx-4">
{children}
</div>
)
@@ -37,19 +45,19 @@ export function IntegrationsPane(): React.JSX.Element {
'Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.'
)}
>
<ProviderRow>
<ProviderRow id={INTEGRATION_SETTING_TARGET_IDS.github}>
<GitHubIntegrationCard />
</ProviderRow>
<ProviderRow>
<ProviderRow id={INTEGRATION_SETTING_TARGET_IDS.gitlab}>
<GitLabIntegrationCard />
</ProviderRow>
<ProviderRow>
<ProviderRow id={INTEGRATION_SETTING_TARGET_IDS.bitbucket}>
<BitbucketIntegrationCard />
</ProviderRow>
<ProviderRow>
<ProviderRow id={INTEGRATION_SETTING_TARGET_IDS.azuredevops}>
<AzureDevOpsIntegrationCard />
</ProviderRow>
<ProviderRow>
<ProviderRow id={INTEGRATION_SETTING_TARGET_IDS.gitea}>
<GiteaIntegrationCard />
</ProviderRow>
</SettingsGroup>
@@ -1,9 +1,25 @@
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import {
JIRA_INTEGRATION_SECTION_ID,
LINEAR_INTEGRATION_SECTION_ID
} from '../../integrations/task-trackers/task-provider-integration-section-ids'
// Provider rows carry these ids so search results can scroll to and highlight them.
export const INTEGRATION_SETTING_TARGET_IDS = {
github: 'integrations-github',
gitlab: 'integrations-gitlab',
bitbucket: 'integrations-bitbucket',
azuredevops: 'integrations-azure-devops',
gitea: 'integrations-gitea',
jira: JIRA_INTEGRATION_SECTION_ID,
linear: LINEAR_INTEGRATION_SECTION_ID
} as const
export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.github,
title: translate(
'auto.components.settings.integrations.search.a17a94d0b0',
'GitHub integration'
@@ -25,6 +41,7 @@ export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.gitlab,
title: translate(
'auto.components.settings.integrations.search.cd74b2f587',
'GitLab integration'
@@ -51,6 +68,7 @@ export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.bitbucket,
title: translate(
'auto.components.settings.integrations.search.9e238582e8',
'Bitbucket integration'
@@ -95,6 +113,7 @@ export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.azuredevops,
title: translate(
'auto.components.settings.integrations.search.b3efe7ab71',
'Azure DevOps integration'
@@ -128,6 +147,7 @@ export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.gitea,
title: translate(
'auto.components.settings.integrations.search.86a08aa60c',
'Gitea integration'
@@ -157,6 +177,7 @@ export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.jira,
title: translate('auto.components.settings.integrations.search.d8906d60a9', 'Jira integration'),
description: translate(
'auto.components.settings.integrations.search.76f6af7c57',
@@ -191,6 +212,7 @@ export const getIntegrationsPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: INTEGRATION_SETTING_TARGET_IDS.linear,
title: translate(
'auto.components.settings.integrations.search.d905b3ecee',
'Linear integration'
@@ -27,7 +27,7 @@ import type { MobileRelayMintFailure } from '../../../../../shared/remote-runtim
import { useMobilePairingConnectionMode } from '../../mobile/use-mobile-pairing-connection-mode'
import { useMobilePairingAddressPreference } from '../../mobile/use-mobile-pairing-address-preference'
import { usePairingOfferExpiry } from '../../mobile/use-pairing-offer-expiry'
import { shouldOpenMobilePairingAddress } from './mobile-pane-search'
import { MOBILE_SETTING_ANCHORS, shouldOpenMobilePairingAddress } from './mobile-pane-search'
export function MobilePane(): React.JSX.Element {
const t = useTranslate()
@@ -349,7 +349,7 @@ export function MobilePane(): React.JSX.Element {
<MachineNameField id="mobile-machine-name" />
</div>
<div data-settings-row="" className="space-y-6 py-4">
<div id={MOBILE_SETTING_ANCHORS.pairing} data-settings-row="" className="space-y-6 py-4">
<MobilePairingSetupSection
connectionMode={connectionMode}
canGenerate={canMintMobilePairingOffer({ connectionMode, signedIn })}
@@ -416,7 +416,7 @@ export function MobilePane(): React.JSX.Element {
/>
</div>
<div data-settings-row="" className="py-4">
<div id={MOBILE_SETTING_ANCHORS.devices} data-settings-row="" className="py-4">
<MobilePairedDevicesSection
devices={devices}
hasQrCode={qrDataUrl != null}
@@ -1,6 +1,7 @@
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import { getEmulatorOpeningMode } from '../../../../../../shared/emulator-opening-preferences'
import { SettingsRow } from '../../form-controls/SettingsFormControls'
import { MOBILE_EMULATOR_SETTING_ANCHORS } from './mobile-emulator-search'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
import { useTranslate } from '@/i18n/use-translate'
import { simulatorRuntimeLabel } from '@/lib/simulator/launch/simulator-runtime-label'
@@ -21,6 +22,7 @@ export function EmulatorOpeningSettings({ settings, devices, enabled, updateSett
return (
<>
<SettingsRow
id={MOBILE_EMULATOR_SETTING_ANCHORS.opening}
alignTop
label={t('emulator.opening.when', 'When opening')}
description={t(
@@ -12,7 +12,10 @@ import { MobileEmulatorAvailabilityDetails } from './MobileEmulatorAvailabilityD
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { SettingsSwitchRow } from '../../form-controls/SettingsFormControls'
import { getMobileEmulatorSearchEntries } from './mobile-emulator-search'
import {
getMobileEmulatorSearchEntries,
MOBILE_EMULATOR_SETTING_ANCHORS
} from './mobile-emulator-search'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { emulatorErrorText, emulatorMessageText } from '@/lib/simulator/launch/emulator-error-copy'
@@ -169,7 +172,7 @@ export function MobileEmulatorSettingsPane({
onChange={() => updateSettings({ mobileEmulatorEnabled: !enabled })}
/>
<div className="py-3">
<div id={MOBILE_EMULATOR_SETTING_ANCHORS.availability} className="py-3">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1 space-y-0.5">
<Label>
@@ -233,6 +236,7 @@ export function MobileEmulatorSettingsPane({
{enabled ? (
<SearchableSetting
id={MOBILE_EMULATOR_SETTING_ANCHORS.agentControl}
title={t(
'auto.components.settings.MobileEmulatorSettingsPane.fd698d2d47',
'Agent mobile emulator control'
@@ -2,6 +2,12 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const MOBILE_EMULATOR_SETTING_ANCHORS = {
availability: 'mobile-emulator-availability',
opening: 'mobile-emulator-opening',
agentControl: 'mobile-emulator-agent-control'
} as const
export const getMobileEmulatorSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
@@ -68,6 +74,7 @@ export const getMobileEmulatorSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: MOBILE_EMULATOR_SETTING_ANCHORS.opening,
title: translate(
'auto.components.settings.mobile.emulator.search.b8c9f903eb',
'Default emulator device'
@@ -100,6 +107,7 @@ export const getMobileEmulatorSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: MOBILE_EMULATOR_SETTING_ANCHORS.availability,
title: translate(
'auto.components.settings.mobile.emulator.search.de556a0911',
'Emulator availability'
@@ -132,6 +140,7 @@ export const getMobileEmulatorSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: MOBILE_EMULATOR_SETTING_ANCHORS.agentControl,
title: translate(
'auto.components.settings.mobile.emulator.search.0d6e7dba38',
'Agent CLI control'
@@ -4,8 +4,15 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from '../search/settings-search'
import { getMachineNameSearchEntries } from '../machine-identity/machine-name-search'
export const MOBILE_SETTING_ANCHORS = {
networkInterface: 'mobile-network-interface',
pairing: 'mobile-pairing',
devices: 'mobile-paired-devices'
} as const
const getNetworkInterfaceSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: MOBILE_SETTING_ANCHORS.networkInterface,
title: translate('auto.components.settings.mobile.pane.search.a068d70f7b', 'Network interface'),
description: translate(
'auto.components.settings.mobile.pane.search.3190ef67a4',
@@ -54,6 +61,7 @@ export function shouldOpenMobilePairingAddress(searchQuery: string): boolean {
export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: MOBILE_SETTING_ANCHORS.pairing,
title: translate('auto.components.settings.mobile.pane.search.1dcb1d1a11', 'Mobile pairing'),
description: translate(
'auto.components.settings.mobile.pane.search.7fb728fb2b',
@@ -69,6 +77,7 @@ export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: MOBILE_SETTING_ANCHORS.devices,
title: translate('auto.components.settings.mobile.pane.search.cb018bcc07', 'Connected devices'),
description: translate(
'auto.components.settings.mobile.pane.search.13419718b3',
@@ -2,6 +2,8 @@ import { useState, type ReactNode } from 'react'
import { ChevronDown, Loader2, QrCode, RefreshCw } from 'lucide-react'
import { Button } from '../../../ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../ui/collapsible'
import { useSettingsTargetReveal } from '../../section-layout/settings-target-highlight'
import { MOBILE_SETTING_ANCHORS } from '../mobile-pane-search'
import { Tooltip, TooltipContent, TooltipTrigger } from '../../../ui/tooltip'
import { cn } from '@/lib/class-names'
import { useTranslate } from '@/i18n/use-translate'
@@ -53,6 +55,9 @@ export function MobilePairingSetupSection({
const t = useTranslate()
const usingRelay = connectionMode === 'automatic'
const [addressDisclosureOpen, setAddressDisclosureOpen] = useState(false)
const addressRevealRef = useSettingsTargetReveal<HTMLDivElement>(() =>
setAddressDisclosureOpen(true)
)
// Render search/custom-address pickers outright; a collapse trigger cannot close pinned-open state.
const addressDisclosurePinned = addressDisclosureForcedOpen || selectedAddressIsCustom
// Relay can use main’s default LAN endpoint; LAN-only requires an explicit reachable address.
@@ -64,7 +69,7 @@ export function MobilePairingSetupSection({
)
const addressControls = (
<div className="space-y-2">
<div id={MOBILE_SETTING_ANCHORS.networkInterface} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
@@ -145,7 +150,11 @@ export function MobilePairingSetupSection({
</div>
) : usingRelay ? (
// Keep Relay’s optional direct path available in a disclosure without dominating setup.
<Collapsible open={addressDisclosureOpen} onOpenChange={setAddressDisclosureOpen}>
<Collapsible
ref={addressRevealRef}
open={addressDisclosureOpen}
onOpenChange={setAddressDisclosureOpen}
>
<CollapsibleTrigger asChild>
<Button
type="button"
@@ -162,7 +171,8 @@ export function MobilePairingSetupSection({
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{/* Mounted while closed so a settings jump can find and reveal the address picker. */}
<CollapsibleContent forceMount className="data-[state=closed]:hidden">
<div className="mt-2 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
{addressControls}
</div>
@@ -1,6 +1,6 @@
import { type RefObject, useMemo } from 'react'
import { ArrowLeft, Search, Server } from 'lucide-react'
import type { KeyboardEvent, RefObject } from 'react'
import { useDeferredValue, useId, useMemo, useState } from 'react'
import { ArrowLeft, Server } from 'lucide-react'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import type {
SettingsNavIcon,
@@ -8,13 +8,10 @@ import type {
} from '@/components/settings/navigation/settings-navigation-types'
import type { GitHubRepositoryIdentity } from '../../../../../shared/github/repositories/repository-identity'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../../ShortcutKeyCombo'
import { cn } from '@/lib/class-names'
import { RepoIconGlyph } from '../../repo/repo-icon'
import { RepoForkIndicator } from '../../repo/repo-fork-indicator'
import { Button } from '../../ui/button'
import { Input } from '../../ui/input'
import { SetupGuideProgressRing } from '../../setup-guide/SetupGuideProgressRing'
import {
useSettingsSetupGuideProgress,
@@ -24,6 +21,12 @@ import {
import { useTranslate } from '@/i18n/use-translate'
import { resolveLeftSidebarStyleVariables } from '@/app-shell/appearance/left-sidebar-appearance'
import { useAppStore } from '@/store/index'
import {
rankSettingsSearchResults,
SETTINGS_SEARCH_RESULT_LIMIT,
type SettingsSearchResult
} from './settings-search-results'
import { SettingsSearchField, SettingsSearchResultsList } from './SettingsSidebarSearch'
type NavSection = {
id: string
@@ -51,50 +54,12 @@ type SettingsSidebarProps = {
settings: GlobalSettings | null
generalGroups: NavGroup[]
repoSections: RepoNavSection[]
hasRepos: boolean
searchCandidates: readonly SettingsSearchResult[]
searchInputRef?: RefObject<HTMLInputElement | null>
searchAutoFocus?: boolean
onBack: () => void
onSelectSection: (sectionId: string) => void
}
function SettingsSearchField({
searchInputRef,
searchAutoFocus = false
}: Pick<SettingsSidebarProps, 'searchInputRef' | 'searchAutoFocus'>): React.JSX.Element {
const t = useTranslate()
const searchQuery = useAppStore((state) => state.settingsSearchInputQuery)
const onSearchChange = useAppStore((state) => state.setSettingsSearchQuery)
const searchShortcutCombos = useShortcutKeyComboDetails('settings.search')
return (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={t('auto.components.settings.SettingsSidebar.dbceaa8840', 'Search settings')}
className="bg-background/60 pl-9 pr-14 text-[13px]"
/>
{searchQuery === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
) : null}
</div>
</div>
)
onOpenSearchResult: (result: SettingsSearchResult) => void
}
type VisibleInstallStatus = Extract<
@@ -161,13 +126,79 @@ export function SettingsSidebar({
settings,
generalGroups,
repoSections,
hasRepos,
searchCandidates,
searchInputRef,
searchAutoFocus = false,
onBack,
onSelectSection
onSelectSection,
onOpenSearchResult
}: SettingsSidebarProps): React.JSX.Element {
const t = useTranslate()
const searchQuery = useAppStore((state) => state.settingsSearchInputQuery)
const setSearchQuery = useAppStore((state) => state.setSettingsSearchQuery)
const listboxId = useId()
// Ranking follows the deferred query so a large catalog never blocks typing.
const deferredSearchQuery = useDeferredValue(searchQuery)
const rankedResults = useMemo(
() => rankSettingsSearchResults(deferredSearchQuery, searchCandidates),
[searchCandidates, deferredSearchQuery]
)
const searchResults = rankedResults.slice(0, SETTINGS_SEARCH_RESULT_LIMIT)
const hiddenResultCount = rankedResults.length - searchResults.length
const isSearching = searchQuery.trim() !== ''
// Nothing is selected until the user moves or picks; typing only changes the rows.
const [selectedResultKey, setSelectedResultKey] = useState<string | null>(null)
const selectedResultIndex = searchResults.findIndex((result) => result.key === selectedResultKey)
const selectedResult = selectedResultIndex === -1 ? undefined : searchResults[selectedResultIndex]
const repoSectionById = useMemo(
() => new Map(repoSections.map((section) => [section.id, section] as const)),
[repoSections]
)
const groupTitleById = useMemo(
() => new Map(generalGroups.map((group) => [group.id, group.title] as const)),
[generalGroups]
)
const getOptionId = (index: number): string => `${listboxId}-option-${index}`
const pickResult = (result: SettingsSearchResult): void => {
setSelectedResultKey(result.key)
onOpenSearchResult(result)
}
const changeSearchQuery = (query: string): void => {
setSearchQuery(query)
setSelectedResultKey(null)
}
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.nativeEvent.isComposing) {
return
}
if (event.key === 'Escape' && searchQuery !== '') {
// Keep the page-level Escape from closing Settings while clearing search.
event.preventDefault()
changeSearchQuery('')
return
}
if (!isSearching || searchResults.length === 0) {
return
}
const count = searchResults.length
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const nextIndex =
selectedResultIndex === -1
? event.key === 'ArrowDown'
? 0
: count - 1
: (selectedResultIndex + (event.key === 'ArrowDown' ? 1 : -1) + count) % count
setSelectedResultKey(searchResults[nextIndex].key)
document.getElementById(getOptionId(nextIndex))?.scrollIntoView({ block: 'nearest' })
return
}
if (event.key === 'Enter' && selectedResult) {
event.preventDefault()
pickResult(selectedResult)
}
}
const setupGuideProgress = useSettingsSetupGuideProgress(true)
const leftSidebarStyle = useMemo(() => resolveLeftSidebarStyleVariables(settings), [settings])
const setupActive = activeSectionId === 'setup-guide'
@@ -200,7 +231,8 @@ export function SettingsSidebar({
className="flex w-[280px] shrink-0 flex-col border-r border-worktree-sidebar-border bg-worktree-sidebar"
style={leftSidebarStyle}
>
<div className="border-b border-worktree-sidebar-border px-3 py-3">
{/* Same 48px height as SettingsHeader so both bottom borders meet in one line. */}
<div className="box-border flex h-12 shrink-0 items-center border-b border-worktree-sidebar-border px-3">
<Button
variant="ghost"
size="sm"
@@ -212,9 +244,34 @@ export function SettingsSidebar({
</Button>
</div>
<SettingsSearchField searchInputRef={searchInputRef} searchAutoFocus={searchAutoFocus} />
<SettingsSearchField
searchInputRef={searchInputRef}
searchAutoFocus={searchAutoFocus}
query={searchQuery}
listboxId={listboxId}
expanded={isSearching && searchResults.length > 0}
activeOptionId={selectedResult ? getOptionId(selectedResultIndex) : undefined}
onQueryChange={changeSearchQuery}
onKeyDown={handleSearchKeyDown}
/>
{showSetupGuideTopRow ? (
{isSearching ? (
<SettingsSearchResultsList
results={searchResults}
selectedIndex={selectedResultIndex}
query={searchQuery}
listboxId={listboxId}
getOptionId={getOptionId}
repoSectionById={repoSectionById}
groupTitleById={groupTitleById}
hiddenResultCount={hiddenResultCount}
// The deferred ranking can trail typing by a frame; don't announce a false empty state.
pending={deferredSearchQuery !== searchQuery}
onSelect={pickResult}
/>
) : null}
{!isSearching && showSetupGuideTopRow ? (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<SettingsSetupGuideNavRow
progress={setupGuideProgress}
@@ -224,18 +281,56 @@ export function SettingsSidebar({
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek px-3 py-4">
<div className="space-y-5">
{generalGroups.map((group) => (
<div key={group.id} className="space-y-2">
{/* Not rendered while searching so typing never re-renders every project row. */}
{isSearching ? null : (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek px-3 py-4">
<div className="space-y-5">
{generalGroups.map((group) => (
<div key={group.id} className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
{group.title}
</p>
<div className="space-y-1">
{group.sections
.filter((section) => section.id !== 'setup-guide')
.map((section) => {
const Icon = section.icon
const isActive = activeSectionId === section.id
return (
<button
key={section.id}
aria-current={isActive ? 'page' : undefined}
data-current={isActive ? 'true' : undefined}
onClick={() => onSelectSection(section.id)}
className={navItemClassName(isActive)}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{section.title}</span>
{isVisibleInstallStatus(section.installStatus) ? (
<span className={installStatusClassName}>
{installStatusLabel(section.installStatus)}
</span>
) : section.badge ? (
<span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
{section.badge}
</span>
) : null}
</button>
)
})}
</div>
</div>
))}
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
{group.title}
{t('auto.components.settings.SettingsSidebar.5c9669ff9c', 'Projects')}
</p>
<div className="space-y-1">
{group.sections
.filter((section) => section.id !== 'setup-guide')
.map((section) => {
const Icon = section.icon
{repoSections.length > 0 ? (
<div className="space-y-1">
{repoSections.map((section) => {
const isActive = activeSectionId === section.id
return (
@@ -246,76 +341,36 @@ export function SettingsSidebar({
onClick={() => onSelectSection(section.id)}
className={navItemClassName(isActive)}
>
<Icon className="size-4 shrink-0" />
<RepoIconGlyph
repoIcon={section.repoIcon}
color={section.badgeColor}
className="size-4 shrink-0 text-muted-foreground"
iconClassName="size-3.5"
/>
<span className="truncate">{section.title}</span>
{isVisibleInstallStatus(section.installStatus) ? (
<span className={installStatusClassName}>
{installStatusLabel(section.installStatus)}
<RepoForkIndicator upstream={section.upstream} />
{section.isRemote && (
<span className="ml-auto inline-flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Server className="size-3" />
{t('auto.components.settings.SettingsSidebar.e0900f83e7', 'SSH')}
</span>
) : section.badge ? (
<span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
{section.badge}
</span>
) : null}
)}
</button>
)
})}
</div>
</div>
) : (
<p className="px-3 text-xs text-muted-foreground">
{t(
'auto.components.settings.SettingsSidebar.df38d612b7',
'No projects added yet.'
)}
</p>
)}
</div>
))}
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
{t('auto.components.settings.SettingsSidebar.5c9669ff9c', 'Projects')}
</p>
{repoSections.length > 0 ? (
<div className="space-y-1">
{repoSections.map((section) => {
const isActive = activeSectionId === section.id
return (
<button
key={section.id}
aria-current={isActive ? 'page' : undefined}
data-current={isActive ? 'true' : undefined}
onClick={() => onSelectSection(section.id)}
className={navItemClassName(isActive)}
>
<RepoIconGlyph
repoIcon={section.repoIcon}
color={section.badgeColor}
className="size-4 shrink-0 text-muted-foreground"
iconClassName="size-3.5"
/>
<span className="truncate">{section.title}</span>
<RepoForkIndicator upstream={section.upstream} />
{section.isRemote && (
<span className="ml-auto inline-flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Server className="size-3" />
{t('auto.components.settings.SettingsSidebar.e0900f83e7', 'SSH')}
</span>
)}
</button>
)
})}
</div>
) : (
<p className="px-3 text-xs text-muted-foreground">
{hasRepos
? t(
'auto.components.settings.SettingsSidebar.3e483e256b',
'No matching project settings.'
)
: t(
'auto.components.settings.SettingsSidebar.df38d612b7',
'No projects added yet.'
)}
</p>
)}
</div>
</div>
</div>
)}
</aside>
)
}
@@ -0,0 +1,206 @@
import type { KeyboardEvent, RefObject } from 'react'
import { Search, X } from 'lucide-react'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../../ShortcutKeyCombo'
import { cn } from '@/lib/class-names'
import { RepoIconGlyph } from '../../repo/repo-icon'
import { Button } from '../../ui/button'
import { Input } from '../../ui/input'
import { useTranslate } from '@/i18n/use-translate'
import { HighlightedText } from '../../worktree-jump/worktree-jump-palette-primitives'
import {
getSettingsSearchTitleMatchRanges,
type SettingsSearchResult
} from './settings-search-results'
type SettingsSearchFieldProps = {
searchInputRef?: RefObject<HTMLInputElement | null>
searchAutoFocus?: boolean
query: string
listboxId: string
expanded: boolean
activeOptionId: string | undefined
onQueryChange: (query: string) => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
}
export function SettingsSearchField({
searchInputRef,
searchAutoFocus = false,
query,
listboxId,
expanded,
activeOptionId,
onQueryChange,
onKeyDown
}: SettingsSearchFieldProps): React.JSX.Element {
const t = useTranslate()
const searchShortcutCombos = useShortcutKeyComboDetails('settings.search')
return (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={t('auto.components.settings.SettingsSidebar.dbceaa8840', 'Search settings')}
aria-label={t('auto.components.settings.SettingsSidebar.dbceaa8840', 'Search settings')}
role="combobox"
aria-autocomplete="list"
aria-expanded={expanded}
aria-controls={expanded ? listboxId : undefined}
aria-activedescendant={activeOptionId}
className="bg-background/60 pl-9 pr-14 text-[13px]"
/>
{query === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
) : (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => {
onQueryChange('')
searchInputRef?.current?.focus()
}}
aria-label={t('settings.search.clear', 'Clear search')}
className="absolute right-1.5 top-1/2 -translate-y-1/2"
>
<X />
</Button>
)}
</div>
</div>
)
}
type SettingsSearchResultsListProps = {
results: readonly SettingsSearchResult[]
selectedIndex: number
query: string
listboxId: string
getOptionId: (index: number) => string
repoSectionById: ReadonlyMap<string, { repoIcon?: RepoIcon | null; badgeColor?: string }>
groupTitleById: ReadonlyMap<string, string>
hiddenResultCount: number
pending: boolean
onSelect: (result: SettingsSearchResult) => void
}
export function SettingsSearchResultsList({
results: searchResults,
selectedIndex: selectedResultIndex,
query: searchQuery,
listboxId,
getOptionId,
repoSectionById,
groupTitleById,
hiddenResultCount,
pending,
onSelect
}: SettingsSearchResultsListProps): React.JSX.Element {
const t = useTranslate()
return (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek px-3 py-4">
{searchResults.length > 0 ? (
<div
id={listboxId}
role="listbox"
aria-label={t('settings.search.results', 'Settings search results')}
className="space-y-0.5"
>
{searchResults.map((result, index) => {
const isSelected = index === selectedResultIndex
const repoSection = repoSectionById.get(result.section.id)
const Icon = result.section.icon
return (
<button
key={result.key}
id={getOptionId(index)}
type="button"
role="option"
tabIndex={-1}
aria-selected={isSelected}
data-current={isSelected ? 'true' : undefined}
// Keep focus in the search field so arrows keep moving through results.
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(result)}
className={cn(
'flex min-h-10 w-full items-start gap-2 rounded-lg px-3 py-1 text-left outline-none transition-colors duration-150',
isSelected
? 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/25'
: 'hover:bg-worktree-sidebar-accent/60'
)}
>
{repoSection ? (
<RepoIconGlyph
repoIcon={repoSection.repoIcon}
color={repoSection.badgeColor}
className="mt-px size-4 shrink-0 text-muted-foreground"
iconClassName="size-3.5"
/>
) : (
<Icon
className={cn(
'mt-px size-4 shrink-0',
isSelected
? 'text-worktree-sidebar-accent-foreground'
: 'text-muted-foreground'
)}
/>
)}
<span className="min-w-0 flex-1">
<span className="block break-words text-[13px] leading-[18px] text-worktree-sidebar-foreground">
<HighlightedText
text={result.entry.title}
matchRanges={getSettingsSearchTitleMatchRanges(
result.entry.title,
searchQuery
)}
/>
</span>
<span className="block break-words text-xs leading-4 text-muted-foreground">
{result.isPane
? repoSection
? t('auto.components.settings.SettingsSidebar.5c9669ff9c', 'Projects')
: (groupTitleById.get(result.section.group) ?? result.section.title)
: result.section.title}
</span>
</span>
</button>
)
})}
</div>
) : null}
{searchResults.length > 0 && hiddenResultCount > 0 ? (
<p className="px-3 pt-3 text-xs text-muted-foreground">
{t('settings.search.moreResults', '{{value0}} more · keep typing to narrow', {
value0: hiddenResultCount
})}
</p>
) : null}
{searchResults.length === 0 && !pending ? (
<p role="status" className="px-3 py-6 text-center text-xs text-muted-foreground">
{t('settings.search.noResults', 'No settings match “{{value0}}”', {
value0: searchQuery.trim()
})}
</p>
) : null}
</div>
)
}
@@ -11,6 +11,8 @@ export function watchForSettingsDeepLinkTarget(args: {
root: HTMLElement | null
isTargetPresent: () => boolean
onTargetPresent: () => void
/** The target never mounted; the caller drops its pending jump so it cannot fire later. */
onTimeout?: () => void
}): SettingsDeepLinkTargetWatch {
const root = args.root ?? (typeof document === 'undefined' ? null : document.body)
if (!root || typeof MutationObserver === 'undefined') {
@@ -42,7 +44,11 @@ export function watchForSettingsDeepLinkTarget(args: {
args.onTargetPresent()
})
observer.observe(root, { subtree: true, childList: true })
timeout = window.setTimeout(finish, TARGET_WAIT_TIMEOUT_MS)
timeout = window.setTimeout(() => {
timeout = null
finish()
args.onTimeout?.()
}, TARGET_WAIT_TIMEOUT_MS)
return { cancel: finish }
}
@@ -9,6 +9,7 @@ import type {
SettingsNavTarget
} from '@/components/settings/navigation/settings-navigation-types'
import type { SettingsDeepLinkTargetWatch } from './settings-deep-link-target-watcher'
import { parseSettingsTitleTargetId } from '../section-layout/settings-target-highlight'
export const SETTINGS_NAV_GROUPS = [
{
@@ -116,20 +117,65 @@ export function getSettingsScrollTarget(
sectionId: string,
container?: HTMLElement | null
): HTMLElement | null {
const title = parseSettingsTitleTargetId(sectionId)
if (title !== null) {
return (
(container ?? document).querySelector<HTMLElement>(
`[data-settings-search-title="${CSS.escape(title)}"]`
) ?? (container ? findSettingsRowByVisibleTitle(title, container) : null)
)
}
return (
container?.querySelector<HTMLElement>(`[data-settings-section="${CSS.escape(sectionId)}"]`) ??
document.getElementById(sectionId)
)
}
// Rows not wrapped in SearchableSetting are found by the label they render.
function findSettingsRowByVisibleTitle(title: string, root: HTMLElement): HTMLElement | null {
const wanted = title.trim().toLowerCase()
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const element = node.parentElement
// The pane's own h2 heading is not a setting row.
if (node.textContent?.trim().toLowerCase() !== wanted || !element || element.closest('h2')) {
continue
}
// A group title stands for its whole card, not the heading text.
const groupCard = element
.closest('[data-settings-group-heading]')
?.closest('[data-settings-group]')
?.querySelector<HTMLElement>('[data-settings-group-card]')
// Free-form card content has no row; the card stands in rather than the bare label.
return (
groupCard ??
element.closest<HTMLElement>('[data-settings-row]') ??
element.closest<HTMLElement>('[data-settings-group-card]') ??
element
)
}
return null
}
function hasNestedScroller(target: HTMLElement, container: HTMLElement): boolean {
for (let node = target.parentElement; node && node !== container; node = node.parentElement) {
const overflowY = getComputedStyle(node).overflowY
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
return true
}
}
return false
}
export function scrollSubsectionIntoView(targetId: string, container?: HTMLElement | null): void {
// Whole-pane swaps need inner scrolling only when the pane exceeds the viewport.
const target = getSettingsScrollTarget(targetId, container)
if (!target) {
return
}
if (!container) {
target.scrollIntoView({ block: 'start' })
// A target inside its own scroll area (the Shortcuts list) needs every scroller moved, not just the page.
if (!container || hasNestedScroller(target, container)) {
target.scrollIntoView({ block: container ? 'center' : 'start' })
return
}
const containerRect = container.getBoundingClientRect()
@@ -74,6 +74,7 @@ export const BROWSER_USER_AGENT_SETTINGS_TARGET_ID = 'browser-user-agent'
export const CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID =
'agents-codex-terminal-server-isolation'
export const GLOBAL_WORKTREE_VISIBILITY_SETTINGS_TARGET_ID = 'general-global-worktree-visibility'
export const NODE_DIST_MIRROR_SETTINGS_TARGET_ID = 'advanced-node-dist-mirror'
export type SettingsNavigationTarget = {
pane: SettingsNavTarget
@@ -0,0 +1,53 @@
import { Settings } from 'lucide-react'
import { expect, it } from 'vitest'
import type { SettingsNavSection } from './settings-navigation-types'
import {
buildSettingsSearchResults,
getSettingsSearchTitleMatchRanges,
rankSettingsSearchResults
} from './settings-search-results'
function section(
id: string,
title: string,
searchEntries: SettingsNavSection['searchEntries']
): SettingsNavSection {
return { id, title, description: '', icon: Settings, group: 'interface', searchEntries }
}
it('ranks individual settings across panes and targets each one', () => {
const results = buildSettingsSearchResults(
[
section('general', 'General', [
{ title: 'Auto update', keywords: ['release'] },
{ title: 'Auto update' },
{ title: 'Theme', keywords: ['update'] }
]),
section('updates', 'Updates', [{ title: 'Channel', targetSectionId: 'updates-channel' }]),
section('repo-1', 'orca', [{ title: 'Update hooks' }])
],
(_section, entry) => (entry.title === 'Theme' ? 'general-theme' : undefined)
)
expect(
rankSettingsSearchResults('update', results).map((result) => [
result.section.id,
result.entry.title,
result.targetId
])
).toEqual([
['updates', 'Updates', 'updates'],
['repo-1', 'Update hooks', 'settings-title:Update hooks'],
['general', 'Auto update', 'settings-title:Auto update'],
['general', 'Theme', 'general-theme']
])
expect(rankSettingsSearchResults('channel', results)[0]?.targetId).toBe('updates-channel')
expect(rankSettingsSearchResults(' ', results)).toEqual([])
})
it('marks the same text the ranking fold matched, including dotted capital I', () => {
expect(getSettingsSearchTitleMatchRanges('İstanbul', 'stan')).toEqual([{ start: 1, end: 5 }])
expect(getSettingsSearchTitleMatchRanges('İstanbul', 'İst')).toEqual([{ start: 0, end: 3 }])
expect(getSettingsSearchTitleMatchRanges('Line height', 'İ')).toEqual([{ start: 1, end: 2 }])
expect(getSettingsSearchTitleMatchRanges('Theme', 'update')).toEqual([])
})
@@ -0,0 +1,93 @@
import type { MatchRange } from '../../../../../shared/palette-match/normalized-text'
import type { SettingsNavSection } from './settings-navigation-types'
import {
foldSettingsSearchText,
getSettingsSectionSearchEntries,
normalizeSettingsSearchQuery,
rankSettingsSearchItems,
type SettingsSearchEntry
} from '../search/settings-search'
import { getSettingsTitleTargetId } from '../section-layout/settings-target-highlight'
// Enough to scan; past this the query is too broad to be useful.
export const SETTINGS_SEARCH_RESULT_LIMIT = 50
export type SettingsSearchResult = {
key: string
section: SettingsNavSection
entry: SettingsSearchEntry
isPane: boolean
/** Scroll target inside the pane; the pane id itself means "show from the top". */
targetId: string
}
export function buildSettingsSearchResults(
sections: readonly SettingsNavSection[],
resolveAnchorId: (section: SettingsNavSection, entry: SettingsSearchEntry) => string | undefined
): SettingsSearchResult[] {
const results: SettingsSearchResult[] = []
for (const section of sections) {
const seenTitles = new Set<string>()
getSettingsSectionSearchEntries(section).forEach((entry, index) => {
if (seenTitles.has(entry.title)) {
return
}
seenTitles.add(entry.title)
const isPane = index === 0
results.push({
key: `${section.id}\u0000${entry.title}`,
section,
entry,
isPane,
targetId: isPane
? section.id
: (entry.targetSectionId ??
resolveAnchorId(section, entry) ??
getSettingsTitleTargetId(entry.title))
})
})
}
return results
}
export function rankSettingsSearchResults(
query: string,
results: readonly SettingsSearchResult[]
): SettingsSearchResult[] {
if (normalizeSettingsSearchQuery(query) === '') {
return []
}
return rankSettingsSearchItems(
query,
results,
(result) => result.entry,
(result) => ({ firstEntryIsPane: result.isPane })
).map(({ item }) => item)
}
/** Range of a title's first match under the ranking fold, mapped back to the original title. */
export function getSettingsSearchTitleMatchRanges(title: string, query: string): MatchRange[] {
const needle = normalizeSettingsSearchQuery(query)
if (needle === '') {
return []
}
// Fold per code point so each folded unit maps back to the original text it came from.
let folded = ''
const originalStarts: number[] = []
const originalEnds: number[] = []
let offset = 0
for (const char of title) {
const chunk = foldSettingsSearchText(char)
for (let i = 0; i < chunk.length; i += 1) {
originalStarts.push(offset)
originalEnds.push(offset + char.length)
}
folded += chunk
offset += char.length
}
const start = folded.indexOf(needle)
if (start === -1) {
return []
}
return [{ start: originalStarts[start], end: originalEnds[start + needle.length - 1] }]
}
@@ -18,6 +18,12 @@ import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS as SCROLLBACK_PRESETS_ROWS } fr
import type { SettingsStoreModel } from '../page/use-settings-store-model'
import type { SettingsInteractionController } from '../page/use-settings-interaction-controller'
import { hasReadyVoiceModel } from './settings-navigation-foundations'
import { buildSettingsSearchResults } from './settings-search-results'
import { getGeneralWorkspaceSearchDescriptors } from '../general/general-search'
import { getGeneralEditorSearchDescriptors } from '../editor/general-editor-search'
import { GENERAL_SUB_PAGE_ENTRY_ANCHORS } from '../general/general-setting-anchors'
import { getGeneralSubPageTitle } from '../general/general-sub-pages'
import { translate } from '@/i18n/i18n'
export function useSettingsNavigationModel(
model: SettingsStoreModel,
@@ -129,6 +135,20 @@ export function useSettingsNavigationModel(
() => new Map(navSections.map((section) => [section.id, section] as const)),
[navSections]
)
const searchCandidates = useMemo(() => {
// General sub-page rows carry anchors in descriptors, not in their search entries.
const generalAnchorByTitle = new Map<string, string>([
...[...getGeneralWorkspaceSearchDescriptors(), ...getGeneralEditorSearchDescriptors()].map(
(descriptor) => [descriptor.entry.title, descriptor.sectionId] as const
),
...GENERAL_SUB_PAGE_ENTRY_ANCHORS.map(
([page, anchor]) => [getGeneralSubPageTitle(page, translate), anchor] as const
)
])
return buildSettingsSearchResults(navSections, (section, entry) =>
section.id === 'general' ? generalAnchorByTitle.get(entry.title) : undefined
)
}, [navSections])
const getSectionSearchEntries = (sectionId: string) => {
const section = navSectionById.get(sectionId)
return section ? getSettingsSectionSearchEntries(section) : []
@@ -204,6 +224,7 @@ export function useSettingsNavigationModel(
displayedGitUsername,
navSections,
getSectionSearchEntries,
searchCandidates,
visibleNavSections,
visibleSectionIds,
projectByRepoId,
@@ -9,6 +9,7 @@ import type { SettingsInteractionController } from '../page/use-settings-interac
import type { SettingsNavigationModel } from './use-settings-navigation-model'
import type { SettingsTerminalModel } from '../page/use-settings-terminal-model'
import { watchForSettingsDeepLinkTarget } from './settings-deep-link-target-watcher'
import { requestSettingsTargetReveal } from '../section-layout/settings-target-highlight'
import {
cancelPendingSettingsDeepLinkTargetWatch,
cancelPendingSettingsSubsectionScrollFrame,
@@ -195,13 +196,19 @@ export function useSettingsRepoScrollEffects(
root: container,
isTargetPresent: () =>
getSettingsScrollTarget(scrollTargetId, contentScrollRef.current) !== null,
onTargetPresent: () => setPendingNavRequestTick((tick) => tick + 1)
onTargetPresent: () => setPendingNavRequestTick((tick) => tick + 1),
onTimeout: () => {
pendingNavSectionRef.current = null
pendingScrollTargetRef.current = null
}
})
return
}
if (pendingNavSectionId === 'general') {
setHighlightedSettingsTarget({ id: scrollTargetId })
const revealTarget = getSettingsScrollTarget(scrollTargetId, container)
if (revealTarget) {
requestSettingsTargetReveal(revealTarget)
}
setHighlightedSettingsTarget({ id: scrollTargetId })
const scrollToSubsection = (): void => {
scrollSubsectionIntoView(scrollTargetId, contentScrollRef.current)
}
@@ -1,4 +1,3 @@
import { SettingsTargetHighlightProvider } from '../section-layout/settings-target-highlight'
import { SettingsHeader } from '../navigation/SettingsHeader'
import { getGeneralSubPageTitle } from '../general/general-sub-pages'
import { ActiveSettingsSectionProvider } from '../section-layout/SettingsSection'
@@ -96,12 +95,13 @@ export function renderSettingsPage(
activeSectionId={model.activeSectionId}
generalGroups={view.generalNavGroups}
repoSections={view.repoNavSections}
hasRepos={model.repos.length > 0}
searchCandidates={navigation.searchCandidates}
searchInputRef={interactions.searchInputRef}
// Deep-linked panes/modals own focus; plain entry focuses search.
searchAutoFocus={model.settingsNavigationTarget == null}
onBack={interactions.closeSettingsPageWithPromptGuard}
onSelectSection={actions.scrollToSection}
onOpenSearchResult={(result) => void actions.openSettingsSearchResult(result)}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
@@ -161,46 +161,42 @@ export function renderSettingsPage(
) : (
<SettingsContentScrollElementProvider value={interactions.contentScrollElement}>
<ActiveSettingsSectionProvider value={model.activeSectionId}>
<SettingsTargetHighlightProvider
value={model.highlightedSettingsTarget?.id ?? null}
>
{renderAgentsSettingsSection(context, t)}
{renderAccountsSettingsSection(context, t)}
{renderOrchestrationSettingsSection(context, t)}
{renderLinearSettingsSection(context, t)}
{renderDesktopCapabilitySettingsSections(context, t)}
{renderOrcaAccountSettingsSection(context, t)}
{renderSetupGuideSettingsSection(context, t)}
{renderGeneralSettingsSection(context, t)}
{renderIntegrationsSettingsSection(context, t)}
{renderMobileSettingsSection(context, t)}
{renderAutomationsSettingsSection(context, t)}
{renderArtifactsSettingsSection(context, t)}
{renderShareSkillsSettingsSection(context, t)}
{renderSessionHistorySettingsSection(context, t)}
{renderGitSettingsSection(context, t)}
{renderTasksSettingsSection(context, t)}
{renderTerminalSettingsSection(context, t)}
{renderQuickCommandsSettingsSection(context, t)}
{renderBrowserSettingsSection(context, t)}
{renderMobileEmulatorSettingsSection(context, t)}
{renderFloatingWorkspaceSettingsSection(context, t)}
{renderAppearanceSettingsSection(context, t)}
{renderChatSettingsSection(context)}
{renderInputSettingsSection(context, t)}
{renderNotificationsSettingsSection(context, t)}
{renderShortcutsSettingsSection(context, t)}
{renderStatsSettingsSection(context, t)}
{renderServersSettingsSection(context, t)}
{renderSshSettingsSection(context, t)}
{renderDeveloperPermissionsSettingsSection(context, t)}
{renderPrivacySettingsSection(context, t)}
{renderAdvancedSettingsSection(context, t)}
{renderDevSettingsSection(context, t)}
{renderExperimentalSettingsSection(context, t)}
{renderPluginsSettingsSection(context)}
{renderProjectSettingsSections(context, t)}
</SettingsTargetHighlightProvider>
{renderAgentsSettingsSection(context, t)}
{renderAccountsSettingsSection(context, t)}
{renderOrchestrationSettingsSection(context, t)}
{renderLinearSettingsSection(context, t)}
{renderDesktopCapabilitySettingsSections(context, t)}
{renderOrcaAccountSettingsSection(context, t)}
{renderSetupGuideSettingsSection(context, t)}
{renderGeneralSettingsSection(context, t)}
{renderIntegrationsSettingsSection(context, t)}
{renderMobileSettingsSection(context, t)}
{renderAutomationsSettingsSection(context, t)}
{renderArtifactsSettingsSection(context, t)}
{renderShareSkillsSettingsSection(context, t)}
{renderSessionHistorySettingsSection(context, t)}
{renderGitSettingsSection(context, t)}
{renderTasksSettingsSection(context, t)}
{renderTerminalSettingsSection(context, t)}
{renderQuickCommandsSettingsSection(context, t)}
{renderBrowserSettingsSection(context, t)}
{renderMobileEmulatorSettingsSection(context, t)}
{renderFloatingWorkspaceSettingsSection(context, t)}
{renderAppearanceSettingsSection(context, t)}
{renderChatSettingsSection(context)}
{renderInputSettingsSection(context, t)}
{renderNotificationsSettingsSection(context, t)}
{renderShortcutsSettingsSection(context, t)}
{renderStatsSettingsSection(context, t)}
{renderServersSettingsSection(context, t)}
{renderSshSettingsSection(context, t)}
{renderDeveloperPermissionsSettingsSection(context, t)}
{renderPrivacySettingsSection(context, t)}
{renderAdvancedSettingsSection(context, t)}
{renderDevSettingsSection(context, t)}
{renderExperimentalSettingsSection(context, t)}
{renderPluginsSettingsSection(context)}
{renderProjectSettingsSections(context, t)}
</ActiveSettingsSectionProvider>
</SettingsContentScrollElementProvider>
)}
@@ -92,11 +92,7 @@ export function renderDeveloperPermissionsSettingsSection(
)}
searchEntries={navigation.getSectionSearchEntries('developer-permissions')}
>
{view.isSectionMounted('developer-permissions') ? (
<DeveloperPermissionsPane
highlightedSettingId={model.highlightedSettingsTarget?.id ?? null}
/>
) : null}
{view.isSectionMounted('developer-permissions') ? <DeveloperPermissionsPane /> : null}
</SettingsSection>
) : null
}
@@ -5,7 +5,11 @@ import type { Translate } from '@/i18n/use-translate'
import type { SettingsStoreModel } from './use-settings-store-model'
import type { SettingsInteractionController } from './use-settings-interaction-controller'
import type { SettingsNavigationModel } from '../navigation/use-settings-navigation-model'
import { getSettingsNavGroupDefinitionsForSearch } from '../navigation/settings-navigation-foundations'
import {
cancelPendingSettingsDeepLinkTargetWatch,
getSettingsNavGroupDefinitionsForSearch
} from '../navigation/settings-navigation-foundations'
import type { SettingsSearchResult } from '../navigation/settings-search-results'
export function useSettingsNavigationActions(
model: Pick<
@@ -36,7 +40,8 @@ export function useSettingsNavigationActions(
confirmDiscardSourceControlAiPromptChanges,
contentScrollRef,
pendingNavSectionRef,
pendingScrollTargetRef
pendingScrollTargetRef,
pendingScrollTargetWatchRef
} = interactions
const scrollToSection = useCallback(
async (sectionId: string): Promise<void> => {
@@ -51,6 +56,10 @@ export function useSettingsNavigationActions(
// Clear stale filtering when selecting a result’s pane.
setSettingsSearchQuery('')
}
// A plain pane pick supersedes any search or deep-link jump still waiting for its row.
pendingNavSectionRef.current = null
pendingScrollTargetRef.current = null
cancelPendingSettingsDeepLinkTargetWatch(pendingScrollTargetWatchRef)
setHighlightedSettingsTarget(null)
setGeneralSubPage(null)
releaseNativeAccounts()
@@ -61,6 +70,9 @@ export function useSettingsNavigationActions(
releaseNativeAccounts,
confirmDiscardSourceControlAiPromptChanges,
contentScrollRef,
pendingNavSectionRef,
pendingScrollTargetRef,
pendingScrollTargetWatchRef,
setActiveSectionId,
setGeneralSubPage,
setHighlightedSettingsTarget,
@@ -69,6 +81,32 @@ export function useSettingsNavigationActions(
]
)
const openSettingsSearchResult = useCallback(
async (result: SettingsSearchResult): Promise<void> => {
const sectionId = result.section.id
if (sectionId !== activeSectionId) {
if (!(await confirmDiscardSourceControlAiPromptChanges())) {
return
}
releaseNativeAccounts()
}
setHighlightedSettingsTarget(null)
pendingNavSectionRef.current = sectionId
pendingScrollTargetRef.current = result.targetId
// Pending refs cannot schedule the jump effect; trigger a render.
setPendingNavRequestTick((tick) => tick + 1)
},
[
activeSectionId,
confirmDiscardSourceControlAiPromptChanges,
pendingNavSectionRef,
pendingScrollTargetRef,
releaseNativeAccounts,
setHighlightedSettingsTarget,
setPendingNavRequestTick
]
)
const openComputerUseFromBrowser = useCallback(async () => {
if (!(await confirmDiscardSourceControlAiPromptChanges())) {
return
@@ -90,7 +128,7 @@ export function useSettingsNavigationActions(
settingsSearchQuery
])
return { scrollToSection, openComputerUseFromBrowser }
return { scrollToSection, openSettingsSearchResult, openComputerUseFromBrowser }
}
export type SettingsNavigationActions = ReturnType<typeof useSettingsNavigationActions>
@@ -9,10 +9,18 @@ import {
} from '../appearance/fonts/installed-font-list'
import type { SettingsStoreModel } from './use-settings-store-model'
import type { SettingsDeepLinkTargetWatch } from '../navigation/settings-deep-link-target-watcher'
import {
applySettingsTargetHighlightBleed,
clearSettingsTargetHighlightBleed,
resolveSettingsHighlightElement,
SETTINGS_TARGET_HIGHLIGHT_CLASS_NAME
} from '../section-layout/settings-target-highlight'
import {
cancelPendingSettingsDeepLinkTargetWatch,
cancelPendingSettingsSubsectionScrollFrame,
getSettingsScrollTarget,
readSourceControlAiSettings,
scrollSubsectionIntoView,
SETTINGS_TARGET_HIGHLIGHT_MS
} from '../navigation/settings-navigation-foundations'
@@ -35,6 +43,14 @@ export type SettingsInteractionModel = Pick<
| 'updateSettingsOrThrow'
>
const SETTINGS_REVEAL_SETTLE_MS = 250
function isFullyVisibleIn(target: HTMLElement, container: HTMLElement): boolean {
const targetRect = target.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
return targetRect.top >= containerRect.top && targetRect.bottom <= containerRect.bottom
}
export function useSettingsInteractionController(model: SettingsInteractionModel) {
const {
closeSettingsPage,
@@ -144,6 +160,34 @@ export function useSettingsInteractionController(model: SettingsInteractionModel
return () => window.clearTimeout(timeout)
}, [highlightedSettingsTarget, setHighlightedSettingsTarget])
useEffect(() => {
const targetId = highlightedSettingsTarget?.id
if (!targetId) {
return
}
const target = getSettingsScrollTarget(targetId, contentScrollRef.current)
if (!target) {
return
}
// The one owner of the highlight, on the element the jump scrolled to (or its card).
const highlighted = resolveSettingsHighlightElement(target)
highlighted.classList.add(SETTINGS_TARGET_HIGHLIGHT_CLASS_NAME)
applySettingsTargetHighlightBleed(highlighted)
// A section the jump just revealed grows over ~200ms; re-scroll once if the row is still cut off.
const settle = window.setTimeout(() => {
const container = contentScrollRef.current
if (container && !isFullyVisibleIn(target, container)) {
scrollSubsectionIntoView(targetId, container)
}
applySettingsTargetHighlightBleed(highlighted)
}, SETTINGS_REVEAL_SETTLE_MS)
return () => {
window.clearTimeout(settle)
clearSettingsTargetHighlightBleed(highlighted)
highlighted.classList.remove(SETTINGS_TARGET_HIGHLIGHT_CLASS_NAME)
}
}, [highlightedSettingsTarget])
const requestFontSuggestions = useCallback((): void => {
if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) {
return
@@ -13,6 +13,7 @@ import {
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { diagnosticsErrorText } from './diagnostics-bundle-error-copy'
import { PRIVACY_SETTING_ANCHORS } from './privacy-search'
export function PrivacyDiagnosticsSection(): React.JSX.Element {
const t = useTranslate()
@@ -352,7 +353,11 @@ function PrivacyDiagnosticsRow({
children: React.ReactNode
}): React.JSX.Element {
return (
<div data-settings-row="" className="flex items-center justify-between gap-4 py-2">
<div
id={PRIVACY_SETTING_ANCHORS.diagnostics}
data-settings-row=""
className="flex items-center justify-between gap-4 py-2"
>
<div className="flex min-w-0 flex-1 items-start gap-2.5">
<div className="mt-0.5 text-muted-foreground">{icon}</div>
<div className="min-w-0 space-y-0.5">
@@ -12,6 +12,7 @@ import {
} from '../../../diagnostics/telemetry/telemetry'
import { useAppStore } from '../../../store/index'
import { PrivacyDiagnosticsSection } from './PrivacyDiagnosticsSection'
import { PRIVACY_SETTING_ANCHORS } from './privacy-search'
import { useTranslate } from '@/i18n/use-translate'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
import { SettingsGroup } from '../section-layout/SettingsGroup'
@@ -92,8 +93,12 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element {
}
return (
<SettingsGroup>
<div data-settings-row="" className="flex items-center justify-between gap-4 py-2">
<SettingsGroup id={PRIVACY_SETTING_ANCHORS.overview}>
<div
id={PRIVACY_SETTING_ANCHORS.usageData}
data-settings-row=""
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4" />
@@ -2,8 +2,15 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const PRIVACY_SETTING_ANCHORS = {
overview: 'privacy-overview',
usageData: 'privacy-usage-data',
diagnostics: 'privacy-diagnostics'
} as const
export const getPrivacyPaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: PRIVACY_SETTING_ANCHORS.overview,
title: translate('auto.components.settings.privacy.search.a966af0840', 'Privacy & telemetry'),
description: translate(
'auto.components.settings.privacy.search.aa3b794c17',
@@ -40,6 +47,7 @@ export const getPrivacyPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: PRIVACY_SETTING_ANCHORS.diagnostics,
title: translate('auto.components.settings.privacy.search.6d258d2ed6', 'Diagnostics'),
description: translate(
'auto.components.settings.privacy.search.8b08f32366',
@@ -54,6 +62,8 @@ export const getPrivacyPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
// The variables disable the usage-data switch; their notice renders under it only while one is set.
targetSectionId: PRIVACY_SETTING_ANCHORS.usageData,
title: translate(
'auto.components.settings.privacy.search.e058a3c98d',
'Telemetry environment variables'
@@ -15,6 +15,7 @@ import { getSettingOwnershipSummary } from '../page/setting-ownership'
import { useTranslate } from '@/i18n/use-translate'
import { QuickCommandsList } from './QuickCommandsList'
import { QuickCommandsToolbar } from './QuickCommandsToolbar'
import { QUICK_COMMANDS_SETTINGS_TARGET_ID } from './quick-commands-search'
import { GLOBAL_SCOPE_KEY } from './QuickCommandsScopeFilter'
import {
getRepoExecutionHostId,
@@ -276,7 +277,7 @@ export function QuickCommandsPane({
})
return (
<div className="space-y-4">
<div id={QUICK_COMMANDS_SETTINGS_TARGET_ID} className="space-y-4">
{shouldShowTerminalQuickCommandHostOwnership(hostOptions) ? (
<p className="text-xs text-muted-foreground">{ownership.description}</p>
) : null}
@@ -2,8 +2,11 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const QUICK_COMMANDS_SETTINGS_TARGET_ID = 'quick-commands-list'
export const getQuickCommandsPaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: QUICK_COMMANDS_SETTINGS_TARGET_ID,
title: translate('auto.components.settings.quick.commands.search.915776ccfa', 'Quick commands'),
description: translate(
'auto.components.settings.quick.commands.search.d691c4e8d8',
@@ -8,6 +8,9 @@ export function getRepositoryIconSectionId(repoId: string): string {
return `repo-${repoId}-icon`
}
// Search entries describe the representative repo; the pane may show another host's repo, so this id is not repo-scoped.
export const REPOSITORY_SOURCE_CONTROL_AI_CARD_ID = 'repo-source-control-ai-card'
export function getRepositorySourceControlAiSectionId(repoId: string): string {
return `repo-${repoId}-source-control-ai`
}
@@ -6,7 +6,10 @@ import { normalizeSourceControlAiSettings } from '../../../../../../shared/sourc
import type { SourceControlAiRepoUpdate } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save'
import type { SourceControlAiSettingsPatch } from '../../../../../../shared/source-control/ai/settings/source-control-ai-settings-types'
import { useAppStore } from '../../../../store/index'
import { getRepositorySourceControlAiSectionId } from '../repository-settings-targets'
import {
getRepositorySourceControlAiSectionId,
REPOSITORY_SOURCE_CONTROL_AI_CARD_ID
} from '../repository-settings-targets'
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
import { RepositorySourceControlAiCustomCommand } from './RepositorySourceControlAiCustomCommand'
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
@@ -83,6 +86,7 @@ export function RepositorySourceControlAiSection({
data-settings-section={getRepositorySourceControlAiSectionId(repo.id)}
>
<SettingsGroup
id={REPOSITORY_SOURCE_CONTROL_AI_CARD_ID}
title={t(
'auto.components.settings.RepositorySourceControlAiSection.112f3b934f',
'Source control AI'
@@ -3,6 +3,7 @@ import type { SettingsSearchEntry } from '../../search/settings-search'
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import { searchKeywordTranslator } from '../../search/settings-search-keywords'
import { REPOSITORY_SOURCE_CONTROL_AI_CARD_ID } from '../repository-settings-targets'
export function getRepositoryGitAuthorSearchEntries(
repo: Repo,
@@ -11,6 +12,7 @@ export function getRepositoryGitAuthorSearchEntries(
const translateSearchKeyword = searchKeywordTranslator(t)
return [
{
targetSectionId: REPOSITORY_SOURCE_CONTROL_AI_CARD_ID,
title: t('auto.components.settings.repository.search.6259586017', 'Git AI author'),
description: t(
'auto.components.settings.repository.search.6cc5c65e64',
@@ -61,7 +61,7 @@ export function isSettingsSearchQueryTooLarge(
}
// Turkish İ lowercases to i + U+0307; drop the dot so plain-i queries still match.
function foldSettingsSearchText(value: string): string {
export function foldSettingsSearchText(value: string): string {
return value.toLowerCase().replaceAll('\u0307', '')
}
@@ -101,9 +101,15 @@ function scoreSettingsSearchValues(
)
}
export type SettingsSearchScoreOptions = {
/** Rank the first entry as the pane title; off for a single setting's entry. */
firstEntryIsPane?: boolean
}
export function scoreSettingsSearch(
query: string,
entries: SettingsSearchEntry | SettingsSearchEntry[]
entries: SettingsSearchEntry | SettingsSearchEntry[],
{ firstEntryIsPane = true }: SettingsSearchScoreOptions = {}
): number {
if (isSettingsSearchQueryTooLarge(query)) {
return SETTINGS_SEARCH_NO_MATCH_SCORE
@@ -116,7 +122,7 @@ export function scoreSettingsSearch(
const values = Array.isArray(entries) ? entries : [entries]
return values.reduce((score, entry, index) => {
// The first entry is the pane title; rank it above setting titles.
const titleScore = index === 0 ? PANE_TITLE_SCORE : ENTRY_TITLE_SCORE
const titleScore = index === 0 && firstEntryIsPane ? PANE_TITLE_SCORE : ENTRY_TITLE_SCORE
return Math.max(
score,
scoreSettingsSearchText(normalizedQuery, entry.title, titleScore),
@@ -138,7 +144,8 @@ export function getSettingsSectionSearchEntries(section: {
export function rankSettingsSearchItems<T>(
query: string,
items: readonly T[],
getEntries: (item: T) => SettingsSearchEntry | SettingsSearchEntry[]
getEntries: (item: T) => SettingsSearchEntry | SettingsSearchEntry[],
getScoreOptions?: (item: T) => SettingsSearchScoreOptions
): RankedSettingsSearchItem<T>[] {
if (isSettingsSearchQueryTooLarge(query)) {
return []
@@ -151,7 +158,7 @@ export function rankSettingsSearchItems<T>(
.map((item, index): SettingsSearchRankCandidate<T> => ({
item,
index,
score: scoreSettingsSearch(query, getEntries(item))
score: scoreSettingsSearch(query, getEntries(item), getScoreOptions?.(item))
}))
.filter((candidate) => candidate.score > SETTINGS_SEARCH_NO_MATCH_SCORE)
.sort((a, b) => b.score - a.score || a.index - b.index)
@@ -1,5 +1,3 @@
import { useContext } from 'react'
import { SettingsTargetHighlightContext } from './settings-target-highlight'
import { useAppStateValue } from '@/store/retained-views/app-state-view'
import type React from 'react'
import { cn } from '@/lib/class-names'
@@ -21,7 +19,6 @@ export function SearchableSetting({
className,
id
}: SearchableSettingProps): React.JSX.Element | null {
const highlighted = useContext(SettingsTargetHighlightContext) === id
const query = useAppStateValue((state) => state.settingsSearchQuery)
if (!forceVisible && !matchesSettingsSearch(query, { title, description, keywords })) {
return null
@@ -30,11 +27,8 @@ export function SearchableSetting({
return (
<div
data-settings-row=""
className={cn(
'scroll-mt-6 w-full',
highlighted && 'rounded-md bg-accent ring-2 ring-ring/50',
className
)}
data-settings-search-title={title}
className={cn('scroll-mt-6 w-full', className)}
id={id}
>
{children}
@@ -11,13 +11,14 @@ export const SETTINGS_GROUP_ROW_BLEED_CLASS = '-mx-5 px-5'
export const SETTINGS_GROUP_NESTED_INSET_CLASS = 'pl-5'
type SettingsGroupProps = {
id?: string
title?: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
children?: React.ReactNode
className?: string
bodyClassName?: string
/** Anchors the card, the same element a search for the group title lands on. */
id?: string
onHeaderClick?: (event: React.MouseEvent<HTMLDivElement>) => void
}
@@ -34,6 +35,8 @@ export function SettingsGroup({
}: SettingsGroupProps): React.JSX.Element {
const card = (
<div
id={id}
data-settings-group-card=""
className={cn(
// Search can filter out every row; hide the empty card but keep the header and its action.
'overflow-hidden rounded-xl border border-border bg-card/50 shadow-xs has-[>[data-settings-group-body]:empty]:hidden',
@@ -51,7 +54,7 @@ export function SettingsGroup({
</div>
)
return (
<section id={id} className={cn('space-y-3', className)}>
<section data-settings-group="" className={cn('space-y-3', className)}>
{title ? (
<SettingsGroupHeader
title={title}
@@ -44,13 +44,12 @@ export const expanded = defineFixture({
}
})
// An active settings search force-opens the disclosure and disables its toggle.
export const forcedOpenBySearch = defineFixture({
// Unsaved edits force the disclosure open and disable its toggle.
export const forcedOpen = defineFixture({
width: 420,
store: { settingsSearchQuery: 'font' },
render: () => (
<SettingsGroup>
<SettingsGroupDisclosure>
<SettingsGroupDisclosure forceOpen>
<AdvancedFontControls />
</SettingsGroupDisclosure>
</SettingsGroup>
@@ -2,8 +2,7 @@ import type React from 'react'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/class-names'
import { useAppStore } from '../../../store/index'
import { normalizeSettingsSearchQuery } from '../search/settings-search'
import { useSettingsTargetReveal } from '../section-layout/settings-target-highlight'
import { useTranslate } from '@/i18n/use-translate'
import { SETTINGS_GROUP_NESTED_INSET_CLASS } from './SettingsGroup'
@@ -20,20 +19,18 @@ export function SettingsGroupDisclosure({
children
}: SettingsGroupDisclosureProps): React.JSX.Element {
const t = useTranslate()
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const [open, setOpen] = useState(false)
const forced = isSearching || forceOpen
const expanded = open || forced
const expanded = open || forceOpen
const revealRef = useSettingsTargetReveal<HTMLDivElement>(() => setOpen(true))
return (
<div data-settings-group-disclosure="" className="border-t border-border">
<div ref={revealRef} data-settings-group-disclosure="" className="border-t border-border">
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen((prev) => !prev)}
// Search matches and unsaved edits must remain reachable.
disabled={forced}
// Unsaved edits must remain reachable.
disabled={forceOpen}
className="flex min-h-11 w-full items-center gap-2 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 disabled:cursor-default"
>
<ChevronRight
@@ -44,7 +41,10 @@ export function SettingsGroupDisclosure({
/>
{label ?? t('auto.components.settings.AppearanceAdvancedDisclosure.advanced', 'Advanced')}
</button>
{expanded ? <div className={SETTINGS_GROUP_NESTED_INSET_CLASS}>{children}</div> : null}
{/* Stays mounted while collapsed so a settings jump can find and reveal its rows. */}
<div hidden={!expanded} className={SETTINGS_GROUP_NESTED_INSET_CLASS}>
{children}
</div>
</div>
)
}
@@ -1,4 +1,89 @@
import { createContext } from 'react'
import { useCallback, useLayoutEffect, useRef, type RefCallback } from 'react'
export const SettingsTargetHighlightContext = createContext<string | null>(null)
export const SettingsTargetHighlightProvider = SettingsTargetHighlightContext.Provider
export const SETTINGS_TARGET_HIGHLIGHT_CLASS_NAME = 'settings-target-highlight'
const SETTINGS_TITLE_TARGET_PREFIX = 'settings-title:'
// Search entries without an anchor id target their rendered row by title.
export function getSettingsTitleTargetId(title: string): string {
return `${SETTINGS_TITLE_TARGET_PREFIX}${title}`
}
export function parseSettingsTitleTargetId(targetId: string): string | null {
return targetId.startsWith(SETTINGS_TITLE_TARGET_PREFIX)
? targetId.slice(SETTINGS_TITLE_TARGET_PREFIX.length)
: null
}
const SETTINGS_GROUP_CARD_SELECTOR = '[data-settings-group-card]'
// Rows sit at different insets (nested rows, sub-fields), so measure the gap from the row to
// its card's inner edges instead of assuming one gutter.
function findEnclosingSettingsCard(element: HTMLElement): HTMLElement | null {
const groupCard = element.parentElement?.closest<HTMLElement>(SETTINGS_GROUP_CARD_SELECTOR)
if (groupCard) {
return groupCard
}
for (let node = element.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node)
if (
Number.parseFloat(style.borderLeftWidth) > 0 &&
Number.parseFloat(style.borderTopLeftRadius) >= 8
) {
return node
}
}
return null
}
// An anchor on a whole section (title plus card) tints only its card, so the tint keeps the card's corners.
export function resolveSettingsHighlightElement(target: HTMLElement): HTMLElement {
if (target.matches(SETTINGS_GROUP_CARD_SELECTOR) || findEnclosingSettingsCard(target)) {
return target
}
return target.querySelector<HTMLElement>(SETTINGS_GROUP_CARD_SELECTOR) ?? target
}
export function applySettingsTargetHighlightBleed(target: HTMLElement): void {
const card = findEnclosingSettingsCard(target)
if (!card) {
return
}
const cardRect = card.getBoundingClientRect()
const cardStyle = getComputedStyle(card)
const targetRect = target.getBoundingClientRect()
const left = targetRect.left - cardRect.left - Number.parseFloat(cardStyle.borderLeftWidth)
const right = cardRect.right - Number.parseFloat(cardStyle.borderRightWidth) - targetRect.right
target.style.setProperty('--settings-target-bleed-left', `${Math.max(0, left)}px`)
target.style.setProperty('--settings-target-bleed-right', `${Math.max(0, right)}px`)
}
export function clearSettingsTargetHighlightBleed(target: HTMLElement): void {
target.style.removeProperty('--settings-target-bleed-left')
target.style.removeProperty('--settings-target-bleed-right')
}
const SETTINGS_TARGET_REVEAL_EVENT = 'orca-settings-target-reveal'
// Collapsed sections keep their rows mounted but hidden; the jump asks each one around the target to open.
export function requestSettingsTargetReveal(target: HTMLElement): void {
target.dispatchEvent(new Event(SETTINGS_TARGET_REVEAL_EVENT, { bubbles: true }))
}
// A ref callback, so a disclosure that mounts later (or remounts) still gets the listener.
export function useSettingsTargetReveal<T extends HTMLElement>(
onReveal: () => void
): RefCallback<T> {
const onRevealRef = useRef(onReveal)
useLayoutEffect(() => {
onRevealRef.current = onReveal
})
return useCallback((node: T | null) => {
if (!node) {
return
}
const listener = (): void => onRevealRef.current()
node.addEventListener(SETTINGS_TARGET_REVEAL_EVENT, listener)
return () => node.removeEventListener(SETTINGS_TARGET_REVEAL_EVENT, listener)
}, [])
}
@@ -19,7 +19,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../ui/co
import { Label } from '../../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover'
import { Switch } from '../../ui/switch'
import { getAutoRenameBranchAdvancedSearchEntries } from './auto-rename-branch-search'
import {
AUTO_RENAME_BRANCH_TEMPLATE_SETTING_ID,
getAutoRenameBranchAdvancedSearchEntries
} from './auto-rename-branch-search'
import { useSettingsTargetReveal } from '../section-layout/settings-target-highlight'
import { SearchableSetting } from '../section-layout/SearchableSetting'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from '../search/settings-search'
import { Trans } from 'react-i18next'
@@ -65,6 +69,7 @@ export function AutoRenameBranchFromWorkSetting({
const searchQuery = settingsSearchQuery ?? storeSearchQuery
const config = readSourceControlSettings(settings)
const [optionsOpen, setOptionsOpen] = useState(false)
const advancedRevealRef = useSettingsTargetReveal<HTMLDivElement>(() => setOptionsOpen(true))
const advancedSearchOpen = shouldOpenAutoRenameBranchAdvanced(searchQuery)
const advancedOpen = optionsOpen || advancedSearchOpen
const persistedBranchNameTemplate = resolveSourceControlActionCommandTemplate(
@@ -179,7 +184,7 @@ export function AutoRenameBranchFromWorkSetting({
/>
</div>
<Collapsible open={advancedOpen} onOpenChange={setOptionsOpen}>
<Collapsible ref={advancedRevealRef} open={advancedOpen} onOpenChange={setOptionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
@@ -193,9 +198,10 @@ export function AutoRenameBranchFromWorkSetting({
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{/* Mounted while closed so a settings jump can find and reveal the template. */}
<CollapsibleContent forceMount className="data-[state=closed]:hidden">
<div className="mt-2 space-y-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="space-y-2">
<div id={AUTO_RENAME_BRANCH_TEMPLATE_SETTING_ID} className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="git-auto-rename-branch-name-template">
{t(
@@ -1,6 +1,7 @@
import type { SourceControlAiSettings } from '../../../../../shared/source-control/ai/settings/source-control-ai-settings-types'
import { Label } from '../../ui/label'
import { SearchableSetting } from '../section-layout/SearchableSetting'
import { PR_CREATION_DEFAULTS_SETTING_ID } from './commit-message-ai-search'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
@@ -78,6 +79,7 @@ export function HostedReviewCreationDefaults({
return (
<SearchableSetting
key="pr-creation-defaults"
id={PR_CREATION_DEFAULTS_SETTING_ID}
title={t(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
@@ -62,8 +62,11 @@ export const getAutoRenameBranchParentSearchEntry = createLocalizedCatalog(
})
)
export const AUTO_RENAME_BRANCH_TEMPLATE_SETTING_ID = 'git-auto-rename-branch-template'
export const getAutoRenameBranchAdvancedSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: AUTO_RENAME_BRANCH_TEMPLATE_SETTING_ID,
title: translate(
'auto.components.settings.auto.rename.branch.search.722551c5b3',
'Branch name command template'
@@ -2,6 +2,8 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const PR_CREATION_DEFAULTS_SETTING_ID = 'source-control-pr-creation-defaults'
export const getCommitMessageAiPaneSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
@@ -124,6 +126,7 @@ export const getCommitMessageAiPaneSearchEntries = createLocalizedCatalog(() =>
]
},
{
targetSectionId: PR_CREATION_DEFAULTS_SETTING_ID,
title: translate(
'auto.components.settings.commit.message.ai.search.eefd33788c',
'PR creation defaults'
@@ -9,6 +9,7 @@ import { SshTargetsEmptyState } from './targets/SshTargetsEmptyState'
import { SshTargetDestructiveActions } from './targets/SshTargetDestructiveActions'
import { SshHostsHeaderActions, SshHostsListToolbar } from './SshPaneToolbar'
import { SshHostsHeaderPortal } from './ssh-hosts-header-slot'
import { SSH_SETTING_ANCHORS } from './ssh-search'
import { useSshTargetTests } from './targets/use-ssh-target-tests'
import { SshRestoreRemovedHostsDialog } from './targets/SshRestoreRemovedHostsDialog'
import { useSshConfigSync } from './targets/use-ssh-config-sync'
@@ -262,7 +263,7 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
}
return (
<div className="space-y-3">
<div id={SSH_SETTING_ANCHORS.hosts} className="space-y-3">
<SshHostsHeaderPortal>
<SshHostsHeaderActions
syncing={syncing}
@@ -1,5 +1,6 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { SSH_SETTING_ANCHORS } from './ssh-search'
type SshHostsHeaderSlotState = {
slot: HTMLElement | null
@@ -25,7 +26,13 @@ export function SshHostsHeaderSlotProvider({
export function SshHostsHeaderSlot(): React.JSX.Element | null {
const context = useContext(SshHostsHeaderSlotContext)
return context ? <div ref={context.setSlot} className="flex items-center gap-1.5" /> : null
return context ? (
<div
id={SSH_SETTING_ANCHORS.headerActions}
ref={context.setSlot}
className="flex items-center gap-1.5"
/>
) : null
}
/** Renders inline when no section header provides a slot (tests, other hosts of the pane). */
@@ -36,7 +43,11 @@ export function SshHostsHeaderPortal({
}): React.JSX.Element | null {
const context = useContext(SshHostsHeaderSlotContext)
if (!context) {
return <div className="flex justify-end gap-1.5">{children}</div>
return (
<div id={SSH_SETTING_ANCHORS.headerActions} className="flex justify-end gap-1.5">
{children}
</div>
)
}
return context.slot ? createPortal(children, context.slot) : null
}
@@ -2,8 +2,14 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const SSH_SETTING_ANCHORS = {
hosts: 'ssh-hosts',
headerActions: 'ssh-host-actions'
} as const
export const getSshPaneSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: SSH_SETTING_ANCHORS.hosts,
title: translate('auto.components.settings.ssh.search.53ae83cb55', 'SSH connections'),
description: translate('auto.components.settings.ssh.search.74c6d90d78', 'Manage SSH hosts.'),
keywords: [
@@ -15,6 +21,7 @@ export const getSshPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: SSH_SETTING_ANCHORS.headerActions,
title: translate('auto.components.settings.ssh.search.004ef87a84', 'Add SSH target'),
description: translate('auto.components.settings.ssh.search.62826efbe9', 'Add a new SSH host.'),
keywords: [
@@ -27,6 +34,7 @@ export const getSshPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: SSH_SETTING_ANCHORS.headerActions,
title: translate('auto.components.settings.ssh.search.893c8717f2', 'Import from SSH config'),
description: translate(
'auto.components.settings.ssh.search.7f251a45a8',
@@ -40,6 +48,7 @@ export const getSshPaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: SSH_SETTING_ANCHORS.hosts,
title: translate('auto.components.settings.ssh.search.50416e3809', 'Test connection'),
description: translate(
'auto.components.settings.ssh.search.96ca5d9a0b',
@@ -2,6 +2,7 @@ import type { GlobalSettings } from '../../../../../../shared/global-settings-ty
import { useDetectedOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
import { useTranslate } from '@/i18n/use-translate'
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { TERMINAL_MAC_YEN_SETTING_ID } from './terminal-advanced-platform-search'
import {
SettingsRow,
SettingsSegmentedControl,
@@ -123,6 +124,7 @@ export function TerminalMacKeyboardSection({
</SearchableSetting>
<SearchableSetting
id={TERMINAL_MAC_YEN_SETTING_ID}
title={t(
'auto.components.settings.TerminalPane.19f4935159',
'JIS Yen (¥) to Backslash (\\\\)'
@@ -93,8 +93,11 @@ export const getTerminalMacOptionSearchEntries = createLocalizedCatalog(() => [
}
])
export const TERMINAL_MAC_YEN_SETTING_ID = 'terminal-mac-jis-yen-to-backslash'
export const getTerminalMacYenSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: TERMINAL_MAC_YEN_SETTING_ID,
title: translate(
'auto.components.settings.terminal.search.694b8764ac',
'JIS Yen (¥) to Backslash (\\)'
@@ -24,6 +24,7 @@ import {
import { ThemePicker } from './TerminalThemePicker'
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { SettingsGroupSubsection } from '../../section-layout/SettingsGroup'
import { TERMINAL_THEME_MODE_SETTING_ID } from './terminal-theme-search'
import { TerminalSettingsPreview } from '../appearance/TerminalSettingsPreview'
import { WarpThemeImportButton } from './WarpThemeImportButton'
import { YamlThemeImportButton } from './YamlThemeImportButton'
@@ -189,6 +190,7 @@ export function TerminalThemeCatalogSection({
}
>
<SearchableSetting
id={TERMINAL_THEME_MODE_SETTING_ID}
title={t('auto.components.settings.TerminalThemeSections.2318e07c68', 'Theme mode')}
keywords={['terminal', 'theme', 'dark', 'light']}
forceVisible
@@ -2,6 +2,9 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
// Light-mode rows render only after switching Theme mode to Light, so their results land on that switch.
export const TERMINAL_THEME_MODE_SETTING_ID = 'terminal-theme-mode'
export const getTerminalDarkThemeSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.terminal.search.ee9665265e', 'Dark theme'),
@@ -56,6 +59,7 @@ export const getTerminalThemeTargetSearchEntries = createLocalizedCatalog(() =>
export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [
{
targetSectionId: TERMINAL_THEME_MODE_SETTING_ID,
title: translate(
'auto.components.settings.terminal.search.match_dark_mode_title',
'Match dark mode'
@@ -101,6 +105,7 @@ export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: TERMINAL_THEME_MODE_SETTING_ID,
title: translate('auto.components.settings.terminal.search.18a9f63aff', 'Light divider color'),
description: translate(
'auto.components.settings.terminal.search.77d9f9cd55',
@@ -9,6 +9,7 @@ import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translat
import { languageDisplayName, uiCollator } from '@/i18n/ui-intl-formatters'
import { SettingsRow } from '../form-controls/SettingsFormControls'
import { SettingsSearchableSelect } from '../form-controls/SettingsSearchableSelect'
import { VOICE_SETTING_ANCHORS } from './voice-pane-search'
// Intl formatter caches key on option identity, so this must be a module constant.
const NAME_COLLATOR: Intl.CollatorOptions = { sensitivity: 'base' }
@@ -81,6 +82,7 @@ export function VoiceDictationLanguageSetting({
return (
<SettingsRow
id={VOICE_SETTING_ANCHORS.language}
className={className}
label={label}
description={describeLanguageSetting(
@@ -18,6 +18,7 @@ import { Switch } from '../../ui/switch'
import { SettingsRow, SettingsSegmentedControl } from '../form-controls/SettingsFormControls'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { isSpeechModelDownloading } from './voice-speech-model-rows'
import { VOICE_SETTING_ANCHORS } from './voice-pane-search'
type VoiceDictationSettingsProps = {
voiceSettings: VoiceSettings
@@ -129,6 +130,7 @@ export function VoiceDictationSettings(props: VoiceDictationSettingsProps): Reac
}
/>
<SettingsRow
id={VOICE_SETTING_ANCHORS.mode}
label={modeLabel}
description={modeDescription}
control={
@@ -20,6 +20,7 @@ import {
VoiceSpeechModelRow
} from './VoiceSpeechModelRow'
import { VoiceDictationLanguageSetting } from './VoiceDictationLanguageSetting'
import { VOICE_SETTING_ANCHORS } from './voice-pane-search'
import { cancelSpeechModelDownload, startSpeechModelDownload } from './speech-model-download'
type VoiceSpeechModelSectionProps = {
@@ -91,6 +92,7 @@ export function VoiceSpeechModelSection({
const title = t('auto.components.settings.VoicePane.c5ea32143e', 'Speech model')
return (
<SettingsGroup
id={VOICE_SETTING_ANCHORS.speechModel}
title={title}
action={
<SettingsSegmentedControl
@@ -3,8 +3,16 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from '../search/settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const VOICE_SETTING_ANCHORS = {
mode: 'voice-dictation-mode',
language: 'voice-dictation-language',
speechModel: 'voice-speech-model'
} as const
// The OpenAI key row shows only on the Cloud tab once a key is saved, so the result lands on the model card.
export const getOpenaiTranscriptionSearchEntry = createLocalizedCatalog(
(): SettingsSearchEntry => ({
targetSectionId: VOICE_SETTING_ANCHORS.speechModel,
title: translate(
'auto.components.settings.voice.pane.search.ffaa3efe7c',
'OpenAI transcription'
@@ -68,6 +76,7 @@ export const getVoicePaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: VOICE_SETTING_ANCHORS.mode,
title: translate('auto.components.settings.voice.pane.search.d0c230c061', 'Dictation mode'),
description: translate(
'auto.components.settings.voice.pane.search.748b33e531',
@@ -114,6 +123,7 @@ export const getVoicePaneSearchEntries = createLocalizedCatalog(() => [
]
},
{
targetSectionId: VOICE_SETTING_ANCHORS.language,
title: translate('auto.components.settings.voice.pane.search.9d83747eae', 'Dictation language'),
description: translate(
'auto.components.settings.voice.pane.search.dictationLanguageDescription',
+1 -2
View File
@@ -9029,8 +9029,7 @@
"60f8a673a7": "Zur App zurückkehren",
"6503182299": "Checkliste für den Einstieg",
"82db1b7de4": "Einrichtungscheckliste, {{value0}} von {{value1}} erledigt. Einrichtungsanleitung anzeigen.",
"df38d612b7": "Noch keine Projekte hinzugefügt.",
"3e483e256b": "Keine passenden Projekteinstellungen."
"df38d612b7": "Noch keine Projekte hinzugefügt."
},
"ShortcutFilterRail": {
"28b63545bf": "Status",
+6 -1
View File
@@ -1414,6 +1414,12 @@
"label": "Settings breadcrumb",
"close": "Close settings"
},
"search": {
"clear": "Clear search",
"results": "Settings search results",
"noResults": "No settings match “{{value0}}”",
"moreResults": "{{value0}} more · keep typing to narrow"
},
"voice": {
"cleanup": {
"recipeDescription": "Clean up dictated text before it lands."
@@ -9712,7 +9718,6 @@
"6503182299": "Onboarding checklist",
"82db1b7de4": "Onboarding checklist, {{value0}} of {{value1}} done. Show setup guide.",
"df38d612b7": "No projects added yet.",
"3e483e256b": "No matching project settings.",
"progressComplete": "{{value0}}/{{value1}} complete"
},
"ShortcutFilterRail": {
+1 -2
View File
@@ -8600,8 +8600,7 @@
"60f8a673a7": "Volver a la app",
"6503182299": "Checklist de onboarding",
"82db1b7de4": "Checklist de onboarding, {{value0}} de {{value1}} completados. Mostrar guía de configuración.",
"df38d612b7": "Aún no se han agregado proyectos.",
"3e483e256b": "No hay ajustes de proyecto coincidentes."
"df38d612b7": "Aún no se han agregado proyectos."
},
"ShortcutFilterRail": {
"28b63545bf": "Estado",
+1 -2
View File
@@ -8913,8 +8913,7 @@
"60f8a673a7": "Retour à l'application",
"6503182299": "Checklist d'intégration",
"82db1b7de4": "Checklist d'intégration, {{value0}} sur {{value1}} terminés. Afficher le guide de configuration.",
"df38d612b7": "Aucun projet ajouté pour le moment.",
"3e483e256b": "Aucun paramètre de projet correspondant."
"df38d612b7": "Aucun projet ajouté pour le moment."
},
"ShortcutFilterRail": {
"28b63545bf": "Statut",
+1 -2
View File
@@ -8347,8 +8347,7 @@
"60f8a673a7": "アプリに戻る",
"6503182299": "オンボーディングチェックリスト",
"82db1b7de4": "オンボーディングチェックリスト、{{value0}}/{{value1}} 完了。セットアップガイドを表示。",
"df38d612b7": "まだ追加されたプロジェクトはありません。",
"3e483e256b": "一致するプロジェクト設定がありません。"
"df38d612b7": "まだ追加されたプロジェクトはありません。"
},
"ShortcutFilterRail": {
"28b63545bf": "状態",
+1 -2
View File
@@ -8307,8 +8307,7 @@
"60f8a673a7": "앱으로 돌아가기",
"6503182299": "온보딩 체크리스트",
"82db1b7de4": "온보딩 체크리스트, {{value0}}/{{value1}} 완료. 설정 가이드 보기.",
"df38d612b7": "아직 추가된 프로젝트가 없습니다.",
"3e483e256b": "일치하는 프로젝트 설정이 없습니다."
"df38d612b7": "아직 추가된 프로젝트가 없습니다."
},
"ShortcutFilterRail": {
"28b63545bf": "상태",
+1 -2
View File
@@ -8581,8 +8581,7 @@
"60f8a673a7": "Voltar ao app",
"6503182299": "Checklist de introdução",
"82db1b7de4": "Checklist de introdução, {{value0}} de {{value1}} concluído(s). Mostrar guia de configuração.",
"df38d612b7": "Nenhum projeto adicionado ainda.",
"3e483e256b": "Nenhuma configuração de projeto correspondente."
"df38d612b7": "Nenhum projeto adicionado ainda."
},
"computer": {
"use": {
+1 -2
View File
@@ -9374,8 +9374,7 @@
"60f8a673a7": "Вернуться в приложение",
"6503182299": "Знакомство с Orca",
"82db1b7de4": "Список начальной настройки: выполнено {{value0}} из {{value1}}. Показать руководство.",
"df38d612b7": "Проекты ещё не добавлены.",
"3e483e256b": "Подходящие настройки проектов не найдены."
"df38d612b7": "Проекты ещё не добавлены."
},
"ShortcutFilterRail": {
"28b63545bf": "Статус",
+1 -2
View File
@@ -9006,8 +9006,7 @@
"60f8a673a7": "Uygulamaya dön",
"6503182299": "Başlangıç kontrol listesi",
"82db1b7de4": "Başlangıç kontrol listesi, {{value1}} adımdan {{value0}} tanesi tamamlandı. Kurulum kılavuzunu göster.",
"df38d612b7": "Henüz proje eklenmedi.",
"3e483e256b": "Eşleşen proje ayarı yok."
"df38d612b7": "Henüz proje eklenmedi."
},
"ShortcutFilterRail": {
"28b63545bf": "Durum",
+1 -2
View File
@@ -8850,8 +8850,7 @@
"60f8a673a7": "返回應用程式",
"6503182299": "入門清單",
"82db1b7de4": "入門清單,已完成 {{value0}}/{{value1}}。顯示設定指南。",
"df38d612b7": "尚未新增專案。",
"3e483e256b": "沒有符合的專案設定。"
"df38d612b7": "尚未新增專案。"
},
"ShortcutFilterRail": {
"28b63545bf": "狀態",
-1
View File
@@ -8527,7 +8527,6 @@
"6503182299": "入门清单",
"82db1b7de4": "入门清单,已完成 {{value0}}/{{value1}},显示设置指南",
"df38d612b7": "尚未添加项目",
"3e483e256b": "没有匹配的项目设置",
"progressComplete": "已完成 {{value0}}/{{value1}}"
},
"ShortcutFilterRail": {
@@ -7,6 +7,7 @@ import type { SshManagedServerStatus } from '../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { isPairedWebClientWindow } from '@/app-shell/chrome/desktop-window-chrome'
import { useAppStore } from '@/store/index'
import { NODE_DIST_MIRROR_SETTINGS_TARGET_ID } from '@/components/settings/navigation/settings-navigation-types'
import {
isSshConnectInFlight,
subscribeSshConnectInFlight,
@@ -56,14 +57,15 @@ export function readNodeDistMirrorDialogTarget(data: Record<string, unknown>): s
return typeof data.targetId === 'string' && data.targetId ? data.targetId : null
}
// Search opens collapsed fields too when only the environment variable supplied a mirror.
// The jump reveals the collapsed proxy fields even when only the environment supplied a mirror.
function openNodeDistMirrorSettings(): void {
const { openSettingsPage, openSettingsTarget, setSettingsSearchQuery } = useAppStore.getState()
const { openSettingsPage, openSettingsTarget } = useAppStore.getState()
openSettingsPage()
openSettingsTarget({ pane: 'advanced', repoId: null })
setSettingsSearchQuery(
translate('auto.components.settings.general.search.nodeDistMirror', 'mirror')
)
openSettingsTarget({
pane: 'advanced',
repoId: null,
sectionId: NODE_DIST_MIRROR_SETTINGS_TARGET_ID
})
}
export type SaveNodeDistMirrorResult =
@@ -1,7 +1,6 @@
export const SETTINGS_SEARCH_DEBOUNCE_MS = 150
export type SettingsSearchState = {
settingsSearchInputQuery: string
/** Content filter; the sidebar search no longer drives it, so it stays empty in the app. */
settingsSearchQuery: string
setSettingsSearchQuery: (q: string) => void
}
@@ -9,31 +8,10 @@ export type SettingsSearchState = {
export function createSettingsSearchState(
set: (state: Partial<SettingsSearchState>) => void
): SettingsSearchState {
let settingsSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const clearSettingsSearchDebounce = (): void => {
if (settingsSearchDebounceTimer) {
clearTimeout(settingsSearchDebounceTimer)
settingsSearchDebounceTimer = null
}
}
return {
settingsSearchInputQuery: '',
settingsSearchQuery: '',
setSettingsSearchQuery: (q) => {
clearSettingsSearchDebounce()
if (q.trim() === '') {
set({ settingsSearchInputQuery: q, settingsSearchQuery: q })
return
}
set({ settingsSearchInputQuery: q })
// Why: applying settings search mounts and filters many heavy sections,
// so keep typing responsive while waiting for the query to settle.
settingsSearchDebounceTimer = setTimeout(() => {
settingsSearchDebounceTimer = null
set({ settingsSearchQuery: q })
}, SETTINGS_SEARCH_DEBOUNCE_MS)
}
// Why input only: the sidebar lists matching settings and the page stays unfiltered.
setSettingsSearchQuery: (q) => set({ settingsSearchInputQuery: q })
}
}
+10 -2
View File
@@ -10,6 +10,14 @@ import {
waitForActiveTerminalManager
} from './helpers/terminal'
// Search lists results; the page moves on an explicit pick.
async function openSettingsSearchResult(page: Page, query: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search settings')
await searchInput.fill(query)
await searchInput.press('ArrowDown')
await searchInput.press('Enter')
}
const enabled = process.env.ORCA_REAL_CLAUDE_THEME_TEST === '1'
// Claude Code's own light theme paints its prompt border this gray.
const CLAUDE_LIGHT_BORDER = 0x8a8a8a
@@ -236,7 +244,7 @@ test('an existing user accepts the offer, then Claude Code follows the Orca them
// Turning it off through the real switch restores the user's theme, live too.
await orcaPage.evaluate(() => window.__store!.getState().openSettingsPage())
await orcaPage.getByPlaceholder('Search settings').fill('Match Claude Code')
await openSettingsSearchResult(orcaPage, 'Match Claude Code')
const toggle = orcaPage.getByRole('switch', { name: 'Match Claude Code to Terminal Theme' })
await expect(toggle).toBeChecked()
await windowShot('settings-on-light')
@@ -251,7 +259,7 @@ test('an existing user accepts the offer, then Claude Code follows the Orca them
// The same switch in dark mode, off then on.
await updateSettings({ theme: 'dark' })
await orcaPage.evaluate(() => window.__store!.getState().openSettingsPage())
await orcaPage.getByPlaceholder('Search settings').fill('Match Claude Code')
await openSettingsSearchResult(orcaPage, 'Match Claude Code')
await expect(toggle).not.toBeChecked()
await windowShot('settings-off-dark')
await toggle.click()
@@ -35,13 +35,16 @@ test.describe('Terminal shortcut conflicting with a chord main intercepts', () =
const searchInput = orcaPage.getByPlaceholder('Search settings')
await expect(searchInput).toBeVisible()
await searchInput.fill('shortcuts')
// Search lists results; the page moves on an explicit pick.
await searchInput.press('ArrowDown')
await searchInput.press('Enter')
await expect(orcaPage.getByRole('heading', { name: 'Shortcuts', exact: true })).toBeVisible()
await orcaPage.getByRole('button', { name: /Conflicts/ }).click()
await expect(orcaPage.getByText('Split terminal right', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Worktree History Forward', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Worktree history forward', { exact: true })).toBeVisible()
await expect(
orcaPage.getByText(/conflicts with .*Worktree History Forward/).first()
orcaPage.getByText(/conflicts with .*Worktree history forward/).first()
).toBeVisible()
})
})
@@ -2,7 +2,9 @@ import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
test.describe('Settings sidebar search on the Shortcuts pane', () => {
test('pane-title-only query keeps rows visible and local search usable', async ({ orcaPage }) => {
test('picking a result opens the Shortcuts pane unfiltered with local search usable', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await orcaPage.evaluate(async () => {
@@ -20,21 +22,21 @@ test.describe('Settings sidebar search on the Shortcuts pane', () => {
await expect(searchInput).toBeFocused()
await searchInput.fill('shortcuts')
// Typing only lists results; the page moves on an explicit pick.
await expect(orcaPage.getByRole('option').first()).toBeVisible()
await expect(
orcaPage.getByRole('heading', { name: 'Shortcuts', exact: true })
).not.toBeVisible()
await searchInput.press('ArrowDown')
await searchInput.press('Enter')
await expect(orcaPage.getByRole('heading', { name: 'Shortcuts', exact: true })).toBeVisible()
// Regression: a pane-title-only query used to blank the whole list (0/112).
await expect(orcaPage.getByText('Go to File', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('No shortcuts match those filters.')).not.toBeVisible()
// Keep pane search working with a global query; the old empty base list broke their intersection.
// The sidebar query no longer filters the pane, so every row and the pane's own search stay usable.
await expect(orcaPage.getByText('Go to file', { exact: true })).toBeVisible()
await expect(searchInput).toHaveValue('shortcuts')
const localSearch = orcaPage.getByPlaceholder('Search command or keys')
await localSearch.fill('go to')
await expect(orcaPage.getByText('Go to File', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Force Reload', { exact: true })).not.toBeVisible()
await localSearch.clear()
await searchInput.fill('worktree')
await expect(orcaPage.getByText('Create worktree', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Go to File', { exact: true })).not.toBeVisible()
await expect(orcaPage.getByText('Go to file', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Force reload', { exact: true })).not.toBeVisible()
})
})