diff --git a/src/__tests__/unit/file-tree-dnd.test.ts b/src/__tests__/unit/file-tree-dnd.test.ts new file mode 100644 index 00000000..78dfa63b --- /dev/null +++ b/src/__tests__/unit/file-tree-dnd.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + appendPathMention, + FILE_TREE_DRAG_FALLBACK_MIME, + FILE_TREE_DRAG_MIME, + hasFileTreeDragType, + parseFileTreeDragPayload, + readFileTreeDragPayload, + serializeFileTreeDragPayload, +} from '../../lib/file-tree-dnd'; + +describe('file-tree drag payload helpers', () => { + const makeTypes = (...types: string[]) => types as unknown as readonly string[]; + + it('serializes and parses file payload', () => { + const raw = serializeFileTreeDragPayload({ + path: 'src/app/page.tsx', + name: 'page.tsx', + type: 'file', + }); + + assert.deepEqual(parseFileTreeDragPayload(raw), { + path: 'src/app/page.tsx', + name: 'page.tsx', + type: 'file', + }); + }); + + it('normalizes unknown type to file', () => { + assert.deepEqual( + parseFileTreeDragPayload('{"path":"src","name":"src","type":"weird"}'), + { path: 'src', name: 'src', type: 'file' }, + ); + }); + + it('rejects invalid payloads', () => { + assert.equal(parseFileTreeDragPayload(''), null); + assert.equal(parseFileTreeDragPayload('not-json'), null); + assert.equal(parseFileTreeDragPayload('{"name":"src"}'), null); + }); + + it('detects supported drag MIME types', () => { + assert.equal(hasFileTreeDragType({ types: makeTypes(FILE_TREE_DRAG_MIME) }), true); + assert.equal(hasFileTreeDragType({ types: makeTypes(FILE_TREE_DRAG_FALLBACK_MIME) }), true); + assert.equal(hasFileTreeDragType({ types: makeTypes('text/plain') }), false); + }); + + it('reads payload from preferred MIME first', () => { + const payload = readFileTreeDragPayload({ + types: makeTypes(FILE_TREE_DRAG_MIME, FILE_TREE_DRAG_FALLBACK_MIME), + getData(type: string) { + if (type === FILE_TREE_DRAG_MIME) { + return '{"path":"src/components","name":"components","type":"directory"}'; + } + return ''; + }, + }); + + assert.deepEqual(payload, { + path: 'src/components', + name: 'components', + type: 'directory', + }); + }); +}); + +describe('appendPathMention', () => { + it('appends mention with trailing space', () => { + assert.equal(appendPathMention('', 'src/app/page.tsx'), '@src/app/page.tsx '); + }); + + it('inserts spacing between existing text and mention', () => { + assert.equal( + appendPathMention('请看这个', 'src/app/page.tsx'), + '请看这个 @src/app/page.tsx ', + ); + }); + + it('does not duplicate an existing mention', () => { + assert.equal( + appendPathMention('请看 @src/app/page.tsx ', 'src/app/page.tsx'), + '请看 @src/app/page.tsx ', + ); + }); +}); diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index c7322c45..a6df5392 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -22,6 +22,11 @@ import { useMemo, useState, } from "react"; +import { + FILE_TREE_DRAG_FALLBACK_MIME, + FILE_TREE_DRAG_MIME, + serializeFileTreeDragPayload, +} from "@/lib/file-tree-dnd"; interface FileTreeContextType { expandedPaths: Set; @@ -131,6 +136,17 @@ export const FileTreeFolder = ({ togglePath(path); }, [togglePath, path]); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const payload = serializeFileTreeDragPayload({ path, name, type: "directory" }); + e.dataTransfer.setData(FILE_TREE_DRAG_MIME, payload); + e.dataTransfer.setData(FILE_TREE_DRAG_FALLBACK_MIME, payload); + e.dataTransfer.setData("text/plain", path); + e.dataTransfer.effectAllowed = "copy"; + }, + [name, path] + ); + const folderContextValue = useMemo( () => ({ isExpanded, name, path }), [isExpanded, name, path] @@ -147,6 +163,8 @@ export const FileTreeFolder = ({
{ @@ -232,6 +250,17 @@ export const FileTreeFile = ({ [onAdd, path] ); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const payload = serializeFileTreeDragPayload({ path, name, type: "file" }); + e.dataTransfer.setData(FILE_TREE_DRAG_MIME, payload); + e.dataTransfer.setData(FILE_TREE_DRAG_FALLBACK_MIME, payload); + e.dataTransfer.setData("text/plain", path); + e.dataTransfer.effectAllowed = "copy"; + }, + [name, path] + ); + const fileContextValue = useMemo(() => ({ name, path }), [name, path]); return ( @@ -243,7 +272,9 @@ export const FileTreeFile = ({ className )} onClick={handleClick} + onDragStart={handleDragStart} onKeyDown={handleKeyDown} + draggable role="treeitem" tabIndex={0} {...props} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index f0ed151a..8372331d 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react'; +import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent, type DragEvent as ReactDragEvent } from 'react'; import { Terminal } from "@/components/ui/icon"; import { useTranslation } from '@/hooks/useTranslation'; import type { TranslationKey } from '@/i18n'; @@ -35,6 +35,8 @@ import { useCliToolsFetch } from '@/hooks/useCliToolsFetch'; import { useSlashCommands } from '@/hooks/useSlashCommands'; import { resolveKeyAction, cycleIndex, resolveDirectSlash, dispatchBadge, buildCliAppend } from '@/lib/message-input-logic'; import { QuickActions } from './QuickActions'; +import { appendPathMention, hasFileTreeDragType, readFileTreeDragPayload } from '@/lib/file-tree-dnd'; +import { cn } from '@/lib/utils'; interface MessageInputProps { onSend: (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => void; @@ -87,6 +89,7 @@ export function MessageInput({ const textareaRef = useRef(null); const searchInputRef = useRef(null); const cliSearchRef = useRef(null); + const [isFileTreeDragOver, setIsFileTreeDragOver] = useState(false); // Persist draft per session so switching chats doesn't lose typed text. const draftKey = `codepilot:draft:${sessionId || 'new'}`; const [inputValue, setInputValueRaw] = useState(() => { @@ -165,21 +168,46 @@ export function MessageInput({ } }, [onAssistantTrigger]); + const handleInsertPathMention = useCallback((filePath: string) => { + if (!filePath) return; + setInputValue((prev) => appendPathMention(prev, filePath)); + setTimeout(() => textareaRef.current?.focus(), 0); + }, [setInputValue]); + // Listen for file tree "+" button: insert @filepath into textarea useEffect(() => { const handler = (e: Event) => { const filePath = (e as CustomEvent<{ path: string }>).detail?.path; - if (!filePath) return; - const mention = `@${filePath} `; - setInputValue((prev) => { - const needsSpace = prev.length > 0 && !prev.endsWith(' ') && !prev.endsWith('\n'); - return prev + (needsSpace ? ' ' : '') + mention; - }); - setTimeout(() => textareaRef.current?.focus(), 0); + handleInsertPathMention(filePath); }; window.addEventListener('insert-file-mention', handler); return () => window.removeEventListener('insert-file-mention', handler); - }, [setInputValue]); + }, [handleInsertPathMention]); + + const handleFileTreeDragOver = useCallback((e: ReactDragEvent) => { + if (!hasFileTreeDragType(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setIsFileTreeDragOver(true); + }, []); + + const handleFileTreeDragLeave = useCallback((e: ReactDragEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setIsFileTreeDragOver(false); + }, []); + + const handleFileTreeDrop = useCallback((e: ReactDragEvent) => { + const payload = readFileTreeDragPayload(e.dataTransfer); + if (!payload) return; + + e.preventDefault(); + setIsFileTreeDragOver(false); + handleInsertPathMention(payload.path); + + if (payload.type === 'file') { + window.dispatchEvent(new CustomEvent('attach-file-to-chat', { detail: { path: payload.path } })); + } + }, [handleInsertPathMention]); const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => { e.preventDefault(); @@ -367,7 +395,15 @@ export function MessageInput({ return (
-
+
{/* Slash Command / File Popover */} ; + if (!parsed.path || typeof parsed.path !== 'string') return null; + + return { + path: parsed.path, + name: typeof parsed.name === 'string' ? parsed.name : '', + type: parsed.type === 'directory' ? 'directory' : 'file', + }; + } catch { + return null; + } +} + +export function hasFileTreeDragType( + dataTransfer: Pick | null | undefined, +): boolean { + if (!dataTransfer) return false; + + const types = Array.from(dataTransfer.types as ArrayLike); + return types.includes(FILE_TREE_DRAG_MIME) || types.includes(FILE_TREE_DRAG_FALLBACK_MIME); +} + +export function readFileTreeDragPayload( + dataTransfer: Pick | null | undefined, +): FileTreeDragPayload | null { + if (!dataTransfer) return null; + if (!hasFileTreeDragType(dataTransfer)) return null; + + return parseFileTreeDragPayload( + dataTransfer.getData(FILE_TREE_DRAG_MIME) + || dataTransfer.getData(FILE_TREE_DRAG_FALLBACK_MIME), + ); +} + +export function appendPathMention(inputValue: string, path: string): string { + const mention = `@${path}`; + if (inputValue.includes(mention)) return inputValue; + + const needsSpace = inputValue.length > 0 && !inputValue.endsWith(' ') && !inputValue.endsWith('\n'); + return `${inputValue}${needsSpace ? ' ' : ''}${mention} `; +}