From f34fc3c8ae987fd0e8e8516103ebefb27d22eb89 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 15 Sep 2026 21:23:52 -0700 Subject: [PATCH] Debounce search for real and stop resetting the picked scope The search input's debounce returned a cleanup closure from an onChange handler that nothing ever invoked, so typing a six-letter word fired five separate searches 300ms apart, and each dispatch re-entered search mode with the default scope, silently reverting a Library or Location selection to This Folder on the next keystroke. An effect owns the timer now: each keystroke cancels the previous one, one search fires per settled query, and entering search mode carries the scope already picked. Dropping below the minimum length exits search deterministically, including the backspace to one character that previously left stale two-character results on screen. The minimum query length lives in one exported constant instead of three synchronized literals, and Cmd+F finally does something: the already-defined focusSearchBar keybind expands and focuses the search bar through a handle on ExpandableSearchButton. Per-tab search state and search as a navigation target remain with the search refinement plan's Phase 2; they touch the shell layout another session is actively changing. --- .../src/routes/explorer/ExplorerView.tsx | 58 ++++++++++++++----- .../components/ExpandableSearchButton.tsx | 35 ++++++++--- .../interface/src/routes/explorer/context.tsx | 4 ++ .../routes/explorer/hooks/useExplorerFiles.ts | 7 ++- .../explorer/views/SearchView/SearchView.tsx | 6 +- 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/packages/interface/src/routes/explorer/ExplorerView.tsx b/packages/interface/src/routes/explorer/ExplorerView.tsx index dcb3a9132..62f25e61e 100644 --- a/packages/interface/src/routes/explorer/ExplorerView.tsx +++ b/packages/interface/src/routes/explorer/ExplorerView.tsx @@ -12,6 +12,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState } from 'react'; import {useLocation} from 'react-router-dom'; @@ -19,7 +20,13 @@ import {TopBarItem, TopBarPortal} from '../../TopBar'; import {ExpandableSearchButton} from './components/ExpandableSearchButton'; import {PathBar} from './components/PathBar'; import {VirtualPathBar} from './components/VirtualPathBar'; -import {getSpaceItemKeyFromRoute, useExplorer, type ViewMode} from './context'; +import { + getSpaceItemKeyFromRoute, + MIN_SEARCH_QUERY_LENGTH, + useExplorer, + type ViewMode +} from './context'; +import {useKeybind} from '../../hooks/useKeybind'; import {useVirtualListing} from './hooks/useVirtualListing'; import {SearchToolbar} from './SearchToolbar'; import {SortMenu, SortMenuPanel} from './SortMenu'; @@ -96,21 +103,36 @@ export function ExplorerView({ const [searchValue, setSearchValue] = useState(''); - const handleSearchChange = useCallback( - (value: string) => { - setSearchValue(value); + // The current mode, readable from the debounce effect without making it + // a dependency: a dispatch that changes the mode must not reschedule the + // timer for an unchanged input value. + const modeRef = useRef(mode); + modeRef.current = mode; - if (value.length >= 2) { - const timeoutId = setTimeout(() => { - enterSearchMode(value); - }, 300); - return () => clearTimeout(timeoutId); - } else if (value.length === 0 && mode.type === 'search') { - exitSearchMode(); - } - }, - [enterSearchMode, exitSearchMode, mode.type] - ); + // One search per settled query. The effect owns the timer, so every + // keystroke cancels the previous one, and entering search mode carries + // the scope the user already picked instead of resetting it. Dropping + // under the minimum length exits deterministically, including the + // two-to-one-character backspace. + useEffect(() => { + if (searchValue.length >= MIN_SEARCH_QUERY_LENGTH) { + const timeoutId = setTimeout(() => { + const current = modeRef.current; + enterSearchMode( + searchValue, + current.type === 'search' ? current.scope : undefined + ); + }, 300); + return () => clearTimeout(timeoutId); + } + if (modeRef.current.type === 'search') { + exitSearchMode(); + } + }, [searchValue, enterSearchMode, exitSearchMode]); + + const handleSearchChange = useCallback((value: string) => { + setSearchValue(value); + }, []); const handleSearchClear = useCallback(() => { setSearchValue(''); @@ -123,6 +145,11 @@ export function ExplorerView({ } }, [mode.type]); + const searchInputRef = useRef<{focus: () => void}>(null); + useKeybind('global.focusSearchBar', () => searchInputRef.current?.focus(), { + ignoreWhenInputFocused: false + }); + // When leaving column view, navigate to the deepest column so the // new view shows the directory the user was actually looking at. const handleViewModeChange = useCallback( @@ -254,6 +281,7 @@ export function ExplorerView({ priority="high" > void; +} + +export const ExpandableSearchButton = forwardRef< + ExpandableSearchButtonHandle, + ExpandableSearchButtonProps +>(function ExpandableSearchButton( + { value, onChange, onClear, placeholder = "Search..." }, + ref, +) { const [isExpanded, setIsExpanded] = useState(false); const containerRef = useRef(null); const inputRef = useRef(null); + // The keybind path: expand if collapsed, focus once the input exists. + // A collapsed bar has no input yet; the expand animation's completion + // handler takes over the focus in that case. + useImperativeHandle( + ref, + () => ({ + focus: () => { + setIsExpanded(true); + inputRef.current?.focus(); + }, + }), + [], + ); + // Expand if there's a value useEffect(() => { if (value) { @@ -112,4 +131,4 @@ export function ExpandableSearchButton({ ); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/packages/interface/src/routes/explorer/context.tsx b/packages/interface/src/routes/explorer/context.tsx index 5c84c741c..2e2d833e9 100644 --- a/packages/interface/src/routes/explorer/context.tsx +++ b/packages/interface/src/routes/explorer/context.tsx @@ -46,6 +46,10 @@ export interface ViewSettings { export type SearchScope = 'folder' | 'location' | 'library'; +/** The one place the minimum query length lives; the input handler, the + * query gate, and the results view all read it. */ +export const MIN_SEARCH_QUERY_LENGTH = 2; + export interface SearchFilters { fileTypes?: string[]; contentTypes?: string[]; diff --git a/packages/interface/src/routes/explorer/hooks/useExplorerFiles.ts b/packages/interface/src/routes/explorer/hooks/useExplorerFiles.ts index d7807b959..ba72c6f25 100644 --- a/packages/interface/src/routes/explorer/hooks/useExplorerFiles.ts +++ b/packages/interface/src/routes/explorer/hooks/useExplorerFiles.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import type { DirectorySortBy, File, FileSearchInput, FileSearchOutput } from "@sd/ts-client"; import { useNormalizedQuery } from "../../../contexts/SpacedriveContext"; -import { useExplorer } from "../context"; +import { MIN_SEARCH_QUERY_LENGTH, useExplorer } from "../context"; import { useVirtualListing } from "./useVirtualListing"; export type FileSource = @@ -177,7 +177,10 @@ export function useExplorerFiles(): ExplorerFilesResult { isSearchMode && mode.type === "search" && mode.scope === "folder" && currentPath ? (currentPath as any) : undefined, - enabled: isSearchMode && !!searchQueryInput && searchQueryInput.query.length >= 2, + enabled: + isSearchMode && + !!searchQueryInput && + searchQueryInput.query.length >= MIN_SEARCH_QUERY_LENGTH, }); // Recents query diff --git a/packages/interface/src/routes/explorer/views/SearchView/SearchView.tsx b/packages/interface/src/routes/explorer/views/SearchView/SearchView.tsx index a29ad3d6c..c0f10dfed 100644 --- a/packages/interface/src/routes/explorer/views/SearchView/SearchView.tsx +++ b/packages/interface/src/routes/explorer/views/SearchView/SearchView.tsx @@ -1,4 +1,4 @@ -import { useExplorer } from '../../context'; +import { MIN_SEARCH_QUERY_LENGTH, useExplorer } from '../../context'; import { GridView } from '../GridView'; import { ListView } from '../ListView'; import { MediaView } from '../MediaView'; @@ -30,11 +30,11 @@ export function SearchView() { } // Show minimum character hint - if (mode.query.length < 2) { + if (mode.query.length < MIN_SEARCH_QUERY_LENGTH) { return (

- Type at least 2 characters to search + Type at least {MIN_SEARCH_QUERY_LENGTH} characters to search

);