Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.PATCHED.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Fork's Nightly Changes
*Changes that's already on the fork and waiting to be reviewed for merge into original Atlas*
- Update debounce logic for Browse and Library: Search in Catalog Browse and Library now debounces the text input and waits for a pause before filtering. Previously every keystroke updated `activeFilters.text` and ran `filterGamesWithState` (Library) or scheduled a catalog fetch, causing input lag on large libraries and a wasted local-filter pass even while browsing the server-side catalog. The input still echoes instantly from local state; clear bypasses the delay.[PR#398](https://github.com/towerwatchman/Atlas/pull/398)

## Independent Changes
*Any fork-only changes that is not accepted for merged but valid, or independent changes to make the fork repo releasable (e.g. custom version or preventing updates)*
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
- The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v<version>` for stable and `v<version>-nightly.<run>` for nightly -- so it lands on the real release rather than a 404. (#143)
- Added a "Download Version" entry to the split-button caret on the game detail page, beside "Manual Install". It opens the same downloads modal the UPDATE button does, listing every build and mirror the thread offers, so a different version can be fetched over one already installed. Previously an installed title with no pending update had no route to that modal at all: the primary button becomes PLAY once a version is installed, and the UPDATE button only renders when an update is flagged. The entry goes straight to the downloads modal rather than through the source picker, and is shown disabled with a reason for titles with no F95zone thread linked.

### Changed


### Fixed
- Catalog tag filtering now matches Library. Library already did exact-token filtering via `splitListText`/`normalizeTagText`/`includesTag` `src/hooks/useFilters.js`; Catalog used substring `LOWER(col) LIKE '%tag%'`. Fix copies those Library helpers into shared `src/utils/tagTokens.js` ↔ `electron/db/tagTokens.js` and adds a stored `tags_filter` column plus an indexed `catalog_index_tags` table (`catalog_key, tag` + `idx_catalog_index_tags_tag`). Browse now filters via `EXISTS (SELECT 1 FROM catalog_index_tags WHERE tag=?)` hitting the index instead of `LIKE '%,token,%'` with leading wildcard on `tags_filter` (full scan, a few seconds per tag click). `COALESCE`/`ESCAPE`/`REPLACE` remain only for the union fallback path when the index is not ready.
- Fixed MEGA v1 test timeout — legacy key derivation is intentionally slow and needed a longer test timeout.
Expand Down
27 changes: 13 additions & 14 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const App = () => {
const [wishlistIdentityKeys, setWishlistIdentityKeys] = useState(new Set())
const [activeSavedFilterId, setActiveSavedFilterId] = useState('')
const [savedFilterDeleteStateById, setSavedFilterDeleteStateById] = useState({})
const [resetInputSignal, setResetInputSignal] = useState(0)
// Banner card dimensions for Grid sizing — derived from the same
// resolved template BannerTemplateProvider already computed once for
// <GameBanner> (see src/theme/BannerTemplateProvider.jsx), rather than
Expand Down Expand Up @@ -1214,6 +1215,7 @@ const App = () => {
setActiveSavedFilterId('')
pendingLibraryScrollTopRestoreRef.current = 0
libraryScrollTopRef.current = 0
setResetInputSignal((n) => n + 1)
if (libraryMode === 'catalog') {
// "Reset" in Browse mode should mean the whole catalog, not the
// local library's installed-only default — otherwise resetting
Expand Down Expand Up @@ -1889,7 +1891,11 @@ const App = () => {
}
}, [filterSidebarMode, selectedGame])

const catalogResetDebounceRef = useRef(null)
// Search input already debounces via useDebouncedSearch (SearchBox/
// SearchSidebar) before activeFilters updates, so debouncing again here
// would stack an extra delay before the spinner shows. Fetch immediately
// once the debounced filters arrive; the paramsKey guard still prevents
// the catalogTotal/enter-mode re-runs from wiping correct data.
useEffect(() => {
if (libraryMode !== 'catalog' || !browseAvailable) return
const paramsKey = catalogParamsKey(catalogSearch, catalogQueryFilters)
Expand All @@ -1902,18 +1908,8 @@ const App = () => {
// the "banners flash, spinner, banners reload" sequence this fixes.
return
}
if (catalogResetDebounceRef.current) clearTimeout(catalogResetDebounceRef.current)
catalogResetDebounceRef.current = setTimeout(() => {
catalogResetDebounceRef.current = null
lastFetchedCatalogParamsKeyRef.current = paramsKey
fetchCatalogGames({ reset: true, search: catalogSearch, filters: catalogQueryFilters })
}, 300)
return () => {
if (catalogResetDebounceRef.current) {
clearTimeout(catalogResetDebounceRef.current)
catalogResetDebounceRef.current = null
}
}
lastFetchedCatalogParamsKeyRef.current = paramsKey
fetchCatalogGames({ reset: true, search: catalogSearch, filters: catalogQueryFilters })
}, [browseAvailable, catalogQueryFilters, catalogSearch, catalogTotal, fetchCatalogGames, libraryMode])

// When the catalog index finishes building, anything already on screen in
Expand Down Expand Up @@ -2106,7 +2102,7 @@ const App = () => {
// layout there is no search box here and Collections is a nav
// button instead (see TopNav's LEFT_ORDER).
<div className="flex justify-center w-full">
<SearchBox value={activeFilters.text} onSearchChange={handleSearchChange} onToggleSidebar={toggleSearchSidebar} />
<SearchBox value={activeFilters.text} onSearchChange={handleSearchChange} onToggleSidebar={toggleSearchSidebar} resetInputSignal={resetInputSignal} />
</div>
)}
</div>
Expand Down Expand Up @@ -2244,6 +2240,7 @@ const App = () => {
savedFilterDeleteStateById={savedFilterDeleteStateById}
onApplySavedFilter={applySavedFilter}
onDeleteSavedFilter={deleteSavedFilter}
resetInputSignal={resetInputSignal}
onClose={() => setShowSearchSidebar(false)}
/>
</div>
Expand Down Expand Up @@ -2579,6 +2576,7 @@ const App = () => {
savedFilterDeleteStateById={savedFilterDeleteStateById}
onApplySavedFilter={applySavedFilter}
onDeleteSavedFilter={deleteSavedFilter}
resetInputSignal={resetInputSignal}
onClose={() => setShowSearchSidebar(false)}
/>
)}
Expand All @@ -2601,6 +2599,7 @@ const App = () => {
savedFilterDeleteStateById={savedFilterDeleteStateById}
onApplySavedFilter={applySavedFilter}
onDeleteSavedFilter={deleteSavedFilter}
resetInputSignal={resetInputSignal}
onClose={() => setShowSearchSidebar(false)}
/>
)}
Expand Down
14 changes: 9 additions & 5 deletions src/components/search/SearchBox.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export default function SearchBox({ value = "", onSearchChange, onToggleSidebar }) {
import { useDebouncedSearch } from '../../hooks/useDebouncedSearch.js'

export default function SearchBox({ value = "", onSearchChange, onToggleSidebar, resetInputSignal }) {
const { localValue, handleChange, handleClear } = useDebouncedSearch({ value, onSearchChange, resetInputSignal })

const handleInputKeyDown = (event) => {
event.stopPropagation()
}
Expand All @@ -10,15 +14,15 @@ export default function SearchBox({ value = "", onSearchChange, onToggleSidebar
<input
type="text"
placeholder="Search Atlas"
value={value}
onChange={(e) => onSearchChange?.(e.target.value)}
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleInputKeyDown}
className="bg-transparent outline-none text-text flex-1 px-2 focus:outline-none -webkit-app-region-no-drag"
/>
{value && (
{localValue && (
<button
type="button"
onClick={() => onSearchChange?.("")}
onClick={handleClear}
title="Clear search"
aria-label="Clear search"
className="w-6 h-6 flex items-center justify-center text-muted hover:text-text focus:outline-none -webkit-app-region-no-drag"
Expand Down
18 changes: 14 additions & 4 deletions src/components/search/SearchSidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { builtInSavedFilters, getDefaultSortDirectionForSort, normalizeFilterState } from '../../hooks/useFilters.js'
import { useDebouncedSearch } from '../../hooks/useDebouncedSearch.js'
import SavedFiltersPanel from './SavedFiltersPanel.jsx'
import SearchScopePicker from './SearchScopePicker.jsx'
import { PLAYSTATE_OPTIONS } from '../../utils/playstates.js'
Expand Down Expand Up @@ -85,6 +86,7 @@ const SearchSidebar = ({
savedFilterDeleteStateById = {},
onApplySavedFilter,
onDeleteSavedFilter,
resetInputSignal,
// mode: 'overlay' (default, original behavior) floats fixed on top of
// the library grid without affecting its layout. 'inline' instead
// renders as a normal block — App.jsx places it as a flex sibling of
Expand All @@ -103,6 +105,14 @@ const SearchSidebar = ({
const [saveBusy, setSaveBusy] = useState(false);
const [tagError, setTagError] = useState("");
const [showSavedView, setShowSavedView] = useState(false);
// The sidebar's search field is debounced the same way the header
// SearchBox is: keystrokes echo instantly from local state while the
// parent filter (Library's in-memory filter and Browse's catalog fetch)
// waits for a pause. See useDebouncedSearch.js for why the delay lives
// before setActiveFilters rather than only before the fetch.
const { localValue: debouncedSearchText, handleChange: handleDebouncedSearchChange, handleClear: handleDebouncedSearchClear } =
useDebouncedSearch({ value: searchText, onSearchChange, resetInputSignal })

const selectedFilters = normalizeFilterState(activeFilters);
const [options, setOptions] = useState({
categories: [],
Expand Down Expand Up @@ -457,17 +467,17 @@ const SearchSidebar = ({
<input
type="text"
placeholder="Search Atlas"
value={searchText}
value={debouncedSearchText}
onChange={(e) => {
onSearchChange?.(e.target.value);
handleDebouncedSearchChange(e.target.value);
}}
onKeyDown={handleInputKeyDown}
className="bg-transparent outline-none text-text flex-1 px-3 py-2 focus:outline-none -webkit-app-region-no-drag"
/>
{searchText && (
{debouncedSearchText && (
<button
type="button"
onClick={() => onSearchChange?.("")}
onClick={handleDebouncedSearchClear}
title="Clear search"
aria-label="Clear search"
className="w-8 h-8 flex items-center justify-center text-muted hover:text-text focus:outline-none -webkit-app-region-no-drag"
Expand Down
99 changes: 99 additions & 0 deletions src/hooks/useDebouncedSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useState, useEffect, useRef, useCallback } from 'react'

// Keeps the search input visually instant while the expensive work waits
// for a pause in typing.
//
// Originally SearchBox/SearchSidebar called onSearchChange per keystroke, so
// Library filtered and Browse fetched per character. Even though Browse had
// a fetch-side debounce, activeFilters still updated per keystroke, forcing
// a full App re-render and a wasted local-filter pass via useFilters.js
// when Browse was showing. Debouncing before setActiveFilters fixes both at
// the source and the input still echoes from local state.
export function useDebouncedSearch({ value = '', onSearchChange, delay = 300, resetInputSignal } = {}) {
const [localValue, setLocalValue] = useState(value)
const timeoutRef = useRef(null)
const onSearchChangeRef = useRef(onSearchChange)
const lastSentRef = useRef(null)
const prevResetInputSignalRef = useRef(resetInputSignal)

useEffect(() => {
onSearchChangeRef.current = onSearchChange
}, [onSearchChange])

// Reset signal bails out the pending debounce even when `value` hasn't
// changed (e.g. committed text already '' on fresh load, user types then
// hits Reset within the debounce window). The normal [value] effect below
// never runs in that case because the prop is still ''. The parent
// (App.jsx) increments resetInputSignal on resetFilters so both SearchBox and
// SearchSidebar cancel their pending timers and sync to the authoritative
// value. Without this, the stale timer fires and re-applies the typed
// text after the grid was already reset.
useEffect(() => {
if (resetInputSignal === undefined) return
if (prevResetInputSignalRef.current === resetInputSignal) return
prevResetInputSignalRef.current = resetInputSignal
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
lastSentRef.current = value
setLocalValue((prev) => (prev === value ? prev : value))
}, [resetInputSignal, value])

// Adopt external `value` changes (resetFilters, applySavedFilter, clear
// from the other search box) but do not overwrite active typing with the
// echo of our own debounce. While a debounce is pending, `value` is still
// the previous committed filter; when the timeout fires we send the new
// local value and the parent echoes it back. That echo must not clobber a
// more recent local edit. Tracking `lastSent` lets us distinguish an echo
// (value === lastSent) from a genuine external change.
// Deps is [value] only — adding localValue would run the effect on every
// keystroke.
useEffect(() => {
setLocalValue((prev) => {
if (value === lastSentRef.current) {
// Echo of our own debounced emit. If we have already typed ahead
// (timeout pending), keep the ahead value; otherwise sync.
if (timeoutRef.current) return prev
return prev === value ? prev : value
}
if (timeoutRef.current) {
// External change while typing (e.g. saved filter applied) — cancel
// the pending local emit and adopt the authoritative value.
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
return prev === value ? prev : value
})
}, [value])

useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}, [])

const schedule = useCallback((next) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null
lastSentRef.current = next
onSearchChangeRef.current?.(next)
}, delay)
}, [delay])

const handleChange = useCallback((next) => {
setLocalValue(next)
schedule(next)
}, [schedule])

// Clear is user-intent to see "all" again; bypass the delay so the grid
// updates without waiting for the trailing edge.
const handleClear = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
lastSentRef.current = ''
setLocalValue('')
onSearchChangeRef.current?.('')
}, [])

return { localValue, handleChange, handleClear, setLocalValue }
}
Loading