| import { useMemo, useState, useEffect, useCallback } from "react"; |
| import { useQuery, keepPreviousData } from "@tanstack/react-query"; |
| import type { ProjectFile } from "shared/types"; |
| import { bridge } from "@/services"; |
| import { useChatStore } from "@/stores"; |
| import { useDebouncedValue } from "@/hooks/useDebouncedValue"; |
| import { MEDIA_CONFIG } from "@/services/config"; |
|
|
| export interface FileItem { |
| name: string; |
| path: string; |
| isDirectory: boolean; |
| matchPositions?: number[]; |
| } |
|
|
| interface ActiveToken { |
| trigger: "/" | "@"; |
| start: number; |
| query: string; |
| } |
|
|
| const NO_FILES: ProjectFile[] = []; |
|
|
| interface UseFilePickerResult { |
| showFileMenu: boolean; |
| fileItems: FileItem[]; |
| selectedIndex: number; |
| isLoading: boolean; |
| isStale: boolean; |
| showMediaOption: boolean; |
| fileMenuHeaderCount: number; |
| setSelectedIndex: (index: number) => void; |
| handleSelectItem: (item: FileItem) => void; |
| handleFileMenuKey: (e: React.KeyboardEvent) => boolean; |
| resetFilePicker: () => void; |
| } |
|
|
| export function useFilePicker(activeToken: ActiveToken | null, onInsertFile: (path: string) => void, onPickMedia: () => void, onCancel: () => void): UseFilePickerResult { |
| const { isStreaming, draftMedia } = useChatStore(); |
| const canAddMedia = !isStreaming && draftMedia.length < MEDIA_CONFIG.maxCount; |
|
|
| const [selectedIndex, setSelectedIndex] = useState(0); |
|
|
| const showFileMenu = activeToken?.trigger === "@"; |
| const query = activeToken?.query || ""; |
|
|
| const debouncedQuery = useDebouncedValue(query, 100); |
| const searchQuery = useQuery({ |
| queryKey: ["projectFiles", "search", debouncedQuery], |
| queryFn: () => bridge.getProjectFiles({ query: debouncedQuery || undefined }), |
| enabled: showFileMenu, |
| placeholderData: keepPreviousData, |
| }); |
| const searchResults = searchQuery.data ?? NO_FILES; |
| const isLoading = searchQuery.isLoading; |
| const isStale = debouncedQuery !== query || searchQuery.isPlaceholderData; |
|
|
| useEffect(() => { |
| setSelectedIndex(0); |
| }, [query]); |
|
|
| const fileItems = useMemo((): FileItem[] => { |
| return searchResults.slice(0, 50).map((f) => ({ |
| name: f.name, |
| path: f.path, |
| isDirectory: f.isDirectory, |
| matchPositions: f.matchPositions, |
| })); |
| }, [searchResults]); |
|
|
| const showMediaOption = canAddMedia && query === ""; |
| const fileMenuHeaderCount = showMediaOption ? 1 : 0; |
|
|
| useEffect(() => { |
| setSelectedIndex((i) => Math.min(i, Math.max(0, fileMenuHeaderCount + fileItems.length - 1))); |
| }, [fileMenuHeaderCount, fileItems.length]); |
|
|
| const resetFilePicker = useCallback(() => { |
| setSelectedIndex(0); |
| }, []); |
|
|
| const handleFileMenuConfirm = useCallback(() => { |
| if (showMediaOption && selectedIndex === 0) { |
| onPickMedia(); |
| return; |
| } |
|
|
| if (isStale) return; |
| const item = fileItems[selectedIndex - fileMenuHeaderCount]; |
| if (!item) return; |
| onInsertFile(item.path); |
| }, [selectedIndex, showMediaOption, isStale, fileMenuHeaderCount, fileItems, onPickMedia, onInsertFile]); |
|
|
| const handleSelectItem = useCallback( |
| (item: FileItem) => { |
| if (isStale) return; |
| onInsertFile(item.path); |
| }, |
| [isStale, onInsertFile], |
| ); |
|
|
| const handleFileMenuKey = useCallback( |
| (e: React.KeyboardEvent): boolean => { |
| if (!showFileMenu) return false; |
|
|
| const maxIdx = Math.max(0, fileMenuHeaderCount + fileItems.length - 1); |
|
|
| switch (e.key) { |
| case "ArrowDown": |
| e.preventDefault(); |
| setSelectedIndex((i) => Math.min(i + 1, maxIdx)); |
| return true; |
| case "ArrowUp": |
| e.preventDefault(); |
| setSelectedIndex((i) => Math.max(i - 1, 0)); |
| return true; |
| case "Tab": |
| case "Enter": |
| if (fileMenuHeaderCount + fileItems.length === 0) return false; |
| e.preventDefault(); |
| handleFileMenuConfirm(); |
| return true; |
| case "Escape": |
| e.preventDefault(); |
| onCancel(); |
| return true; |
| default: |
| return false; |
| } |
| }, |
| [showFileMenu, fileMenuHeaderCount, fileItems, handleFileMenuConfirm, onCancel], |
| ); |
|
|
| return { |
| showFileMenu, |
| fileItems, |
| selectedIndex, |
| isLoading, |
| isStale, |
| showMediaOption, |
| fileMenuHeaderCount, |
| setSelectedIndex, |
| handleSelectItem, |
| handleFileMenuKey, |
| resetFilePicker, |
| }; |
| } |
|
|