Skip to content

Commit 8690290

Browse files
author
ComputelessComputer
committed
add highlighted matches and arrow key search nav
1 parent 4a829ca commit 8690290

1 file changed

Lines changed: 80 additions & 6 deletions

File tree

src/components/layout/AppLayout.tsx

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { listen, } from "@tauri-apps/api/event";
33
import { getCurrentWindow, } from "@tauri-apps/api/window";
44
import { watch, } from "@tauri-apps/plugin-fs";
55
import { openPath, } from "@tauri-apps/plugin-opener";
6-
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
6+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, } from "react";
77
import { useCurrentDate, } from "../../hooks/useCurrentDate";
88
import { useTimezoneCity, } from "../../hooks/useTimezoneCity";
99
import type { LibraryItem, } from "../../services/library";
@@ -37,6 +37,23 @@ interface GlobalSearchResult {
3737
snippet: string;
3838
}
3939

40+
function renderSearchSnippet(snippet: string,) {
41+
const parts = snippet.split(/(\[[^\]]+\])/,);
42+
return parts.filter(Boolean,).map((part, index,) => {
43+
if (part.startsWith("[",) && part.endsWith("]",) && part.length > 2) {
44+
return (
45+
<mark
46+
key={`h-${index}`}
47+
className="bg-yellow-200 dark:bg-yellow-500/70 text-gray-900 rounded px-0.5"
48+
>
49+
{part.slice(1, -1,)}
50+
</mark>
51+
);
52+
}
53+
return <Fragment key={`t-${index}`}>{part}</Fragment>;
54+
},);
55+
}
56+
4057
function DateHeader({ date, city, }: { date: string; city?: string | null; },) {
4158
const showToday = isToday(date,);
4259

@@ -116,12 +133,14 @@ export default function AppLayout() {
116133
const [globalSearchOpen, setGlobalSearchOpen,] = useState(false,);
117134
const [globalSearchQuery, setGlobalSearchQuery,] = useState("",);
118135
const [globalSearchResults, setGlobalSearchResults,] = useState<GlobalSearchResult[]>([],);
136+
const [globalSearchSelectedIndex, setGlobalSearchSelectedIndex,] = useState(-1,);
119137
const [globalSearchLoading, setGlobalSearchLoading,] = useState(false,);
120138
const [globalSearchError, setGlobalSearchError,] = useState<string | null>(null,);
121139
const cityRef = useRef(currentCity,);
122140
const prevCityRef = useRef(currentCity,);
123141
const todayNoteRef = useRef<DailyNote | null>(null,);
124142
const searchInputRef = useRef<HTMLInputElement>(null,);
143+
const searchResultRefs = useRef<(HTMLButtonElement | null)[]>([],);
125144
useEffect(() => {
126145
todayNoteRef.current = todayNote;
127146
}, [todayNote,],);
@@ -145,10 +164,16 @@ export default function AppLayout() {
145164
setGlobalSearchOpen(false,);
146165
setGlobalSearchQuery("",);
147166
setGlobalSearchResults([],);
167+
setGlobalSearchSelectedIndex(-1,);
148168
setGlobalSearchLoading(false,);
149169
setGlobalSearchError(null,);
150170
}, [],);
151171

172+
const openGlobalSearchResult = useCallback((result: GlobalSearchResult | undefined,) => {
173+
if (!result) return;
174+
openPath(result.path,).catch(console.error,);
175+
}, [],);
176+
152177
// Load configuration and extend FS scope on mount
153178
useEffect(() => {
154179
loadSettings()
@@ -196,14 +221,42 @@ export default function AppLayout() {
196221
openGlobalSearch();
197222
return;
198223
}
224+
225+
if (globalSearchOpen && globalSearchResults.length > 0 && event.key === "ArrowDown") {
226+
event.preventDefault();
227+
setGlobalSearchSelectedIndex((prev,) => (prev + 1) % globalSearchResults.length);
228+
return;
229+
}
230+
231+
if (globalSearchOpen && globalSearchResults.length > 0 && event.key === "ArrowUp") {
232+
event.preventDefault();
233+
setGlobalSearchSelectedIndex((prev,) => (
234+
prev <= 0 ? globalSearchResults.length - 1 : prev - 1
235+
));
236+
return;
237+
}
238+
239+
if (globalSearchOpen && event.key === "Enter" && globalSearchSelectedIndex >= 0) {
240+
event.preventDefault();
241+
openGlobalSearchResult(globalSearchResults[globalSearchSelectedIndex],);
242+
return;
243+
}
244+
199245
if (event.key === "Escape" && globalSearchOpen) {
200246
event.preventDefault();
201247
closeGlobalSearch();
202248
}
203249
};
204250
window.addEventListener("keydown", handleHotkey,);
205251
return () => window.removeEventListener("keydown", handleHotkey,);
206-
}, [closeGlobalSearch, globalSearchOpen, openGlobalSearch,],);
252+
}, [
253+
closeGlobalSearch,
254+
globalSearchOpen,
255+
globalSearchResults,
256+
globalSearchSelectedIndex,
257+
openGlobalSearch,
258+
openGlobalSearchResult,
259+
],);
207260

