import { useRouterState } from '@tanstack/react-router'
import { FileText, Folder, Search } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useKnowledgeSearch, useKnowledgeTree } from '@/hooks/use-knowledge'
import { cn } from '@/lib/utils'
import { useKnowledgeStore } from '@/stores/knowledge'
import type { KnowledgeSearchHit, KnowledgeTreeEntry } from '@/types/knowledge'
interface SearchModalProps {
/** When opened externally (e.g. from chat message action) */
forceOpen?: boolean
/** When set, modal acts as a file picker for moving a chat message */
selectedMessageText?: string | null
/** Called in moveMessage mode when a file is selected */
onMoveToFile?: (path: string) => void
/** Called in moveMessage mode when a directory is selected */
onMoveToDir?: (dir: string) => void
/** Called when the modal closes */
onClose?: () => void
}
/** A single item that can appear in the results list (search hit, file, or directory). */
interface ResultItem {
path: string
name: string
isDir: boolean
}
const MAX_RECENT_FILES = 15
export function SearchModal({
forceOpen,
selectedMessageText,
onMoveToFile,
onMoveToDir,
onClose,
}: SearchModalProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [focusedIndex, setFocusedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const openFile = useKnowledgeStore((s) => s.openFile)
const searchMutation = useKnowledgeSearch()
const { data: treeEntries } = useKnowledgeTree()
const isMoveMode = selectedMessageText != null
const [searchResults, setSearchResults] = useState<KnowledgeSearchHit[]>([])
// ── Build the display list ──────────────────────────────────
const recentFiles: ResultItem[] = (treeEntries ?? [])
.filter((e: KnowledgeTreeEntry) => !e.is_dir)
.slice(0, MAX_RECENT_FILES)
.map((e: KnowledgeTreeEntry) => ({ path: e.name, name: e.name, isDir: false }))
const treeDirs: ResultItem[] = (treeEntries ?? [])
.filter((e: KnowledgeTreeEntry) => e.is_dir)
.map((e: KnowledgeTreeEntry) => ({ path: e.name, name: e.name, isDir: true }))
const displayItems: ResultItem[] = (() => {
if (query.trim() && searchResults.length > 0) {
return searchResults.map((h: KnowledgeSearchHit) => ({
path: h.path,
name: h.name,
isDir: false,
}))
}
// In move mode, show directories first then files
if (isMoveMode) {
return [...treeDirs, ...recentFiles]
}
// Normal empty state: just show recent files
return recentFiles
})()
// ── Global ⌘K / ⌘P listener (M5: pathname via ref) ───────────
const router = useRouterState()
const pathnameRef = useRef(router.location.pathname)
pathnameRef.current = router.location.pathname
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!pathnameRef.current.startsWith('/knowledge')) return
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'p')) {
e.preventDefault()
e.stopPropagation()
setOpen(true)
setQuery('')
setFocusedIndex(0)
setSearchResults([])
}
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [])
// ── Focus input when opening ────────────────────────────────
useEffect(() => {
if (open) {
// Small delay so the DOM mounts before focusing
const id = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(id)
}
}, [open])
// ── Respond to forceOpen prop ───────────────────────────────
useEffect(() => {
if (forceOpen) {
setOpen(true)
setQuery('')
setFocusedIndex(0)
setSearchResults([])
}
}, [forceOpen])
// ── Auto-search on query change (debounced) ─────────────────
useEffect(() => {
if (!open) return
if (!query.trim()) {
setSearchResults([])
setFocusedIndex(0)
return
}
const timer = setTimeout(() => {
searchMutation.mutate(
{ query, limit: 20 },
{
onSuccess: (data) => {
setSearchResults(data.results)
setFocusedIndex(0)
},
onError: () => {
setSearchResults([])
},
},
)
}, 150)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, open, searchMutation.mutate])
// ── Close handler ───────────────────────────────────────────
const close = useCallback(() => {
setOpen(false)
onClose?.()
}, [onClose])
// ── Select handler ──────────────────────────────────────────
const handleSelect = useCallback(
(item: ResultItem) => {
if (isMoveMode) {
if (item.isDir && onMoveToDir) {
onMoveToDir(item.path)
} else if (!item.isDir && onMoveToFile) {
onMoveToFile(item.path)
}
} else {
openFile(item.path)
}
close()
},
[isMoveMode, onMoveToDir, onMoveToFile, openFile, close],
)
// ── Keyboard navigation inside modal ────────────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const count = Math.max(displayItems.length, 1)
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setFocusedIndex((i) => (i + 1) % count)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusedIndex((i) => (i - 1 + count) % count)
return
}
if (e.key === 'Enter') {
e.preventDefault()
const item = displayItems[focusedIndex]
if (item) {
handleSelect(item)
}
return
}
},
[displayItems, focusedIndex, close, handleSelect],
)
// ── Scroll focused item into view ───────────────────────────
useEffect(() => {
if (!listRef.current) return
const focusedEl = listRef.current.children[focusedIndex] as HTMLElement | undefined
focusedEl?.scrollIntoView({ block: 'nearest' })
}, [focusedIndex])
// ── Early return when closed ────────────────────────────────
if (!open) return null
const hasQuery = query.trim().length > 0
const isSearching = searchMutation.isPending && hasQuery
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50" onClick={close} />
{/* Dialog */}
<div className="relative w-full max-w-lg bg-background border rounded-lg shadow-lg overflow-hidden">
{/* Search input row */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b">
<Search className="h-4 w-4 text-muted-foreground shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isMoveMode ? t('knowledge.searchOrSelectDestination') : t('knowledge.searchFiles')
}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<kbd className="text-2xs text-muted-foreground border rounded px-1.5 py-0.5 font-mono">
ESC
</kbd>
</div>
{/* Results list */}
<ul ref={listRef} className="max-h-80 overflow-y-auto p-1">
{displayItems.length > 0 ? (
displayItems.map((item, i) => (
<li
key={item.path + (item.isDir ? '-dir' : '-file')}
className={cn(
'flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer select-none rounded-md transition-colors',
i === focusedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
onClick={() => handleSelect(item)}
onMouseEnter={() => setFocusedIndex(i)}
>
{item.isDir ? (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="font-medium truncate">{item.name.replace(/\.md$/, '')}</span>
{item.path.includes('/') && (
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{item.path.replace(/\/[^/]+$/, '')}
</span>
)}
{item.isDir && (
<span className="ml-auto text-xs text-muted-foreground shrink-0">dir</span>
)}
</li>
))
) : hasQuery ? (
<li className="px-4 py-6 text-sm text-muted-foreground text-center">
{isSearching ? t('knowledge.searching') : t('knowledge.noSearchResults')}
</li>
) : (
<li className="px-4 py-6 text-sm text-muted-foreground text-center">
{isMoveMode ? 'Select a file or directory' : 'Type to search files'}
</li>
)}
</ul>
{/* Footer hint */}
<div className="flex items-center justify-between border-t px-3 py-1.5 text-2xs text-muted-foreground">
<span>
<kbd className="font-mono border rounded px-1">↑↓</kbd> navigate
{' · '}
<kbd className="font-mono border rounded px-1">↵</kbd> select
</span>
<span>
<kbd className="font-mono border rounded px-1">⌘K</kbd> to open
</span>
</div>
</div>
</div>
)
}