@@ -3,7 +3,7 @@ import { listen, } from "@tauri-apps/api/event";
33import { getCurrentWindow , } from "@tauri-apps/api/window" ;
44import { watch , } from "@tauri-apps/plugin-fs" ;
55import { 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" ;
77import { useCurrentDate , } from "../../hooks/useCurrentDate" ;
88import { useTimezoneCity , } from "../../hooks/useTimezoneCity" ;
99import 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+
4057function 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