diff --git a/src/app/projects/[projectId]/components/newChat/FileCompletion.tsx b/src/app/projects/[projectId]/components/newChat/FileCompletion.tsx new file mode 100644 index 0000000..b82e924 --- /dev/null +++ b/src/app/projects/[projectId]/components/newChat/FileCompletion.tsx @@ -0,0 +1,319 @@ +import { CheckIcon, FileIcon, FolderIcon } from "lucide-react"; +import type React from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { Button } from "../../../../../components/ui/button"; +import { + Collapsible, + CollapsibleContent, +} from "../../../../../components/ui/collapsible"; +import { + type FileCompletionEntry, + useFileCompletion, +} from "../../../../../hooks/useFileCompletion"; +import { cn } from "../../../../../lib/utils"; + +type FileCompletionProps = { + projectId: string; + inputValue: string; + onFileSelect: (filePath: string) => void; + className?: string; +}; + +export type FileCompletionRef = { + handleKeyDown: (e: React.KeyboardEvent) => boolean; +}; + +// Parse the @ completion from input value +const parseFileCompletionFromInput = (input: string) => { + // Find the last @ symbol + const lastAtIndex = input.lastIndexOf("@"); + if (lastAtIndex === -1) { + return { shouldShow: false, searchPath: "", beforeAt: "", afterAt: "" }; + } + + // Get the text before and after @ + const beforeAt = input.slice(0, lastAtIndex); + const afterAt = input.slice(lastAtIndex + 1); + + // Check if we're in the middle of a word after @ (no space after the path) + const parts = afterAt.split(/\s/); + const searchPath = parts[0] || ""; + + // Don't show completion if there's a space after the path (user has finished typing the path) + // This includes cases like "@hoge " where parts = ["hoge", ""] + const hasSpaceAfterPath = parts.length > 1; + + return { + shouldShow: !hasSpaceAfterPath, + searchPath, + beforeAt, + afterAt, + }; +}; + +export const FileCompletion = forwardRef< + FileCompletionRef, + FileCompletionProps +>(({ projectId, inputValue, onFileSelect, className }, ref) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const listRef = useRef(null); + + // Parse the input to extract the path being completed + const { shouldShow, searchPath, beforeAt, afterAt } = useMemo( + () => parseFileCompletionFromInput(inputValue), + [inputValue], + ); + + // Determine the base path and filter term + const { basePath, filterTerm } = useMemo(() => { + if (!searchPath) { + return { basePath: "/", filterTerm: "" }; + } + + const lastSlashIndex = searchPath.lastIndexOf("/"); + if (lastSlashIndex === -1) { + return { basePath: "/", filterTerm: searchPath }; + } + + const path = searchPath.slice(0, lastSlashIndex + 1); + const term = searchPath.slice(lastSlashIndex + 1); + return { + basePath: path === "/" ? "/" : path, + filterTerm: term, + }; + }, [searchPath]); + + // Fetch file completion data + const { data: completionData, isLoading } = useFileCompletion( + projectId, + basePath, + shouldShow, + ); + + // Filter entries based on the current filter term + const filteredEntries = useMemo(() => { + if (!completionData?.entries) return []; + + if (!filterTerm) { + return completionData.entries; + } + + return completionData.entries.filter((entry) => + entry.name.toLowerCase().includes(filterTerm.toLowerCase()), + ); + }, [completionData?.entries, filterTerm]); + + // Determine if completion should be shown + const shouldBeOpen = shouldShow && !isLoading && filteredEntries.length > 0; + + // Update open state when it should change + if (isOpen !== shouldBeOpen) { + setIsOpen(shouldBeOpen); + setSelectedIndex(-1); + } + + // Handle file/directory selection with different behaviors for different triggers + const handleEntrySelect = useCallback( + (entry: FileCompletionEntry, forceClose = false) => { + const fullPath = entry.path; + + // For directories, add a trailing slash to continue completion (unless forced to close) + const pathToInsert = + entry.type === "directory" && !forceClose ? `${fullPath}/` : fullPath; + + // Reconstruct the message with the selected path + const newMessage = `${beforeAt}@${pathToInsert}${afterAt.split(/\s/).slice(1).join(" ")}`; + + onFileSelect(newMessage.trim()); + + // Close completion if it's a file, or if forced to close + if (entry.type === "file" || forceClose) { + setIsOpen(false); + setSelectedIndex(-1); + } + }, + [beforeAt, afterAt, onFileSelect], + ); + + // Scroll to selected entry + const scrollToSelected = useCallback(() => { + if (selectedIndex >= 0 && listRef.current) { + const selectedElement = listRef.current.children[ + selectedIndex + 1 + ] as HTMLElement; // +1 for header + if (selectedElement) { + selectedElement.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + }, [selectedIndex]); + + // Keyboard navigation + const handleKeyboardNavigation = useCallback( + (e: React.KeyboardEvent): boolean => { + if (!isOpen || filteredEntries.length === 0) return false; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => { + const newIndex = prev < filteredEntries.length - 1 ? prev + 1 : 0; + setTimeout(scrollToSelected, 0); + return newIndex; + }); + return true; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => { + const newIndex = prev > 0 ? prev - 1 : filteredEntries.length - 1; + setTimeout(scrollToSelected, 0); + return newIndex; + }); + return true; + case "Enter": + if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) { + e.preventDefault(); + const selectedEntry = filteredEntries[selectedIndex]; + if (selectedEntry) { + // Enter always closes completion (even for directories) + handleEntrySelect(selectedEntry, true); + } + return true; + } + break; + case "Tab": + if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) { + e.preventDefault(); + const selectedEntry = filteredEntries[selectedIndex]; + if (selectedEntry) { + // Tab: continue completion for directories, close for files + handleEntrySelect(selectedEntry, selectedEntry.type === "file"); + } + return true; + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setSelectedIndex(-1); + return true; + } + return false; + }, + [ + isOpen, + filteredEntries.length, + selectedIndex, + handleEntrySelect, + scrollToSelected, + filteredEntries, + ], + ); + + // Handle clicks outside the component + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSelectedIndex(-1); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Expose keyboard handler to parent + useImperativeHandle( + ref, + () => ({ + handleKeyDown: handleKeyboardNavigation, + }), + [handleKeyboardNavigation], + ); + + if (!shouldShow || isLoading || filteredEntries.length === 0) { + return null; + } + + return ( +
+ + +
+ {filteredEntries.length > 0 && ( +
+
+ + Files & Directories ({filteredEntries.length}) + {basePath !== "/" && ( + + in {basePath} + + )} +
+ {filteredEntries.map((entry, index) => ( + + ))} +
+ )} +
+
+
+
+ ); +}); + +FileCompletion.displayName = "FileCompletion"; diff --git a/src/app/projects/[projectId]/components/newChat/NewChat.tsx b/src/app/projects/[projectId]/components/newChat/NewChat.tsx index f7aa95b..a8cd98b 100644 --- a/src/app/projects/[projectId]/components/newChat/NewChat.tsx +++ b/src/app/projects/[projectId]/components/newChat/NewChat.tsx @@ -9,6 +9,7 @@ import { CommandCompletion, type CommandCompletionRef, } from "./CommandCompletion"; +import { FileCompletion, type FileCompletionRef } from "./FileCompletion"; export const NewChat: FC<{ projectId: string; @@ -51,6 +52,7 @@ export const NewChat: FC<{ const [message, setMessage] = useState(""); const completionRef = useRef(null); + const fileCompletionRef = useRef(null); const helpId = useId(); const handleSubmit = () => { @@ -59,7 +61,12 @@ export const NewChat: FC<{ }; const handleKeyDown = (e: React.KeyboardEvent) => { - // まずコマンド補完のキーボードイベントを処理 + // まずファイル補完のキーボードイベントを処理 + if (fileCompletionRef.current?.handleKeyDown(e)) { + return; + } + + // 次にコマンド補完のキーボードイベントを処理 if (completionRef.current?.handleKeyDown(e)) { return; } @@ -76,6 +83,11 @@ export const NewChat: FC<{ textareaRef.current?.focus(); }; + const handleFileSelect = (filePath: string) => { + setMessage(filePath); + textareaRef.current?.focus(); + }; + return (
{startNewChat.error && ( @@ -92,13 +104,13 @@ export const NewChat: FC<{ value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Type your message here... (Start with / for commands, Shift+Enter to send)" + placeholder="Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)" className="min-h-[100px] resize-none" disabled={startNewChat.isPending} maxLength={4000} aria-label="Message input with command completion" aria-describedby={helpId} - aria-expanded={message.startsWith("/")} + aria-expanded={message.startsWith("/") || message.includes("@")} aria-haspopup="listbox" role="combobox" aria-autocomplete="list" @@ -110,12 +122,19 @@ export const NewChat: FC<{ onCommandSelect={handleCommandSelect} className="absolute top-full left-0 right-0" /> +
{message.length}/4000 characters • Use arrow keys to navigate - commands + completions