feat: add @-triggered file search popup with directory navigation

- Input @ in composer to open file search popup showing project root
- Filter files/directories by typing, navigate with keyboard or mouse
- Click directory to enter subdirectory, type / to navigate deeper
- Enter selects item and inserts path text at cursor, popup closes
- Parse filter path (e.g. @src/components/) to auto-navigate and search
- Desktop: filesystem browse/search API supports includeFiles and search
- Web: directory tree browser with real-time search filtering
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 19:05:01 +08:00
parent da3d51b006
commit ad4ba88a62
5 changed files with 458 additions and 18 deletions

View File

@ -1,18 +1,30 @@
import { api } from './client'
type DirEntry = {
name: string
path: string
isDirectory: boolean
}
type BrowseResult = {
currentPath: string
parentPath: string
entries: Array<{
name: string
path: string
isDirectory: boolean
}>
entries: DirEntry[]
query?: string
}
export const filesystemApi = {
browse(path?: string) {
const params = path ? `?path=${encodeURIComponent(path)}` : ''
return api.get<BrowseResult>(`/api/filesystem/browse${params}`)
browse(path?: string, options?: { includeFiles?: boolean }) {
const q = new URLSearchParams()
if (path) q.set('path', path)
if (options?.includeFiles) q.set('includeFiles', 'true')
const qs = q.toString()
return api.get<BrowseResult>(`/api/filesystem/browse${qs ? `?${qs}` : ''}`)
},
search(query: string, cwd?: string) {
const q = new URLSearchParams({ search: query, maxResults: '200' })
if (cwd) q.set('path', cwd)
return api.get<BrowseResult>(`/api/filesystem/browse?${q}`)
},
}

View File

@ -7,6 +7,7 @@ import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { ProjectContextChip } from '../shared/ProjectContextChip'
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
import {
FALLBACK_SLASH_COMMANDS,
findSlashTrigger,
@ -29,12 +30,16 @@ export function ChatInput() {
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [fileSearchOpen, setFileSearchOpen] = useState(false)
const [atFilter, setAtFilter] = useState('')
const [atCursorPos, setAtCursorPos] = useState(-1)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const { sendMessage, chatState, stopGeneration, slashCommands } = useChatStore()
const activeSessionId = useSessionStore((state) => state.activeSessionId)
@ -91,6 +96,23 @@ export function ChatInput() {
return () => document.removeEventListener('mousedown', handleClick)
}, [slashMenuOpen])
useEffect(() => {
if (!fileSearchOpen) return
const handleClick = (event: MouseEvent) => {
const menu = document.getElementById('file-search-menu')
if (
menu &&
!menu.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setFileSearchOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [fileSearchOpen])
const filteredCommands = useMemo(() => {
const source = slashCommands.length > 0 ? slashCommands : FALLBACK_SLASH_COMMANDS
if (!slashFilter) return source
@ -118,14 +140,51 @@ export function ChatInput() {
return
}
setFileSearchOpen(false)
setSlashFilter(token.filter)
setSlashMenuOpen(true)
}, [])
// Detect @ trigger (file search)
const detectAtTrigger = useCallback((value: string, cursorPos: number) => {
const textBeforeCursor = value.slice(0, cursorPos)
let pos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
const ch = textBeforeCursor[i]!
if (ch === '@') {
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
pos = i
break
}
break
}
if (/\s/.test(ch)) {
break
}
}
if (pos < 0) {
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
return
}
// Extract filter text after @
const filter = textBeforeCursor.slice(pos + 1)
setAtFilter(filter)
setAtCursorPos(cursorPos)
setSlashMenuOpen(false)
setFileSearchOpen(true)
}, [])
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
const cursorPos = event.target.selectionStart ?? value.length
setInput(value)
detectSlashTrigger(value, event.target.selectionStart ?? value.length)
detectSlashTrigger(value, cursorPos)
detectAtTrigger(value, cursorPos)
}
const selectSlashCommand = useCallback((command: string) => {
@ -159,6 +218,24 @@ export function ChatInput() {
}
const handleKeyDown = (event: React.KeyboardEvent) => {
// Route file search navigation keys to FileSearchMenu
if (fileSearchOpen) {
const key = event.key
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
event.preventDefault()
if (key === 'Escape') {
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
return
}
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
return
}
// Other keys (typing) should go to the textarea - let it propagate
return
}
if (slashMenuOpen && filteredCommands.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
@ -287,6 +364,29 @@ export function ChatInput() {
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
{fileSearchOpen && (
<FileSearchMenu
ref={fileSearchRef}
cwd={gitInfo?.workDir || activeSession?.workDir || ''}
filter={atFilter}
onSelect={(_path, name) => {
if (atCursorPos >= 0) {
// Insert name at cursor position, replacing filter text
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
const newCursorPos = atCursorPos + name.length
setInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void textareaRef.current?.focus()
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}
}}
/>
)}
{slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}

View File

@ -0,0 +1,196 @@
import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react'
import { filesystemApi } from '../../api/filesystem'
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 [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)]">Searching...</div>
) : entries.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
{filter ? 'No files match' : 'No files in this directory'}
</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>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>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>close</span>
</div>
</div>
)
})
FileSearchMenu.displayName = 'FileSearchMenu'

View File

@ -6,6 +6,7 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
import {
FALLBACK_SLASH_COMMANDS,
findSlashToken,
@ -30,12 +31,16 @@ export function EmptySession() {
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [fileSearchOpen, setFileSearchOpen] = useState(false)
const [atFilter, setAtFilter] = useState('')
const [atCursorPos, setAtCursorPos] = useState(-1)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const createSession = useSessionStore((state) => state.createSession)
const sendMessage = useChatStore((state) => state.sendMessage)
@ -74,6 +79,23 @@ export function EmptySession() {
return () => document.removeEventListener('mousedown', handleClick)
}, [slashMenuOpen])
useEffect(() => {
if (!fileSearchOpen) return
const handleClick = (event: MouseEvent) => {
const menu = document.getElementById('file-search-menu')
if (
menu &&
!menu.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setFileSearchOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [fileSearchOpen])
const filteredCommands = FALLBACK_SLASH_COMMANDS.filter((command) => {
if (!slashFilter) return true
const lower = slashFilter.toLowerCase()
@ -123,13 +145,57 @@ export function EmptySession() {
const token = findSlashToken(value, cursorPos)
if (!token) {
setSlashMenuOpen(false)
return
} else {
setSlashFilter(token.filter)
setSlashMenuOpen(true)
}
// Detect @ trigger for file search
const textBeforeCursor = value.slice(0, cursorPos)
let pos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
const ch = textBeforeCursor[i]!
if (ch === '@') {
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
pos = i
break
}
break
}
if (/\s/.test(ch)) {
break
}
}
if (pos < 0) {
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
} else {
setAtFilter(textBeforeCursor.slice(pos + 1))
setAtCursorPos(cursorPos)
setSlashMenuOpen(false)
setFileSearchOpen(true)
}
setSlashFilter(token.filter)
setSlashMenuOpen(true)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
// Route file search navigation keys to FileSearchMenu
if (fileSearchOpen) {
const key = event.key
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
event.preventDefault()
if (key === 'Escape') {
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
return
}
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
return
}
return
}
if (slashMenuOpen && filteredCommands.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
@ -284,6 +350,28 @@ export function EmptySession() {
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
{fileSearchOpen && (
<FileSearchMenu
ref={fileSearchRef}
cwd={workDir || ''}
filter={atFilter}
onSelect={(_path, name) => {
if (atCursorPos >= 0) {
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
const newCursorPos = atCursorPos + name.length
setInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void textareaRef.current?.focus()
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}
}}
/>
)}
{slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}

View File

@ -1,5 +1,6 @@
/**
* Filesystem browser API returns directory listings for the DirectoryPicker component.
* Filesystem browser & search API supports directory browsing and file search
* for the DirectoryPicker component and @-triggered file search popup.
*/
import * as path from 'path'
@ -16,6 +17,9 @@ export async function handleFilesystemRoute(pathname: string, url: URL): Promise
async function handleBrowse(url: URL): Promise<Response> {
const targetPath = url.searchParams.get('path') || process.env.HOME || '/'
const resolvedPath = path.resolve(targetPath)
const searchQuery = url.searchParams.get('search') || ''
const includeFiles = url.searchParams.get('includeFiles') === 'true'
const maxResults = Math.min(parseInt(url.searchParams.get('maxResults') || '200', 10), 200)
try {
const stat = fs.statSync(resolvedPath)
@ -24,19 +28,59 @@ async function handleBrowse(url: URL): Promise<Response> {
}
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true })
const dirs = entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
if (searchQuery) {
// Search mode: filter by filename, include both dirs and files
const query = searchQuery.toLowerCase()
const results = entries
.filter((e) => {
if (e.name.startsWith('.')) return false
if (e.isDirectory()) return e.name.toLowerCase().includes(query)
if (!includeFiles) return false
return e.name.toLowerCase().includes(query)
})
.slice(0, maxResults)
.map((e) => ({
name: e.name,
path: path.join(resolvedPath, e.name),
isDirectory: e.isDirectory(),
}))
.sort((a, b) => {
// Directories first, then alphabetically
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
return a.name.localeCompare(b.name)
})
return json({
currentPath: resolvedPath,
parentPath: path.dirname(resolvedPath),
entries: results,
query: searchQuery,
})
}
// Browse mode: show all directories (and optionally files)
const filtered = entries.filter((e) => {
if (e.name.startsWith('.')) return false
if (e.isDirectory()) return true
return includeFiles
})
const entries_list = filtered
.map((e) => ({
name: e.name,
path: path.join(resolvedPath, e.name),
isDirectory: true,
isDirectory: e.isDirectory(),
}))
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
return a.name.localeCompare(b.name)
})
return json({
currentPath: resolvedPath,
parentPath: path.dirname(resolvedPath),
entries: dirs,
entries: entries_list,
})
} catch (err) {
return json({ error: `Cannot read directory: ${err}`, path: resolvedPath }, 500)