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

);