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}