From f5883afed3da58f9c29970d51961913d0f1db329 Mon Sep 17 00:00:00 2001 From: tianli Date: Sat, 18 Jul 2026 21:54:43 -0700 Subject: [PATCH] feat(app): persist search history with a clickable recent-searches dropdown Complements the in-memory ArrowUp/ArrowDown history from #31 (useSearchHistory.ts) by persisting queries across app restarts and surfacing them in a clickable panel. - New useRecentSearches hook wraps useStoredState to persist a newest-first, deduped, capped-at-50 list under localStorage key cardinal.recentSearches. - History records on query submit AND on opening a result (double-click / Cmd+O / context menu) via a small subscribeResultOpened pub/sub in openResultPath. - SearchBar gains a clock-icon toggle + dropdown (re-run a query, clear history, Esc / outside-click to close); native button semantics, aria-haspopup on toggle. - Fully i18n'd across all 15 locales; light/dark via existing CSS variables. - 6 unit tests for ordering, trimming, dedup, cap, restore/clear, malformed payload. --- cardinal/src/App.css | 113 ++++++++++++++++++ cardinal/src/App.tsx | 38 +++++- cardinal/src/components/SearchBar.tsx | 110 +++++++++++++++++ .../hooks/__tests__/useRecentSearches.test.ts | 97 +++++++++++++++ cardinal/src/hooks/useFilesTabState.ts | 7 +- cardinal/src/hooks/useRecentSearches.ts | 64 ++++++++++ cardinal/src/i18n/resources/ar-SA.json | 5 + cardinal/src/i18n/resources/de-DE.json | 5 + cardinal/src/i18n/resources/en-US.json | 5 + cardinal/src/i18n/resources/es-ES.json | 5 + cardinal/src/i18n/resources/fr-FR.json | 5 + cardinal/src/i18n/resources/hi-IN.json | 5 + cardinal/src/i18n/resources/it-IT.json | 5 + cardinal/src/i18n/resources/ja-JP.json | 5 + cardinal/src/i18n/resources/ko-KR.json | 5 + cardinal/src/i18n/resources/pt-BR.json | 5 + cardinal/src/i18n/resources/ru-RU.json | 5 + cardinal/src/i18n/resources/tr-TR.json | 5 + cardinal/src/i18n/resources/uk-UA.json | 5 + cardinal/src/i18n/resources/zh-CN.json | 5 + cardinal/src/i18n/resources/zh-TW.json | 5 + cardinal/src/utils/openResultPath.ts | 16 +++ 22 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 cardinal/src/hooks/__tests__/useRecentSearches.test.ts create mode 100644 cardinal/src/hooks/useRecentSearches.ts diff --git a/cardinal/src/App.css b/cardinal/src/App.css index 5d6af384..8af882ce 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -2051,3 +2051,116 @@ button:active:not(:disabled) { overflow-x: auto; overflow-y: hidden; } + +.search-history { + position: relative; + display: inline-flex; + align-items: center; +} + +.search-history-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 1.8rem; + width: 1.8rem; + height: 1.8rem; + padding: 0; + border: none; + border-radius: 0.55rem; + background: transparent; + box-shadow: none; + color: var(--color-muted); + cursor: pointer; + user-select: none; +} + +.search-history-toggle:hover { + background-color: var(--search-toggle-hover-bg); +} + +.search-history-toggle[aria-expanded='true'] { + color: var(--color-accent); + background-color: rgba(var(--color-accent-rgb), 0.08); +} + +.search-history-toggle svg { + display: block; + width: 1rem; + height: 1rem; +} + +.search-history-panel { + position: absolute; + top: calc(100% + 0.45rem); + right: 0; + z-index: 40; + display: flex; + flex-direction: column; + width: min(24rem, 72vw); + max-height: 19rem; + padding: 0.3rem; + border-radius: 12px; + border: 1px solid rgba(var(--color-accent-rgb), 0.14); + background-color: var(--color-elevated-bg); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); + box-sizing: border-box; +} + +.search-history-list { + display: flex; + flex-direction: column; + overflow-y: auto; + min-height: 0; +} + +.search-history-item { + display: block; + width: 100%; + padding: 0.4rem 0.55rem; + border: none; + border-radius: 8px; + background: transparent; + box-shadow: none; + color: var(--color-text); + font-size: 0.85rem; + font-family: var(--font-sans); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.search-history-item:hover { + background-color: rgba(var(--color-accent-rgb), 0.08); +} + +.search-history-empty { + padding: 0.55rem; + color: var(--color-muted); + font-size: 0.85rem; + text-align: center; +} + +.search-history-clear { + display: block; + width: 100%; + margin-top: 0.2rem; + padding: 0.4rem 0.55rem; + border: none; + border-top: 1px solid rgba(var(--color-accent-rgb), 0.12); + border-radius: 0 0 8px 8px; + background: transparent; + box-shadow: none; + color: var(--color-muted); + font-size: 0.8rem; + font-family: var(--font-sans); + text-align: center; + cursor: pointer; +} + +.search-history-clear:hover { + color: var(--color-accent); + background-color: rgba(var(--color-accent-rgb), 0.06); +} diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 6a49e9a1..c2ef641b 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback, useEffect, useMemo } from 'react'; +import { useRef, useCallback, useEffect, useMemo, useState } from 'react'; import type { ChangeEvent, CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import './App.css'; import { FileRow } from './components/FileRow'; @@ -23,13 +23,14 @@ import type { FSEventsPanelHandle } from './components/FSEventsPanel'; import { useTranslation } from 'react-i18next'; import { useFullDiskAccessPermission } from './hooks/useFullDiskAccessPermission'; import type { DisplayState } from './components/StateDisplay'; -import { openResultPath } from './utils/openResultPath'; +import { openResultPath, subscribeResultOpened } from './utils/openResultPath'; import { useStableEvent } from './hooks/useStableEvent'; import { useAppHotkeys } from './hooks/useAppHotkeys'; import { useAppPreferences } from './hooks/useAppPreferences'; import { useAppWindowListeners } from './hooks/useAppWindowListeners'; import { useFilesTabEffects } from './hooks/useFilesTabEffects'; import { useFilesTabState } from './hooks/useFilesTabState'; +import { useRecentSearches } from './hooks/useRecentSearches'; function App() { const { @@ -106,6 +107,9 @@ function App() { searchInputRef.current?.blur(); }, [displayedResults.length, selectSingleRow]); + const { recentSearches, recordSearch, clearRecentSearches } = useRecentSearches(); + const [historyOpen, setHistoryOpen] = useState(false); + const { activeTab, isSearchFocused, @@ -127,7 +131,28 @@ function App() { queueSearch, queueDirectorySearch, onNavigateFromSearchToResults: navigateFromSearchToResults, + onQueryCommitted: recordSearch, + }); + + // Record the active query whenever a result is opened — a search that led to an + // open is a successful search worth keeping in history. + const recordOpenedSearch = useStableEvent(() => { + recordSearch(searchParams.query); }); + useEffect(() => subscribeResultOpened(recordOpenedSearch), [recordOpenedSearch]); + + const toggleHistory = useCallback(() => { + setHistoryOpen((open) => !open); + }, []); + + const selectHistoryEntry = useCallback( + (query: string) => { + setHistoryOpen(false); + submitFilesQuery(query, { immediate: true }); + searchInputRef.current?.focus(); + }, + [submitFilesQuery], + ); const { filteredEvents } = useRecentFSEvents({ caseSensitive, isActive: activeTab === 'events', @@ -392,6 +417,15 @@ function App() { caseSensitiveLabel={caseSensitiveLabel} onFocus={handleSearchFocus} onBlur={handleSearchBlur} + historyEnabled={activeTab === 'files'} + historyEntries={recentSearches} + historyOpen={historyOpen} + onToggleHistory={toggleHistory} + onSelectHistoryEntry={selectHistoryEntry} + onClearHistory={clearRecentSearches} + historyLabel={t('search.history.toggle')} + historyEmptyLabel={t('search.history.empty')} + historyClearLabel={t('search.history.clear')} />
{activeTab === 'events' ? ( diff --git a/cardinal/src/components/SearchBar.tsx b/cardinal/src/components/SearchBar.tsx index 3bb190ca..cf2110d1 100644 --- a/cardinal/src/components/SearchBar.tsx +++ b/cardinal/src/components/SearchBar.tsx @@ -2,6 +2,27 @@ import React, { useCallback, useEffect, useRef } from 'react'; import type { ChangeEvent, FocusEventHandler } from 'react'; import { hasModifierKey } from '../utils/keyboard'; +const HISTORY_ICON = ( + +); + const MACOS_FOLDER_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADGUlEQVRYCe1XsW4TQRCdPZ9tEsuxiEmTABIFSijpKKClooiEhNKAlJJIFBRUfAEVEgW/QEoKkCiQKEAUNDRE0EBQFKBACbIT49z57pb39rx3Z0exneSiUGSsOe/tzs68ndmZ3RM5oWP2gMrYd9Eud7mQ6e9vRujwutzpH9zvuwVA47W7r78/Gps4fdsRXRqgKPK9ztsn16YWIPMH7IP1APmBQxZA7c7L1celanWR0lpraLRD6XzFXqWkgDGtvW/NlU/3l5euf4BEmEoNbFHOei+gpLVy7uarX18dkaJWWiKshyD6icYddLsQdAuOFCDr4KfYOYwgEumo7W01l5/Nzz2AeAMc0PWk8a22V6SBvVYfixExjAJ2AQ/+GwBDIkC9Xe+NSbGyeGP5i/tiYe4edDYsgMJ2G6FUWNoIZFesdKx46BSoJVACJpDIcW9hzkNwAkC1PWxo2tejgRhqtE+AoC1gtJht4xSxHpC/vi9n6xNyabouk9US4mu3R5+mA76G2FObW758/rkh65vb1GJSPQFQr47LlYvTRn0A4c6o+3pEQIxurVI2Nt6srHOWWWECYHbmjHgB0g87e9hGHNFmj5gK4xRmGGZnJuV9dzQBMHaqJF4YLxuiGI6B9Gg54AuzgKSERVSEtiwlALBsAUhTaLSOBa3QYf9tTbFpntWXAAgiFCYESkX5br6sMeNVhFgyC0wARPS+sT1CVevVuv+3jIkEQIj6q1S+rt8LmQ0Jx3sAAMFec/Ltz5wzPQCO1QORSZGjKcO73ZeG+j/yQBggCwfdxHav46A92qRcPDvxgOnToSlER3UiosjEF500AmkWBPSAOd/hBQdFIe9jmSlOtXhoXrm6ZD0Qtdo7UnZdhAGCuG4pVqu89iRU8ZCTEB6Aq73AXAfRm9aBnUajuVafqJw3wFiSc64Jpvh0S3Cj2VqDnR3aspWnjvaFy0/fPa+UyjPFYl5Lp4mUOp1IWr734+PS1Xn0roI3LACejwQxBa6BmQ4cs+NoHooYdDJPHN6Gf4M3wH7WAEHYL6M8jUOtIQvCfhfwg+aE5B+lBx09YnlGKQAAAABJRU5ErkJggg=='; @@ -25,6 +46,15 @@ type SearchBarProps = { caseSensitiveLabel: string; onFocus: FocusEventHandler; onBlur: FocusEventHandler; + historyEnabled?: boolean; + historyEntries?: string[]; + historyOpen?: boolean; + onToggleHistory?: () => void; + onSelectHistoryEntry?: (query: string) => void; + onClearHistory?: () => void; + historyLabel?: string; + historyEmptyLabel?: string; + historyClearLabel?: string; }; const isCollapsedAtStart = (input: HTMLInputElement): boolean => @@ -55,8 +85,18 @@ export function SearchBar({ caseSensitiveLabel, onFocus, onBlur, + historyEnabled = false, + historyEntries = [], + historyOpen = false, + onToggleHistory, + onSelectHistoryEntry, + onClearHistory, + historyLabel = 'Search history', + historyEmptyLabel = 'No search history yet', + historyClearLabel = 'Clear history', }: SearchBarProps): React.JSX.Element { const directoryInputRef = useRef(null); + const historyContainerRef = useRef(null); useEffect(() => { if (directoryScopeOpen) { @@ -64,6 +104,31 @@ export function SearchBar({ } }, [directoryScopeOpen]); + useEffect(() => { + if (!historyOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + const container = historyContainerRef.current; + if (container && event.target instanceof Node && !container.contains(event.target)) { + onToggleHistory?.(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onToggleHistory?.(); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [historyOpen, onToggleHistory]); + const handleQueryKeyDown = useCallback( (event: React.KeyboardEvent) => { if ( @@ -176,6 +241,51 @@ export function SearchBar({ />
+ {historyEnabled ? ( +
+ + {historyOpen ? ( +
+ {historyEntries.length === 0 ? ( +
{historyEmptyLabel}
+ ) : ( + <> +
+ {historyEntries.map((entry) => ( + + ))} +
+ + + )} +
+ ) : null} +
+ ) : null}