feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
623
product-source/hololake-platform/src/components/SearchPanel.tsx
Normal file
623
product-source/hololake-platform/src/components/SearchPanel.tsx
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
import { createElement, forwardRef, useRef, useEffect, useCallback, useLayoutEffect, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SearchResult, VaultEntry } from '../types'
|
||||
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
|
||||
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { formatSearchSubtitle } from '../utils/noteListHelpers'
|
||||
import type { DateDisplayFormat } from '../utils/dateDisplay'
|
||||
import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { WorkspaceInitialsBadge } from './WorkspaceInitialsBadge'
|
||||
import { useDateDisplayFormat } from '../hooks/useAppPreferences'
|
||||
|
||||
interface SearchPanelProps {
|
||||
open: boolean
|
||||
vaultPath: string
|
||||
entries: VaultEntry[]
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SearchKeyboardAction = 'close' | 'next' | 'previous' | 'select'
|
||||
// WKWebView can emit duplicate non-text navigation keydowns around native key injection.
|
||||
const NATIVE_KEYDOWN_DUPLICATE_WINDOW_MS = 500
|
||||
const handledSearchKeyboardEvents = new WeakSet<Event>()
|
||||
|
||||
interface SearchKeyboardEvent {
|
||||
key: string
|
||||
nativeEvent?: Event
|
||||
preventDefault: () => void
|
||||
repeat?: boolean
|
||||
stopImmediatePropagation?: () => void
|
||||
stopPropagation?: () => void
|
||||
timeStamp?: number
|
||||
}
|
||||
|
||||
interface SearchKeydownRecord {
|
||||
key: string
|
||||
timeStamp: number
|
||||
}
|
||||
|
||||
interface SearchKeyboardActionContext {
|
||||
handleSelect: (result: SearchResult) => void
|
||||
onClose: () => void
|
||||
resultsRef: React.MutableRefObject<SearchResult[]>
|
||||
selectedIndexRef: React.MutableRefObject<number>
|
||||
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>
|
||||
}
|
||||
|
||||
function resolveSearchKeyboardAction(key: string): SearchKeyboardAction | null {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
return 'close'
|
||||
case 'ArrowDown':
|
||||
return 'next'
|
||||
case 'ArrowUp':
|
||||
return 'previous'
|
||||
case 'Enter':
|
||||
return 'select'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nextSearchSelectionIndex(
|
||||
action: Extract<SearchKeyboardAction, 'next' | 'previous'>,
|
||||
currentIndex: number,
|
||||
resultCount: number,
|
||||
): number {
|
||||
if (resultCount <= 0) return 0
|
||||
if (action === 'next') return Math.min(currentIndex + 1, resultCount - 1)
|
||||
return Math.max(currentIndex - 1, 0)
|
||||
}
|
||||
|
||||
function shouldHandleKeydown(
|
||||
event: SearchKeyboardEvent,
|
||||
pressedKeys: Set<string>,
|
||||
handledEvents: WeakSet<Event>,
|
||||
recentKeydownRef: React.MutableRefObject<SearchKeydownRecord | null>,
|
||||
): boolean {
|
||||
const eventIdentity = resolveSearchKeyboardEventIdentity(event)
|
||||
if (eventIdentity) {
|
||||
if (handledEvents.has(eventIdentity)) return false
|
||||
handledEvents.add(eventIdentity)
|
||||
}
|
||||
|
||||
if (isDuplicateNativeKeydown(event, recentKeydownRef.current)) {
|
||||
return false
|
||||
}
|
||||
|
||||
rememberSearchKeydown(event, recentKeydownRef)
|
||||
if (event.repeat) return true
|
||||
if (pressedKeys.has(event.key)) return false
|
||||
|
||||
pressedKeys.add(event.key)
|
||||
return true
|
||||
}
|
||||
|
||||
function isDuplicateNativeKeydown(
|
||||
event: SearchKeyboardEvent,
|
||||
previous: SearchKeydownRecord | null,
|
||||
): boolean {
|
||||
const timeStamp = resolveSearchKeyboardEventTimestamp(event)
|
||||
if (!previous || timeStamp === null || previous.key !== event.key) return false
|
||||
|
||||
const elapsedMs = timeStamp - previous.timeStamp
|
||||
return elapsedMs >= 0 && elapsedMs <= NATIVE_KEYDOWN_DUPLICATE_WINDOW_MS
|
||||
}
|
||||
|
||||
function rememberSearchKeydown(
|
||||
event: SearchKeyboardEvent,
|
||||
recentKeydownRef: React.MutableRefObject<SearchKeydownRecord | null>,
|
||||
) {
|
||||
const timeStamp = resolveSearchKeyboardEventTimestamp(event)
|
||||
if (timeStamp !== null) recentKeydownRef.current = { key: event.key, timeStamp }
|
||||
}
|
||||
|
||||
function resolveSearchKeyboardEventTimestamp(event: SearchKeyboardEvent): number | null {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') return performance.now()
|
||||
|
||||
const { timeStamp } = event
|
||||
return typeof timeStamp === 'number' && Number.isFinite(timeStamp) ? timeStamp : null
|
||||
}
|
||||
|
||||
function resolveSearchKeyboardEventIdentity(event: SearchKeyboardEvent): Event | null {
|
||||
if (event.nativeEvent instanceof Event) return event.nativeEvent
|
||||
if (event instanceof Event) return event
|
||||
return null
|
||||
}
|
||||
|
||||
function applySearchSelection(
|
||||
action: Extract<SearchKeyboardAction, 'next' | 'previous'>,
|
||||
resultsRef: React.MutableRefObject<SearchResult[]>,
|
||||
selectedIndexRef: React.MutableRefObject<number>,
|
||||
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>,
|
||||
) {
|
||||
const nextIndex = nextSearchSelectionIndex(action, selectedIndexRef.current, resultsRef.current.length)
|
||||
selectedIndexRef.current = nextIndex
|
||||
setSelectedIndex(nextIndex)
|
||||
}
|
||||
|
||||
function performSearchKeyboardAction(action: SearchKeyboardAction, context: SearchKeyboardActionContext) {
|
||||
if (action === 'close') {
|
||||
context.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'select') {
|
||||
const result = context.resultsRef.current[context.selectedIndexRef.current]
|
||||
if (result) context.handleSelect(result)
|
||||
return
|
||||
}
|
||||
|
||||
applySearchSelection(action, context.resultsRef, context.selectedIndexRef, context.setSelectedIndex)
|
||||
}
|
||||
|
||||
function useSearchKeyboardDocumentListeners({
|
||||
handleKeyDown,
|
||||
handleKeyUp,
|
||||
open,
|
||||
pressedKeysRef,
|
||||
}: {
|
||||
handleKeyDown: (event: KeyboardEvent) => void
|
||||
handleKeyUp: (event: KeyboardEvent) => void
|
||||
open: boolean
|
||||
pressedKeysRef: React.MutableRefObject<Set<string>>
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const pressedKeys = pressedKeysRef.current
|
||||
if (!open) {
|
||||
pressedKeys.clear()
|
||||
return
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keyup', handleKeyUp, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keyup', handleKeyUp, true)
|
||||
pressedKeys.clear()
|
||||
}
|
||||
}, [handleKeyDown, handleKeyUp, open, pressedKeysRef])
|
||||
}
|
||||
|
||||
function searchVaultPathsForEntries(entries: VaultEntry[], fallbackVaultPath: string): string | string[] {
|
||||
const paths = entries
|
||||
.map((entry) => entry.workspace?.path)
|
||||
.filter((path): path is string => !!path)
|
||||
return paths.length > 0 ? [...new Set(paths)] : fallbackVaultPath
|
||||
}
|
||||
|
||||
function shouldShowWorkspace(entries: VaultEntry[]): boolean {
|
||||
return new Set(entries.map((entry) => entry.workspace?.alias).filter(Boolean)).size > 1
|
||||
}
|
||||
|
||||
function useSearchSelectionRefs(results: SearchResult[], selectedIndex: number) {
|
||||
const resultsRef = useRef(results)
|
||||
const selectedIndexRef = useRef(selectedIndex)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
resultsRef.current = results
|
||||
selectedIndexRef.current = selectedIndex
|
||||
}, [results, selectedIndex])
|
||||
|
||||
return { resultsRef, selectedIndexRef }
|
||||
}
|
||||
|
||||
function useSearchEntryData(entries: VaultEntry[]) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const entryLookup = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [entries])
|
||||
const showWorkspace = useMemo(() => shouldShowWorkspace(entries), [entries])
|
||||
|
||||
return { entryLookup, showWorkspace, typeEntryMap }
|
||||
}
|
||||
|
||||
function useSearchKeyboard({
|
||||
open,
|
||||
onClose,
|
||||
handleSelect,
|
||||
resultsRef,
|
||||
selectedIndexRef,
|
||||
setSelectedIndex,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
handleSelect: (result: SearchResult) => void
|
||||
resultsRef: React.MutableRefObject<SearchResult[]>
|
||||
selectedIndexRef: React.MutableRefObject<number>
|
||||
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>
|
||||
}) {
|
||||
const pressedKeysRef = useRef(new Set<string>())
|
||||
const recentKeydownRef = useRef<SearchKeydownRecord | null>(null)
|
||||
const handleKeyDown = useCallback((e: SearchKeyboardEvent) => {
|
||||
const action = resolveSearchKeyboardAction(e.key)
|
||||
if (!action) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation?.()
|
||||
e.stopPropagation?.()
|
||||
if (!shouldHandleKeydown(e, pressedKeysRef.current, handledSearchKeyboardEvents, recentKeydownRef)) return
|
||||
|
||||
performSearchKeyboardAction(action, { handleSelect, onClose, resultsRef, selectedIndexRef, setSelectedIndex })
|
||||
}, [handleSelect, onClose, resultsRef, selectedIndexRef, setSelectedIndex])
|
||||
|
||||
const handleKeyUp = useCallback((e: { key: string }) => {
|
||||
if (resolveSearchKeyboardAction(e.key)) pressedKeysRef.current.delete(e.key)
|
||||
}, [])
|
||||
|
||||
useSearchKeyboardDocumentListeners({ handleKeyDown, handleKeyUp, open, pressedKeysRef })
|
||||
}
|
||||
|
||||
function useSearchPanelController({ open, vaultPath, entries, onSelectNote, onClose }: SearchPanelProps) {
|
||||
const searchVaultPaths = useMemo(() => searchVaultPathsForEntries(entries, vaultPath), [entries, vaultPath])
|
||||
const {
|
||||
query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs,
|
||||
} = useUnifiedSearch(searchVaultPaths, open)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const { resultsRef, selectedIndexRef } = useSearchSelectionRefs(results, selectedIndex)
|
||||
|
||||
useEffect(() => {
|
||||
scrollSelectedHTMLChildIntoView(listRef.current, selectedIndex)
|
||||
}, [selectedIndex])
|
||||
|
||||
const handleSelect = useCallback((result: SearchResult) => {
|
||||
const entry = entries.find(e => e.path === result.path)
|
||||
if (entry) {
|
||||
onSelectNote(entry)
|
||||
onClose()
|
||||
}
|
||||
}, [entries, onSelectNote, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}, [open])
|
||||
|
||||
useSearchKeyboard({
|
||||
open,
|
||||
onClose,
|
||||
handleSelect,
|
||||
resultsRef,
|
||||
selectedIndexRef,
|
||||
setSelectedIndex,
|
||||
})
|
||||
const entryData = useSearchEntryData(entries)
|
||||
|
||||
return {
|
||||
elapsedMs,
|
||||
handleSelect,
|
||||
inputRef,
|
||||
listRef,
|
||||
loading,
|
||||
query,
|
||||
results,
|
||||
selectedIndex,
|
||||
setQuery,
|
||||
setSelectedIndex,
|
||||
...entryData,
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchPanel({
|
||||
open,
|
||||
vaultPath,
|
||||
entries,
|
||||
onSelectNote,
|
||||
onClose,
|
||||
}: SearchPanelProps) {
|
||||
const dateDisplayFormat = useDateDisplayFormat()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
elapsedMs,
|
||||
entryLookup,
|
||||
handleSelect,
|
||||
inputRef,
|
||||
listRef,
|
||||
loading,
|
||||
query,
|
||||
results,
|
||||
selectedIndex,
|
||||
setQuery,
|
||||
setSelectedIndex,
|
||||
showWorkspace,
|
||||
typeEntryMap,
|
||||
} = useSearchPanelController({ open, vaultPath, entries, onSelectNote, onClose })
|
||||
const handleResultHover = useCallback((index: number, event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (shouldApplySearchResultHover(event)) setSelectedIndex(index)
|
||||
}, [setSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
|
||||
const handleRootClick = (event: MouseEvent) => {
|
||||
if (event.target === root) onClose()
|
||||
}
|
||||
|
||||
root.addEventListener('click', handleRootClick)
|
||||
return () => root.removeEventListener('click', handleRootClick)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close search"
|
||||
className="absolute inset-0 z-0 cursor-default border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex w-[540px] max-w-[90vw] max-h-[480px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
>
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
query={query}
|
||||
loading={loading}
|
||||
onChange={setQuery}
|
||||
/>
|
||||
<SearchContent
|
||||
query={query}
|
||||
results={results}
|
||||
selectedIndex={selectedIndex}
|
||||
loading={loading}
|
||||
elapsedMs={elapsedMs}
|
||||
entryLookup={entryLookup}
|
||||
typeEntryMap={typeEntryMap}
|
||||
showWorkspace={showWorkspace}
|
||||
dateDisplayFormat={dateDisplayFormat}
|
||||
listRef={listRef}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleResultHover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
query: string
|
||||
loading: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
function SearchInput({ query, loading, onChange }, ref) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
ref={ref}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Search in all notes..."
|
||||
value={query}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
{loading && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
|
||||
data-testid="search-spinner"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface SearchContentProps {
|
||||
query: string
|
||||
results: SearchResult[]
|
||||
selectedIndex: number
|
||||
loading: boolean
|
||||
elapsedMs: number | null
|
||||
entryLookup: Map<string, VaultEntry>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
showWorkspace: boolean
|
||||
dateDisplayFormat: DateDisplayFormat
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onSelect: (result: SearchResult) => void
|
||||
onHover: (index: number, event: React.MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
interface SearchResultRowProps {
|
||||
result: SearchResult
|
||||
entry: VaultEntry | undefined
|
||||
selected: boolean
|
||||
index: number
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
showWorkspace: boolean
|
||||
dateDisplayFormat: DateDisplayFormat
|
||||
onSelect: (result: SearchResult) => void
|
||||
onHover: (index: number, event: React.MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
interface SearchResultPresentation {
|
||||
TypeIcon: ReturnType<typeof getTypeIcon>
|
||||
icon?: string | null
|
||||
noteType: string | null
|
||||
subtitle: string | null
|
||||
title: string
|
||||
typeColor?: string
|
||||
workspace: VaultEntry['workspace'] | null
|
||||
}
|
||||
|
||||
function resolveSearchResultPresentation({
|
||||
result,
|
||||
entry,
|
||||
typeEntryMap,
|
||||
showWorkspace,
|
||||
dateDisplayFormat,
|
||||
}: Pick<SearchResultRowProps, 'result' | 'entry' | 'typeEntryMap' | 'showWorkspace' | 'dateDisplayFormat'>): SearchResultPresentation {
|
||||
const isA = entry?.isA ?? result.noteType
|
||||
const noteType = isA || null
|
||||
const typeEntry = typeEntryMap[isA ?? '']
|
||||
|
||||
return {
|
||||
TypeIcon: getTypeIcon(isA ?? null, typeEntry?.icon),
|
||||
icon: entry?.icon,
|
||||
noteType,
|
||||
subtitle: entry ? formatSearchSubtitle(entry, dateDisplayFormat) : null,
|
||||
title: entry?.title ?? result.title,
|
||||
typeColor: resolveSearchResultTypeColor(noteType, isA, typeEntry),
|
||||
workspace: resolveSearchResultWorkspace(showWorkspace, entry),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSearchResultTypeColor(
|
||||
noteType: string | null,
|
||||
isA: string | null,
|
||||
typeEntry: VaultEntry | undefined,
|
||||
): string | undefined {
|
||||
return noteType ? getTypeColor(isA, typeEntry?.color) : undefined
|
||||
}
|
||||
|
||||
function resolveSearchResultWorkspace(showWorkspace: boolean, entry: VaultEntry | undefined): VaultEntry['workspace'] | null {
|
||||
return showWorkspace ? entry?.workspace ?? null : null
|
||||
}
|
||||
|
||||
function SearchResultRow({
|
||||
result, entry, selected, index, typeEntryMap, showWorkspace, dateDisplayFormat, onSelect, onHover,
|
||||
}: SearchResultRowProps) {
|
||||
const presentation = resolveSearchResultPresentation({
|
||||
result,
|
||||
entry,
|
||||
typeEntryMap,
|
||||
showWorkspace,
|
||||
dateDisplayFormat,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"w-full cursor-pointer border-0 bg-transparent px-4 py-2.5 text-left transition-colors",
|
||||
selected ? "bg-accent" : "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseMove={(event) => onHover(index, event)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{createElement(presentation.TypeIcon, {
|
||||
width: 14,
|
||||
height: 14,
|
||||
className: 'shrink-0',
|
||||
style: { color: presentation.typeColor ?? 'var(--muted-foreground)' },
|
||||
})}
|
||||
<SearchResultTitle icon={presentation.icon} title={presentation.title} />
|
||||
<SearchResultTypeLabel noteType={presentation.noteType} />
|
||||
<WorkspaceInitialsBadge workspace={presentation.workspace} testId="search-result-workspace-badge" />
|
||||
</div>
|
||||
<SearchResultSubtitle subtitle={presentation.subtitle} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultTitle({ icon, title }: { icon?: string | null; title: string }) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
<NoteTitleIcon icon={icon} size={14} className="mr-1" />
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultTypeLabel({ noteType }: { noteType: string | null }) {
|
||||
return noteType ? <span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span> : null
|
||||
}
|
||||
|
||||
function SearchResultSubtitle({ subtitle }: { subtitle: string | null }) {
|
||||
return subtitle ? <p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">{subtitle}</p> : null
|
||||
}
|
||||
|
||||
function SearchIdleMessage() {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">Enter to open · Esc to close</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchLoadingMessage() {
|
||||
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">Searching...</div>
|
||||
}
|
||||
|
||||
function SearchNoResultsMessage() {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultsHeader({ count, elapsedMs }: { count: number; elapsedMs: number | null }) {
|
||||
return (
|
||||
<div className="border-b border-border/50 px-4 py-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{count} result{count !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchContent({
|
||||
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, showWorkspace, dateDisplayFormat, listRef, onSelect, onHover,
|
||||
}: SearchContentProps) {
|
||||
const hasQuery = query.trim().length > 0
|
||||
const hasResults = results.length > 0
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!hasQuery && <SearchIdleMessage />}
|
||||
{hasQuery && !hasResults && loading && <SearchLoadingMessage />}
|
||||
{hasQuery && !hasResults && !loading && <SearchNoResultsMessage />}
|
||||
{hasResults && (
|
||||
<>
|
||||
<SearchResultsHeader count={results.length} elapsedMs={elapsedMs} />
|
||||
<div ref={listRef} role="listbox" aria-label="Search results">
|
||||
{results.map((result, i) => (
|
||||
<SearchResultRow
|
||||
key={result.path}
|
||||
result={result}
|
||||
entry={entryLookup.get(result.path)}
|
||||
selected={i === selectedIndex}
|
||||
index={i}
|
||||
typeEntryMap={typeEntryMap}
|
||||
showWorkspace={showWorkspace}
|
||||
dateDisplayFormat={dateDisplayFormat}
|
||||
onSelect={onSelect}
|
||||
onHover={onHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function shouldApplySearchResultHover(event: React.MouseEvent<HTMLDivElement>): boolean {
|
||||
return event.movementX !== 0 || event.movementY !== 0
|
||||
}
|
||||
Loading…
Reference in a new issue