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.
This commit is contained in:
Jamie Pine committed 2026-09-15 21:28:10 -07:00
1 parent af34703731
commit f34fc3c8ae
5 files changed
+82 -28

No files matched your search

@@ -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"
>
<ExpandableSearchButton
ref={searchInputRef}
placeholder={
currentPath
? 'Search in current folder...'
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MagnifyingGlass } from "@phosphor-icons/react";
import { CircleButton, SearchBar } from "@spacedrive/primitives";
@@ -10,16 +10,35 @@ interface ExpandableSearchButtonProps {
placeholder?: string;
}
export function ExpandableSearchButton({
value,
onChange,
onClear,
placeholder = "Search...",
}: ExpandableSearchButtonProps) {
export interface ExpandableSearchButtonHandle {
focus: () => void;
}
export const ExpandableSearchButton = forwardRef<
ExpandableSearchButtonHandle,
ExpandableSearchButtonProps
>(function ExpandableSearchButton(
{ value, onChange, onClear, placeholder = "Search..." },
ref,
) {
const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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({
</motion.div>
</div>
);
}
});
@@ -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[];
@@ -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
@@ -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 (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
<p className="text-ink-dull text-sm">
Type at least 2 characters to search
Type at least {MIN_SEARCH_QUERY_LENGTH} characters to search
</p>
</div>
);