mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-11-05 14:53:38 -05:00
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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user