import { useRouterState } from '@tanstack/react-router'
import { ArrowRightLeft } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
useDeleteFile,
useKnowledgeFile,
useKnowledgeTree,
useWriteFile,
} from '@/hooks/use-knowledge'
import { cn } from '@/lib/utils'
import { useKnowledgeStore } from '@/stores/knowledge'
import type { KnowledgeTreeEntry } from '@/types/knowledge'
export function MoveModal() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [focusedIndex, setFocusedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const currentFilePath = useKnowledgeStore((s) => s.currentFilePath)
const openFile = useKnowledgeStore((s) => s.openFile)
const { data: treeEntries } = useKnowledgeTree()
const { data: currentContent } = useKnowledgeFile(currentFilePath)
const writeFile = useWriteFile()
const deleteFile = useDeleteFile()
// Extract directories from tree (root level only — user can type paths for deeper dirs)
const allDirs = extractDirectories(treeEntries)
// Also allow manual path input by the user — the query field doubles as a path entry
// If the query looks like a path (contains /), add it as a suggestion
const manualDir = query.trim().startsWith('/')
? query.trim()
: query.trim() && query.includes('/')
? query.trim()
: null
const extraDirs = manualDir && !allDirs.includes(manualDir) ? [manualDir] : []
const allDirsWithManual = [...allDirs, ...extraDirs]
// Filter by query
const filteredDirs = query.trim()
? allDirsWithManual.filter((d) => d.toLowerCase().includes(query.toLowerCase()))
: allDirsWithManual
// Global ⌘M 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 === 'm') {
// Don't open if no file is selected
if (!currentFilePath) return
e.preventDefault()
e.stopPropagation()
setOpen(true)
setQuery('')
setFocusedIndex(0)
}
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [currentFilePath])
// Focus input on open
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
// Reset focusedIndex when filtered list changes
useEffect(() => {
setFocusedIndex(0)
}, [])
const close = useCallback(() => setOpen(false), [])
const handleMove = useCallback(
async (targetDir: string) => {
if (!currentFilePath || currentContent == null) return
const filename = currentFilePath.split('/').pop()!
const newPath = targetDir === '/' ? filename : `${targetDir}/${filename}`
if (newPath === currentFilePath) {
close()
return
}
// Write to new location, then delete old
await writeFile.mutateAsync({ path: newPath, content: currentContent })
await deleteFile.mutateAsync(currentFilePath)
openFile(newPath)
close()
},
[currentFilePath, currentContent, writeFile, deleteFile, openFile, close],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
close()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setFocusedIndex((i) => (i + 1) % Math.max(filteredDirs.length, 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusedIndex(
(i) => (i - 1 + Math.max(filteredDirs.length, 1)) % Math.max(filteredDirs.length, 1),
)
} else if (e.key === 'Enter') {
e.preventDefault()
if (filteredDirs[focusedIndex] !== undefined) {
handleMove(filteredDirs[focusedIndex])
}
}
},
[filteredDirs, focusedIndex, close, handleMove],
)
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
{/* Backdrop — click outside to close */}
<div className="fixed inset-0 bg-black/50" onClick={close} />
<div className="relative w-full max-w-md bg-background border rounded-lg shadow-lg overflow-hidden">
{/* Header with search input */}
<div className="flex items-center gap-2 p-3 border-b">
<ArrowRightLeft className="h-4 w-4 text-muted-foreground shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('knowledge.moveToFolder')}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<kbd className="text-xs text-muted-foreground border rounded px-1.5 py-0.5">ESC</kbd>
</div>
{/* Directory list */}
<ul className="max-h-80 overflow-y-auto p-1">
{filteredDirs.length > 0 ? (
filteredDirs.map((dir, i) => (
<li
key={dir}
aria-selected={i === focusedIndex}
className={cn(
'px-4 py-2.5 text-sm cursor-pointer select-none transition-colors',
i === focusedIndex ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => handleMove(dir)}
onMouseEnter={() => setFocusedIndex(i)}
>
{dir === '/' ? '/' : `${dir}/`}
</li>
))
) : (
<li className="px-4 py-6 text-sm text-muted-foreground text-center">
{t('knowledge.noMatchingFolders')}
</li>
)}
</ul>
</div>
</div>
)
}
/**
* Recursively extract all directories from root-level tree entries.
* Each directory is fetched lazily — sub-directories are discovered as
* the user navigates (not pre-fetched).
* Returns `['/', ...dirPaths]` sorted with underscore-prefixed dirs last.
*/
function extractDirectories(entries?: KnowledgeTreeEntry[]): string[] {
if (!entries) return ['/']
const dirs: string[] = ['/']
for (const entry of entries) {
if (entry.is_dir && !entry.name.startsWith('.') && entry.name !== 'media') {
dirs.push(entry.name)
}
}
// Sort: underscore dirs last, then alphabetical
dirs.sort((a, b) => {
const aUnderscore = a.startsWith('_') ? 1 : 0
const bUnderscore = b.startsWith('_') ? 1 : 0
if (aUnderscore !== bUnderscore) return aUnderscore - bUnderscore
return a.localeCompare(b)
})
return dirs
}