perf: add search debounce and increase global search timeout
- SearchBar: 300ms debounce with local input state, flush on Enter - CommandPalette: global search debounce 200→400ms - Show "500+" when search results are capped - Fix: syncSearchMatchesWithRendered now updates searchMatchItemIds Ported from upstream 5c7f921e (SearchBar/CommandPalette parts).
This commit is contained in:
parent
c21350713c
commit
22387ca0cb
3 changed files with 63 additions and 7 deletions
|
|
@ -288,7 +288,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 400);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
|
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* SearchBar - In-session search interface component.
|
* SearchBar - In-session search interface component.
|
||||||
* Appears at the top of the chat view when Cmd+F is pressed.
|
* Appears at the top of the chat view when Cmd+F is pressed.
|
||||||
|
*
|
||||||
|
* Uses a local input state with debouncing to avoid triggering expensive
|
||||||
|
* search on every keystroke.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
tabId?: string;
|
tabId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +24,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
searchVisible,
|
searchVisible,
|
||||||
searchResultCount,
|
searchResultCount,
|
||||||
currentSearchIndex,
|
currentSearchIndex,
|
||||||
|
searchResultsCapped,
|
||||||
conversation,
|
conversation,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
hideSearch,
|
hideSearch,
|
||||||
|
|
@ -30,6 +36,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
searchVisible: s.searchVisible,
|
searchVisible: s.searchVisible,
|
||||||
searchResultCount: s.searchResultCount,
|
searchResultCount: s.searchResultCount,
|
||||||
currentSearchIndex: s.currentSearchIndex,
|
currentSearchIndex: s.currentSearchIndex,
|
||||||
|
searchResultsCapped: s.searchResultsCapped,
|
||||||
conversation: tabId
|
conversation: tabId
|
||||||
? (s.tabSessionData[tabId]?.conversation ?? s.conversation)
|
? (s.tabSessionData[tabId]?.conversation ?? s.conversation)
|
||||||
: s.conversation,
|
: s.conversation,
|
||||||
|
|
@ -40,8 +47,43 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Local input value for responsive typing — debounced before triggering search
|
||||||
|
const [localQuery, setLocalQuery] = useState(searchQuery);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(
|
||||||
|
0 as unknown as ReturnType<typeof setTimeout>
|
||||||
|
);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Sync local state when store query changes externally (e.g., hideSearch clears it)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalQuery(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Debounced search dispatch
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setLocalQuery(value);
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
// Clear immediately when input is emptied
|
||||||
|
if (!value.trim()) {
|
||||||
|
setSearchQuery('', conversation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setSearchQuery(value, conversation);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[conversation, setSearchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearTimeout(debounceRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-focus input when search becomes visible
|
// Auto-focus input when search becomes visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchVisible && inputRef.current) {
|
if (searchVisible && inputRef.current) {
|
||||||
|
|
@ -55,6 +97,11 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
hideSearch();
|
hideSearch();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
|
// Flush any pending debounce immediately on Enter
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (localQuery !== searchQuery) {
|
||||||
|
setSearchQuery(localQuery, conversation);
|
||||||
|
}
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
previousSearchResult();
|
previousSearchResult();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -67,14 +114,18 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resultLabel = searchResultsCapped
|
||||||
|
? `${currentSearchIndex + 1} of ${searchResultCount}+`
|
||||||
|
: `${currentSearchIndex + 1} of ${searchResultCount}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-4 top-2 z-20 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 shadow-lg">
|
<div className="absolute right-4 top-2 z-20 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 shadow-lg">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={localQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value, conversation)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Find in conversation..."
|
placeholder="Find in conversation..."
|
||||||
className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none"
|
className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none"
|
||||||
|
|
@ -83,9 +134,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
{/* Result count */}
|
{/* Result count */}
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<span className="whitespace-nowrap text-xs text-text-secondary">
|
<span className="whitespace-nowrap text-xs text-text-secondary">
|
||||||
{searchResultCount > 0
|
{searchResultCount > 0 ? resultLabel : 'No results'}
|
||||||
? `${currentSearchIndex + 1} of ${searchResultCount}`
|
|
||||||
: 'No results'}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -397,10 +397,17 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild matchItemIds from synced matches
|
||||||
|
const syncedMatchItemIds = new Set<string>();
|
||||||
|
for (const match of nextMatches) {
|
||||||
|
syncedMatchItemIds.add(match.itemId);
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
searchMatches: nextMatches,
|
searchMatches: nextMatches,
|
||||||
searchResultCount: nextMatches.length,
|
searchResultCount: nextMatches.length,
|
||||||
currentSearchIndex: newCurrentIndex,
|
currentSearchIndex: newCurrentIndex,
|
||||||
|
searchMatchItemIds: syncedMatchItemIds,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue