Debounce settings search (#2187)

This commit is contained in:
Neil 2026-05-17 17:25:41 -07:00 committed by GitHub
parent 5642277aaf
commit 49d14fd260
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 7 deletions

View File

@ -211,6 +211,7 @@ function Settings(): React.JSX.Element {
const removeRepo = useAppStore((s) => s.removeRepo)
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery)
@ -941,7 +942,7 @@ function Settings(): React.JSX.Element {
generalSections={generalNavSections}
repoSections={repoNavSections}
hasRepos={repos.length > 0}
searchQuery={settingsSearchQuery}
searchQuery={settingsSearchInputQuery}
searchInputRef={searchInputRef}
onBack={closeSettingsPageWithPromptGuard}
onSearchChange={setSettingsSearchQuery}

View File

@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createSettingsSearchState,
SETTINGS_SEARCH_DEBOUNCE_MS,
type SettingsSearchState
} from './settings-search-state'
function createTestSearchState(): {
getState: () => SettingsSearchState
setState: (state: Partial<SettingsSearchState>) => void
} {
let state = {} as SettingsSearchState
const setState = (updates: Partial<SettingsSearchState>): void => {
state = { ...state, ...updates }
}
state = createSettingsSearchState(setState)
return { getState: () => state, setState }
}
afterEach(() => {
vi.useRealTimers()
})
describe('settings search state', () => {
it('updates the search input immediately and debounces the applied filter', () => {
vi.useFakeTimers()
const store = createTestSearchState()
store.getState().setSettingsSearchQuery('terminal')
expect(store.getState().settingsSearchInputQuery).toBe('terminal')
expect(store.getState().settingsSearchQuery).toBe('')
vi.advanceTimersByTime(SETTINGS_SEARCH_DEBOUNCE_MS - 1)
expect(store.getState().settingsSearchQuery).toBe('')
vi.advanceTimersByTime(1)
expect(store.getState().settingsSearchQuery).toBe('terminal')
})
it('clears settings search immediately and cancels pending debounce work', () => {
vi.useFakeTimers()
const store = createTestSearchState()
store.setState({ settingsSearchInputQuery: 'terminal', settingsSearchQuery: 'terminal' })
store.getState().setSettingsSearchQuery('agents')
vi.advanceTimersByTime(SETTINGS_SEARCH_DEBOUNCE_MS - 1)
store.getState().setSettingsSearchQuery('')
expect(store.getState().settingsSearchInputQuery).toBe('')
expect(store.getState().settingsSearchQuery).toBe('')
vi.advanceTimersByTime(SETTINGS_SEARCH_DEBOUNCE_MS)
expect(store.getState().settingsSearchQuery).toBe('')
})
})

View File

@ -0,0 +1,39 @@
export const SETTINGS_SEARCH_DEBOUNCE_MS = 150
export type SettingsSearchState = {
settingsSearchInputQuery: string
settingsSearchQuery: string
setSettingsSearchQuery: (q: string) => void
}
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)
}
}
}

View File

@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore, makeWorktree } from './store-test-helpers'
import type { AppState } from '../types'
import type { WorktreeLineage } from '../../../../shared/types'

View File

@ -10,11 +10,10 @@ import {
import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands'
import { normalizeVisibleTaskProviders } from '../../../../shared/task-providers'
import { normalizeOpenInApplications } from '../../../../shared/open-in-applications'
import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state'
export type SettingsSlice = {
export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
settingsSearchQuery: string
setSettingsSearchQuery: (q: string) => void
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
switchRuntimeEnvironment: (environmentId: string | null) => Promise<boolean>
@ -221,8 +220,7 @@ async function verifyRuntimeEnvironmentReachable(environmentId: string | null):
export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> = (set, get) => ({
settings: null,
settingsSearchQuery: '',
setSettingsSearchQuery: (q) => set({ settingsSearchQuery: q }),
...createSettingsSearchState((state) => set(state)),
fetchSettings: async () => {
try {