mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Add a lightweight custom i18n system supporting English (default) and Chinese, with a language switcher in Settings > General. All 35+ UI components internationalized with ~270 translation keys, including 189 Chinese spinner verbs and server error code mapping.
199 lines
8.0 KiB
TypeScript
199 lines
8.0 KiB
TypeScript
import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react'
|
|
import { filesystemApi } from '../../api/filesystem'
|
|
import { useTranslation } from '../../i18n'
|
|
|
|
type DirEntry = {
|
|
name: string
|
|
path: string
|
|
isDirectory: boolean
|
|
}
|
|
|
|
export type FileSearchMenuHandle = {
|
|
handleKeyDown: (e: KeyboardEvent) => void
|
|
}
|
|
|
|
type Props = {
|
|
cwd: string
|
|
filter?: string
|
|
onSelect: (path: string, relativePath: string) => void
|
|
}
|
|
|
|
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect }, ref) => {
|
|
const t = useTranslation()
|
|
const [entries, setEntries] = useState<DirEntry[]>([])
|
|
const [currentPath, setCurrentPath] = useState(cwd)
|
|
const [loading, setLoading] = useState(false)
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
const currentPathRef = useRef(cwd)
|
|
|
|
// Parse filter: if it contains '/', navigate to that subdir and search the rest
|
|
// Uses currentPathRef as base so nested paths navigate from current depth
|
|
const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => {
|
|
const base = currentPathRef.current
|
|
if (!rawFilter || !rawFilter.includes('/')) {
|
|
return { navigateTo: base, searchQuery: rawFilter }
|
|
}
|
|
const lastSlash = rawFilter.lastIndexOf('/')
|
|
const dirPart = rawFilter.slice(0, lastSlash + 1)
|
|
const searchPart = rawFilter.slice(lastSlash + 1)
|
|
const navigateTo = dirPart === '' ? base : `${base}/${dirPart}`
|
|
return { navigateTo, searchQuery: searchPart }
|
|
}
|
|
|
|
// Load directory entries
|
|
const loadDir = useCallback(async (dirPath: string, searchQuery: string) => {
|
|
setLoading(true)
|
|
// Only update currentPath if actually navigating to a different directory
|
|
if (dirPath !== currentPathRef.current) {
|
|
setCurrentPath(dirPath)
|
|
currentPathRef.current = dirPath
|
|
}
|
|
try {
|
|
if (searchQuery) {
|
|
const result = await filesystemApi.search(searchQuery, dirPath)
|
|
setEntries(result.entries)
|
|
} else {
|
|
const result = await filesystemApi.browse(dirPath, { includeFiles: true })
|
|
setEntries(result.entries)
|
|
}
|
|
setSelectedIndex(0)
|
|
} catch {
|
|
setEntries([])
|
|
}
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
// Initial load: parse filter path and navigate accordingly
|
|
useEffect(() => {
|
|
currentPathRef.current = cwd
|
|
const { navigateTo, searchQuery } = parseFilter(filter)
|
|
void loadDir(navigateTo, searchQuery)
|
|
}, [cwd, filter, loadDir])
|
|
|
|
// Keyboard navigation handler exposed via ref
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setSelectedIndex((prev) => Math.min(prev + 1, entries.length - 1))
|
|
return
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
return
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault()
|
|
if (entries[selectedIndex]) {
|
|
onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name)
|
|
}
|
|
return
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [entries, selectedIndex])
|
|
|
|
useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown])
|
|
|
|
// Scroll selected into view
|
|
useEffect(() => {
|
|
const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`) as HTMLButtonElement | null
|
|
el?.scrollIntoView({ block: 'nearest' })
|
|
}, [selectedIndex])
|
|
|
|
// Build breadcrumb segments from current path relative to cwd
|
|
const breadcrumbs: string[] = []
|
|
if (currentPath !== cwd && currentPath.startsWith(cwd)) {
|
|
const rel = currentPath.slice(cwd.length).replace(/^\//, '')
|
|
if (rel) breadcrumbs.push(...rel.split('/'))
|
|
}
|
|
|
|
const dirs = entries.filter((e) => e.isDirectory)
|
|
const files = entries.filter((e) => !e.isDirectory)
|
|
|
|
return (
|
|
<div
|
|
id="file-search-menu"
|
|
className="absolute left-0 bottom-full mb-2 z-50 w-full min-w-[480px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header with path */}
|
|
<div className="flex items-center gap-1.5 border-b border-[var(--color-border)] px-3 py-2 text-[11px]">
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">folder_open</span>
|
|
<span className="text-[var(--color-text-tertiary)] font-mono">{cwd.split('/').pop() || cwd}</span>
|
|
{breadcrumbs.map((seg, i) => (
|
|
<span key={i} className="flex items-center gap-1">
|
|
<span className="text-[var(--color-text-tertiary)]">/</span>
|
|
<span className="text-[var(--color-text-primary)] font-mono">{seg}</span>
|
|
</span>
|
|
))}
|
|
{loading && (
|
|
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)] animate-spin ml-1">progress_activity</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* File list */}
|
|
<div ref={listRef} className="max-h-[300px] overflow-y-auto py-1">
|
|
{loading && entries.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('fileSearch.searching')}</div>
|
|
) : entries.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
|
|
{filter ? t('fileSearch.noMatch') : t('fileSearch.noFiles')}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Directories */}
|
|
{dirs.map((entry, i) => (
|
|
<button
|
|
key={entry.path}
|
|
data-index={i}
|
|
onClick={() => {
|
|
void loadDir(entry.path, filter)
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(i)}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
|
|
selectedIndex === i ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">folder</span>
|
|
<span className="text-sm text-[var(--color-text-primary)] truncate">{entry.name}</span>
|
|
</button>
|
|
))}
|
|
|
|
{/* Files */}
|
|
{files.map((entry, i) => {
|
|
const idx = dirs.length + i
|
|
return (
|
|
<button
|
|
key={entry.path}
|
|
data-index={idx}
|
|
onClick={() => onSelect(entry.path, entry.name)}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
|
|
selectedIndex === idx ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">description</span>
|
|
<span className="text-sm text-[var(--color-text-primary)] truncate">{entry.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer hint */}
|
|
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
|
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">↑↓</kbd>
|
|
<span>{t('fileSearch.navigate')}</span>
|
|
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
|
|
<span>{t('fileSearch.attach')}</span>
|
|
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
|
|
<span>{t('fileSearch.close')}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
FileSearchMenu.displayName = 'FileSearchMenu'
|