From ad4ba88a622966b7bc40e70648db9b90444480b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 19:05:01 +0800 Subject: [PATCH] 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 --- desktop/src/api/filesystem.ts | 28 ++- desktop/src/components/chat/ChatInput.tsx | 102 ++++++++- .../src/components/chat/FileSearchMenu.tsx | 196 ++++++++++++++++++ desktop/src/pages/EmptySession.tsx | 94 ++++++++- src/server/api/filesystem.ts | 56 ++++- 5 files changed, 458 insertions(+), 18 deletions(-) create mode 100644 desktop/src/components/chat/FileSearchMenu.tsx diff --git a/desktop/src/api/filesystem.ts b/desktop/src/api/filesystem.ts index 7d1e61ee..7ef57735 100644 --- a/desktop/src/api/filesystem.ts +++ b/desktop/src/api/filesystem.ts @@ -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(`/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(`/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(`/api/filesystem/browse?${q}`) }, } diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 47757088..1f3cbb3d 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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([]) 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(null) const fileInputRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) + const fileSearchRef = useRef(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) => { 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 && ( + { + 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 && (
void +} + +type Props = { + cwd: string + filter?: string + onSelect: (path: string, relativePath: string) => void +} + +export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect }, ref) => { + const [entries, setEntries] = useState([]) + const [currentPath, setCurrentPath] = useState(cwd) + const [loading, setLoading] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(0) + const listRef = useRef(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 ( +
e.stopPropagation()} + > + {/* Header with path */} +
+ folder_open + {cwd.split('/').pop() || cwd} + {breadcrumbs.map((seg, i) => ( + + / + {seg} + + ))} + {loading && ( + progress_activity + )} +
+ + {/* File list */} +
+ {loading && entries.length === 0 ? ( +
Searching...
+ ) : entries.length === 0 ? ( +
+ {filter ? 'No files match' : 'No files in this directory'} +
+ ) : ( + <> + {/* Directories */} + {dirs.map((entry, i) => ( + + ))} + + {/* Files */} + {files.map((entry, i) => { + const idx = dirs.length + i + return ( + + ) + })} + + )} +
+ + {/* Footer hint */} +
+ ↑↓ + navigate + Enter + attach + Esc + close +
+
+ ) +}) + +FileSearchMenu.displayName = 'FileSearchMenu' diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index db114975..59038ce2 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -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([]) 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(null) const fileInputRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) + const fileSearchRef = useRef(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 && ( + { + 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 && (
{ 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 { } 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)