Skip to content

Commit 52ab86f

Browse files
committed
Fix(themes): typing in the Themes picker never applies a theme you did not pick
Found while recording the 2.78.0 diagram clip: from Gruvbox Hard dark, Cmd+Shift+P, Themes, then typing l switched the app to Nord Light with no row highlighted, and the next keystroke kept it there. Each new filter runs two effects in one commit: one re-syncs the highlight onto the current theme, which lands on the next render, and the preview one read `active` in the same commit, still an index into the previous list. It previewed whatever sat at that index in the new list, and the next re-sync found that theme current and locked onto it. Choosing Themes from the recent commands, with an empty query, could do the same with an index from the command list. The preview now remembers the mode and the list it last saw and skips the commit in which either changed (and the first render), so it only follows an `active` that was set against the list it indexes. Arrows, the pointer and Enter preview and keep exactly as before, and Escape still reverts. The same take showed the theme search could not find "apple light" or "gruvbox dark hard": rankItems scores each field alone and the query spans two. rankThemes (lib/themes.ts) adds the row read as one phrase, family, mode, flavor, label, below the label's weight, so typing a label still ranks it first. Tests: rankThemes (family plus mode or flavor, a label first, every theme for an empty query). In the built app over CDP with isolated stores, from Gruvbox Hard dark: typing "light" leaves the theme alone, ArrowDown previews Apple Light and Escape reverts, "apple light" lists Apple Light first, and Themes from the recents keeps the theme; six runs passed. One earlier run read the theme 500 ms after ArrowDown before it repainted, with the machine at a load average of about 38.
1 parent e6bf83f commit 52ab86f

3 files changed

Lines changed: 47 additions & 11 deletions

File tree

‎packages/app-core/src/components/CommandPalette.tsx‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
canReturnToCommandList,
1919
shouldRefocusEditorAfterCommand
2020
} from '../lib/command-palette-mode'
21-
import { THEMES, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes'
21+
import { rankThemes, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes'
2222
import {
2323
buildVaultSwitcherEntries,
2424
newWindowVaultRows,
@@ -104,15 +104,7 @@ export function CommandPalette(): JSX.Element {
104104
return [...recentCommands, ...ranked.filter((c) => !recentSet.has(c.id))]
105105
}, [allCommands, query, recentCommands])
106106

107-
const themeResults = useMemo<ThemeOption[]>(
108-
() =>
109-
rankItems(THEMES, query, [
110-
{ get: (t) => t.label, weight: 1 },
111-
{ get: (t) => t.family, weight: 0.9 },
112-
{ get: (t) => t.variant, weight: 0.6 }
113-
]),
114-
[query]
115-
)
107+
const themeResults = useMemo<ThemeOption[]>(() => rankThemes(query), [query])
116108

117109
const vaultOptions = useMemo<VaultRow[]>(() => {
118110
const entries = buildVaultSwitcherEntries({
@@ -208,7 +200,19 @@ export function CommandPalette(): JSX.Element {
208200
}, [active])
209201

210202
/* -------- Theme preview on active change -------- */
203+
// A new filter or mode re-syncs `active` in the effect above, but that lands
204+
// on the next render: in this one `active` still indexes the previous list
205+
// (or the command list), and previewing it applied a theme nobody had
206+
// highlighted (typing `l` from Gruvbox Hard dark switched to Nord Light),
207+
// which the next re-sync then locked onto. So a preview waits until `active`
208+
// was set against the list it indexes, including on the first render.
209+
const previewBasisRef = useRef<{ mode: Mode; results: ThemeOption[] } | null>(null)
211210
useEffect(() => {
211+
const basis = previewBasisRef.current
212+
if (!basis || basis.mode !== mode || basis.results !== themeResults) {
213+
previewBasisRef.current = { mode, results: themeResults }
214+
return
215+
}
212216
if (mode !== 'theme') return
213217
if (active < 0) return
214218
const theme = themeResults[active]

‎packages/app-core/src/lib/themes.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { readFileSync } from 'node:fs'
33
import { fileURLToPath } from 'node:url'
44
import { describe, expect, it } from 'vitest'
5-
import { THEMES, resolveAuto } from './themes'
5+
import { THEMES, rankThemes, resolveAuto } from './themes'
66

77
const css = readFileSync(
88
fileURLToPath(new URL('../styles/index.css', import.meta.url)),
@@ -77,3 +77,17 @@ describe('built-in theme registry ↔ CSS', () => {
7777
}
7878
})
7979
})
80+
81+
describe('rankThemes', () => {
82+
it('finds a family named together with its mode or flavor', () => {
83+
expect(rankThemes('apple light')[0]?.id).toBe('apple-light')
84+
expect(rankThemes('apple dark')[0]?.id).toBe('apple-dark')
85+
expect(rankThemes('gruvbox dark hard')[0]?.id).toBe('dark-hard')
86+
expect(rankThemes('tokyo night')[0]?.family).toBe('tokyo-night')
87+
})
88+
89+
it('still ranks a whole label first, and lists every theme for an empty query', () => {
90+
expect(rankThemes('light')[0]?.id).toBe('apple-light')
91+
expect(rankThemes('')).toHaveLength(THEMES.length)
92+
})
93+
})

‎packages/app-core/src/lib/themes.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
* dark, dark dimmed, dark high-contrast
1010
*/
1111

12+
import { rankItems } from './fuzzy-score'
13+
1214
export type ThemeFamily =
1315
| 'apple'
1416
| 'gruvbox'
@@ -189,3 +191,19 @@ export function resolveAuto(
189191
// github
190192
return targetMode === 'dark' ? 'github-dark' : 'github-light'
191193
}
194+
195+
/** The Themes picker's ranking. Each field is scored on its own, so a query
196+
* that names a family together with its mode or flavor ("apple light",
197+
* "gruvbox dark hard") matched none of them and the list came up empty. The
198+
* last field reads a row as one phrase, family first, the way people say it. */
199+
export function rankThemes(query: string, themes: readonly ThemeOption[] = THEMES): ThemeOption[] {
200+
return rankItems([...themes], query, [
201+
{ get: (t) => t.label, weight: 1 },
202+
{ get: (t) => t.family, weight: 0.9 },
203+
{ get: (t) => t.variant, weight: 0.6 },
204+
{
205+
get: (t) => [t.family.replace(/-/g, ' '), t.mode, t.variant, t.label].filter(Boolean).join(' '),
206+
weight: 0.8
207+
}
208+
])
209+
}

0 commit comments

Comments
 (0)