208261
useEffect(() => {
209262
if (!globalSearchOpen) return;
@@ -218,6 +271,7 @@ export default function AppLayout() {
218271
const query = globalSearchQuery.trim();
219272
if (!query) {
220273
setGlobalSearchResults([],);
274+
setGlobalSearchSelectedIndex(-1,);
221275
setGlobalSearchLoading(false,);
222276
setGlobalSearchError(null,);
223277
return;
@@ -238,12 +292,14 @@ export default function AppLayout() {
238292
},);
239293
if (!cancelled) {
240294
setGlobalSearchResults(results,);
295+
setGlobalSearchSelectedIndex(results.length > 0 ? 0 : -1,);
241296
}
242297
} catch (error) {
243298
if (!cancelled) {
244299
const message = error instanceof Error ? error.message : "Search failed.";
245300
setGlobalSearchError(message,);
246301
setGlobalSearchResults([],);
302+
setGlobalSearchSelectedIndex(-1,);
247303
}
248304
} finally {
249305
if (!cancelled) {
@@ -259,6 +315,14 @@ export default function AppLayout() {
259315
};
260316
}, [globalSearchOpen, globalSearchQuery,],);
261317

318+
useEffect(() => {
319+
if (!globalSearchOpen || globalSearchSelectedIndex < 0) return;
320+
searchResultRefs.current[globalSearchSelectedIndex]?.scrollIntoView({
321+
block: "nearest",
322+
behavior: "smooth",
323+
},);
324+
}, [globalSearchOpen, globalSearchSelectedIndex,],);
325+
262326
useEffect(() => {
263327
const handleFocus = () => setIsWindowFocused(true,);
264328
const handleBlur = () => setIsWindowFocused(false,);
@@ -485,16 +549,26 @@ export default function AppLayout() {
485549
</p>
486550
)}
487551

488-
{globalSearchResults.map((result,) => (
552+
{globalSearchResults.map((result, index,) => (
489553
<button
490554
key={result.path}
491-
className="w-full text-left rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
555+
ref={(element,) => {
556+
searchResultRefs.current[index] = element;
557+
}}
558+
className={`w-full text-left rounded-xl border px-4 py-3 transition-colors ${
559+
globalSearchSelectedIndex === index
560+
? "border-gray-400 dark:border-gray-500 bg-gray-50 dark:bg-gray-800/80"
561+
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-gray-300 dark:hover:border-gray-600"
562+
}`}
563+
onMouseEnter={() => setGlobalSearchSelectedIndex(index,)}
492564
onClick={() => {
493-
openPath(result.path,).catch(console.error,);
565+
openGlobalSearchResult(result,);
494566
}}
495567
>
496568
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{result.title}</p>
497-
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{result.snippet}</p>
569+
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
570+
{renderSearchSnippet(result.snippet,)}
571+
</p>
498572
<p
499573
className="mt-2 text-[10px] text-gray-400 dark:text-gray-500"
500574
style={{ fontFamily: "'IBM Plex Mono', monospace", }}

0 commit comments

Comments
 (0)