diff --git a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx index 4c9f19e1..93158ab8 100644 --- a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx +++ b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx @@ -24,6 +24,7 @@ import { } from './ui/select'; import { TaskModalLayout } from './task-form/TaskModalLayout'; import { TaskFormFields } from './task-form/TaskFormFields'; +import { type FileReferenceData } from './task-form/useImageUpload'; import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer'; import { FileAutocomplete } from './FileAutocomplete'; import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store'; @@ -111,6 +112,8 @@ export function TaskCreationWizard({ // @ autocomplete state const descriptionRef = useRef(null); + // Ref to track latest description value (avoids stale closure in handleFileReferenceDrop) + const descriptionValueRef = useRef(description); const [autocomplete, setAutocomplete] = useState<{ show: boolean; query: string; @@ -118,6 +121,11 @@ export function TaskCreationWizard({ position: { top: number; left: number }; } | null>(null); + // Keep description ref in sync for use in callbacks + useEffect(() => { + descriptionValueRef.current = description; + }, [description]); + // Load draft when dialog opens useEffect(() => { if (open && projectId) { @@ -293,6 +301,46 @@ export function TaskCreationWizard({ }); }, [autocomplete, description]); + /** + * Handle file reference drop from FileTreeItem drag + * Inserts @filename at cursor position or end of description + * Uses descriptionValueRef to avoid stale closure issues with rapid consecutive drops + */ + const handleFileReferenceDrop = useCallback((_reference: string, data: FileReferenceData) => { + // Construct reference from validated data to avoid using unvalidated text/plain input + const reference = `@${data.name}`; + // Dismiss any active autocomplete when file is dropped + if (autocomplete?.show) { + setAutocomplete(null); + } + + // Get latest description from ref to avoid stale closure + const currentDescription = descriptionValueRef.current; + + // Insert reference at cursor position if textarea is available + const textarea = descriptionRef.current; + if (textarea) { + const start = textarea.selectionStart ?? currentDescription.length; + const end = textarea.selectionEnd ?? currentDescription.length; + const newDescription = + currentDescription.substring(0, start) + + reference + ' ' + + currentDescription.substring(end); + handleDescriptionChange(newDescription); + // Focus textarea and set cursor after inserted text + // Use queueMicrotask for consistency with handleAutocompleteSelect + queueMicrotask(() => { + textarea.focus(); + const newCursorPos = start + reference.length + 1; + textarea.setSelectionRange(newCursorPos, newCursorPos); + }); + } else { + // Fallback: append to end + const separator = currentDescription.endsWith(' ') || currentDescription === '' ? '' : ' '; + handleDescriptionChange(currentDescription + separator + reference + ' '); + } + }, [handleDescriptionChange, autocomplete?.show]); + /** * Parse @mentions from description */ @@ -574,6 +622,7 @@ export function TaskCreationWizard({ disabled={isCreating} error={error} onError={setError} + onFileReferenceDrop={handleFileReferenceDrop} idPrefix="create" > {/* File autocomplete popup - positioned relative to TaskFormFields */} diff --git a/apps/frontend/src/renderer/components/TaskEditDialog.tsx b/apps/frontend/src/renderer/components/TaskEditDialog.tsx index 10829020..65c4961b 100644 --- a/apps/frontend/src/renderer/components/TaskEditDialog.tsx +++ b/apps/frontend/src/renderer/components/TaskEditDialog.tsx @@ -26,12 +26,13 @@ * /> * ``` */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Loader2 } from 'lucide-react'; import { Button } from './ui/button'; import { TaskModalLayout } from './task-form/TaskModalLayout'; import { TaskFormFields } from './task-form/TaskFormFields'; +import { type FileReferenceData } from './task-form/useImageUpload'; import { persistUpdateTask } from '../stores/task-store'; import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types'; import { @@ -162,6 +163,19 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi } }, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel, selectedProfile.phaseModels, selectedProfile.phaseThinking]); + /** + * Handle file reference drop from FileTreeItem drag + * Appends @filename to the end of the description (no textarea ref in edit dialog) + */ + const handleFileReferenceDrop = useCallback((reference: string, _data: FileReferenceData) => { + // Append to description using functional update to ensure latest state + // This prevents stale closure issues with rapid consecutive drops + setDescription(prev => { + const separator = prev.endsWith(' ') || prev === '' ? '' : ' '; + return prev + separator + reference + ' '; + }); + }, []); + const handleSave = async () => { // Validate input if (!description.trim()) { @@ -290,6 +304,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi disabled={isSaving} error={error} onError={setError} + onFileReferenceDrop={handleFileReferenceDrop} idPrefix="edit" /> diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index 50d0d105..097a511e 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -14,6 +14,7 @@ import { useXterm } from './terminal/useXterm'; import { usePtyProcess } from './terminal/usePtyProcess'; import { useTerminalEvents } from './terminal/useTerminalEvents'; import { useAutoNaming } from './terminal/useAutoNaming'; +import { useTerminalFileDrop } from './terminal/useTerminalFileDrop'; // Minimum dimensions to prevent PTY creation with invalid sizes const MIN_COLS = 10; @@ -72,8 +73,14 @@ export function Terminal({ // Check if a terminal is being dragged (vs a file) const { active } = useDndContext(); const isDraggingTerminal = active?.data.current?.type === 'terminal-panel'; - // Only show file drop overlay when dragging files, not terminals - const showFileDropOverlay = isOver && !isDraggingTerminal; + + // Use custom hook for native HTML5 file drop handling from FileTreeItem + // This hook is extracted to enable proper unit testing with renderHook() + const { isNativeDragOver, handleNativeDragOver, handleNativeDragLeave, handleNativeDrop } = + useTerminalFileDrop({ terminalId: id }); + + // Only show file drop overlay when dragging files (via @dnd-kit or native), not terminals + const showFileDropOverlay = (isOver && !isDraggingTerminal) || isNativeDragOver; // Auto-naming functionality const { handleCommandEnter, cleanup: cleanupAutoNaming } = useAutoNaming({ @@ -359,6 +366,9 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title showClaudeBusyIndicator && !isClaudeBusy && 'border-green-500/60 ring-1 ring-green-500/20' )} onClick={handleClick} + onDragOver={handleNativeDragOver} + onDragLeave={handleNativeDragLeave} + onDrop={handleNativeDrop} > {showFileDropOverlay && (
diff --git a/apps/frontend/src/renderer/components/__tests__/Terminal.drop.test.tsx b/apps/frontend/src/renderer/components/__tests__/Terminal.drop.test.tsx new file mode 100644 index 00000000..22c5df74 --- /dev/null +++ b/apps/frontend/src/renderer/components/__tests__/Terminal.drop.test.tsx @@ -0,0 +1,815 @@ +/** + * Unit tests for Terminal file drop handling via useTerminalFileDrop hook + * + * Tests file drag-and-drop from FileTreeItem to insert file paths into terminal. + * Uses renderHook() from React Testing Library to test actual component behavior + * rather than duplicating implementation logic in test helpers. + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTerminalFileDrop } from '../terminal/useTerminalFileDrop'; +import { escapeShellArg, parseFileReferenceDrop, type FileReferenceDropData } from '../../../shared/utils/shell-escape'; + +// Mock sendTerminalInput for testing +const mockSendTerminalInput = vi.fn(); + +// Setup before tests +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useTerminalFileDrop Hook', () => { + // Helper to create mock DragEvent with file reference data + function createMockDragEvent( + fileRefData: FileReferenceDropData | null, + options: { + types?: string[]; + relatedTarget?: Node | null; + currentTarget?: { contains: (node: Node | null) => boolean }; + } = {} + ): React.DragEvent { + const types = options.types ?? (fileRefData ? ['application/json'] : []); + const getData = vi.fn((type: string): string => { + if (type === 'application/json' && fileRefData) { + return JSON.stringify(fileRefData); + } + return ''; + }); + + return { + dataTransfer: { + types, + getData, + setData: vi.fn(), + effectAllowed: 'none' as DataTransfer['effectAllowed'], + dropEffect: 'none' as DataTransfer['dropEffect'] + } as unknown as DataTransfer, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + relatedTarget: options.relatedTarget ?? null, + currentTarget: options.currentTarget ?? { contains: () => false } + } as unknown as React.DragEvent; + } + + // Helper to create file reference drag data (matches FileTreeItem format) + function createFileReferenceDragData(path: string, name: string, isDirectory = false): FileReferenceDropData { + return { + type: 'file-reference', + path, + name, + isDirectory + }; + } + + describe('handleNativeDrop - File Path Insertion', () => { + it('should call sendTerminalInput with escaped file path when dropping valid file reference', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-1', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const dragData = createFileReferenceDragData('/path/to/file.ts', 'file.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-1', "'/path/to/file.ts' "); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + it('should escape file path with spaces using single quotes', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-2', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const dragData = createFileReferenceDragData('/path/to/my file.ts', 'my file.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-2', "'/path/to/my file.ts' "); + }); + + it('should add trailing space after file path', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-3', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const dragData = createFileReferenceDragData('/path/to/file.ts', 'file.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + const callArg = mockSendTerminalInput.mock.calls[0][1]; + expect(callArg.endsWith(' ')).toBe(true); + }); + + it('should handle directory paths the same as file paths', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-4', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const dragData = createFileReferenceDragData('/path/to/directory', 'directory', true); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-4', "'/path/to/directory' "); + }); + + it('should handle directory paths with spaces', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-5', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const dragData = createFileReferenceDragData('/path/to/my directory', 'my directory', true); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-5', "'/path/to/my directory' "); + }); + }); + + describe('handleNativeDrop - Invalid Data Handling', () => { + it('should not call sendTerminalInput when drag data is not file-reference type', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-6', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const invalidData = { type: 'other-type', path: '/path/to/file.ts' } as unknown as FileReferenceDropData; + const mockEvent = createMockDragEvent(invalidData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).not.toHaveBeenCalled(); + }); + + it('should not call sendTerminalInput when drag data has no path property', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-7', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const invalidData = { type: 'file-reference', name: 'file.ts' } as unknown as FileReferenceDropData; + const mockEvent = createMockDragEvent(invalidData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).not.toHaveBeenCalled(); + }); + + it('should not call sendTerminalInput when JSON data is empty', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-8', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const mockEvent = createMockDragEvent(null); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).not.toHaveBeenCalled(); + }); + + it('should handle invalid JSON gracefully without throwing', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-9', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const mockEvent = { + dataTransfer: { + types: ['application/json'], + getData: vi.fn(() => 'not valid json'), + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + currentTarget: { contains: () => false } + } as unknown as React.DragEvent; + + // Should not throw + expect(() => { + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + }).not.toThrow(); + expect(mockSendTerminalInput).not.toHaveBeenCalled(); + }); + }); + + describe('handleNativeDragOver', () => { + it('should set isNativeDragOver to true when dataTransfer contains application/json type', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-10', + sendTerminalInput: mockSendTerminalInput + }) + ); + + expect(result.current.isNativeDragOver).toBe(false); + + const mockEvent = createMockDragEvent( + createFileReferenceDragData('/test/path', 'file.ts'), + { types: ['application/json'] } + ); + + act(() => { + result.current.handleNativeDragOver(mockEvent); + }); + + expect(result.current.isNativeDragOver).toBe(true); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + it('should not set isNativeDragOver when dataTransfer does not contain application/json type', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-11', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const mockEvent = createMockDragEvent(null, { types: ['text/plain'] }); + + act(() => { + result.current.handleNativeDragOver(mockEvent); + }); + + expect(result.current.isNativeDragOver).toBe(false); + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('handleNativeDragLeave', () => { + it('should set isNativeDragOver to false when leaving the container', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-12', + sendTerminalInput: mockSendTerminalInput + }) + ); + + // First set drag over state + const dragOverEvent = createMockDragEvent( + createFileReferenceDragData('/test/path', 'file.ts'), + { types: ['application/json'] } + ); + + act(() => { + result.current.handleNativeDragOver(dragOverEvent); + }); + expect(result.current.isNativeDragOver).toBe(true); + + // Then leave the container (relatedTarget is not contained) + const leaveEvent = createMockDragEvent(null, { + relatedTarget: null, + currentTarget: { contains: () => false } + }); + + act(() => { + result.current.handleNativeDragLeave(leaveEvent); + }); + + expect(result.current.isNativeDragOver).toBe(false); + }); + + it('should not reset isNativeDragOver when moving to a child element', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-13', + sendTerminalInput: mockSendTerminalInput + }) + ); + + // First set drag over state + const dragOverEvent = createMockDragEvent( + createFileReferenceDragData('/test/path', 'file.ts'), + { types: ['application/json'] } + ); + + act(() => { + result.current.handleNativeDragOver(dragOverEvent); + }); + expect(result.current.isNativeDragOver).toBe(true); + + // Move to a child element (relatedTarget is contained) + const childNode = {} as Node; + const leaveEvent = createMockDragEvent(null, { + relatedTarget: childNode, + currentTarget: { contains: (node: Node | null) => node === childNode } + }); + + act(() => { + result.current.handleNativeDragLeave(leaveEvent); + }); + + // Should still be true - moving to child doesn't reset state + expect(result.current.isNativeDragOver).toBe(true); + }); + }); + + describe('Drop State Reset', () => { + it('should set isNativeDragOver to false on drop', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-14', + sendTerminalInput: mockSendTerminalInput + }) + ); + + // First set drag over state + const dragOverEvent = createMockDragEvent( + createFileReferenceDragData('/test/path', 'file.ts'), + { types: ['application/json'] } + ); + + act(() => { + result.current.handleNativeDragOver(dragOverEvent); + }); + expect(result.current.isNativeDragOver).toBe(true); + + // Drop + const dropEvent = createMockDragEvent( + createFileReferenceDragData('/path/to/file.ts', 'file.ts') + ); + + act(() => { + result.current.handleNativeDrop(dropEvent); + }); + + expect(result.current.isNativeDragOver).toBe(false); + }); + + it('should reset isNativeDragOver even when drop data is invalid', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-15', + sendTerminalInput: mockSendTerminalInput + }) + ); + + // First set drag over state + const dragOverEvent = createMockDragEvent( + createFileReferenceDragData('/test/path', 'file.ts'), + { types: ['application/json'] } + ); + + act(() => { + result.current.handleNativeDragOver(dragOverEvent); + }); + expect(result.current.isNativeDragOver).toBe(true); + + // Drop with invalid data + const dropEvent = createMockDragEvent(null); + + act(() => { + result.current.handleNativeDrop(dropEvent); + }); + + // Should still reset drag state + expect(result.current.isNativeDragOver).toBe(false); + expect(mockSendTerminalInput).not.toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle very long file paths', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-long', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const longPath = '/path/' + 'a'.repeat(200) + '/file.ts'; + const dragData = createFileReferenceDragData(longPath, 'file.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-long', `'${longPath}' `); + }); + + it('should handle paths with unicode characters', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-unicode', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const unicodePath = '/path/to/文件.ts'; + const dragData = createFileReferenceDragData(unicodePath, '文件.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-unicode', `'${unicodePath}' `); + }); + + it('should handle paths with unicode characters and spaces', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-unicode-space', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const unicodePath = '/path/to/我的 文件.ts'; + const dragData = createFileReferenceDragData(unicodePath, '我的 文件.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-unicode-space', `'${unicodePath}' `); + }); + + it('should handle relative paths', () => { + const { result } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'test-terminal-relative', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const relativePath = './relative/path/file.ts'; + const dragData = createFileReferenceDragData(relativePath, 'file.ts'); + const mockEvent = createMockDragEvent(dragData); + + act(() => { + result.current.handleNativeDrop(mockEvent); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal-relative', `'${relativePath}' `); + }); + + it('should handle multiple drops with different terminal IDs', () => { + const { result: result1 } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'terminal-a', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const { result: result2 } = renderHook(() => + useTerminalFileDrop({ + terminalId: 'terminal-b', + sendTerminalInput: mockSendTerminalInput + }) + ); + + const dragData = createFileReferenceDragData('/path/to/file.ts', 'file.ts'); + + act(() => { + result1.current.handleNativeDrop(createMockDragEvent(dragData)); + }); + + act(() => { + result2.current.handleNativeDrop(createMockDragEvent(dragData)); + }); + + expect(mockSendTerminalInput).toHaveBeenCalledTimes(2); + expect(mockSendTerminalInput).toHaveBeenNthCalledWith(1, 'terminal-a', "'/path/to/file.ts' "); + expect(mockSendTerminalInput).toHaveBeenNthCalledWith(2, 'terminal-b', "'/path/to/file.ts' "); + }); + }); +}); + +describe('parseFileReferenceDrop Utility', () => { + // Helper to create mock DataTransfer + function createMockDataTransfer(jsonData: object | null): DataTransfer { + return { + types: ['application/json'], + getData: vi.fn((type: string) => { + if (type === 'application/json' && jsonData) { + return JSON.stringify(jsonData); + } + return ''; + }), + setData: vi.fn(), + effectAllowed: 'none' + } as unknown as DataTransfer; + } + + it('should parse valid file reference data', () => { + const dataTransfer = createMockDataTransfer({ + type: 'file-reference', + path: '/path/to/file.ts', + name: 'file.ts', + isDirectory: false + }); + + const result = parseFileReferenceDrop(dataTransfer); + + expect(result).toEqual({ + type: 'file-reference', + path: '/path/to/file.ts', + name: 'file.ts', + isDirectory: false + }); + }); + + it('should return null for non-file-reference type', () => { + const dataTransfer = createMockDataTransfer({ type: 'other-type', path: '/test' }); + + const result = parseFileReferenceDrop(dataTransfer); + + expect(result).toBeNull(); + }); + + it('should return null when path is missing', () => { + const dataTransfer = createMockDataTransfer({ type: 'file-reference', name: 'file.ts' }); + + const result = parseFileReferenceDrop(dataTransfer); + + expect(result).toBeNull(); + }); + + it('should return null for empty JSON data', () => { + const dataTransfer = createMockDataTransfer(null); + dataTransfer.getData = vi.fn(() => ''); + + const result = parseFileReferenceDrop(dataTransfer); + + expect(result).toBeNull(); + }); + + it('should return null for invalid JSON', () => { + const dataTransfer = createMockDataTransfer(null); + dataTransfer.getData = vi.fn(() => 'not valid json'); + + const result = parseFileReferenceDrop(dataTransfer); + + expect(result).toBeNull(); + }); + + it('should handle directory flag', () => { + const dataTransfer = createMockDataTransfer({ + type: 'file-reference', + path: '/path/to/dir', + name: 'dir', + isDirectory: true + }); + + const result = parseFileReferenceDrop(dataTransfer); + + expect(result?.isDirectory).toBe(true); + }); +}); + +describe('escapeShellArg Utility', () => { + it('should wrap paths in single quotes', () => { + const path = '/path/to/file.ts'; + const escaped = escapeShellArg(path); + expect(escaped).toBe("'/path/to/file.ts'"); + }); + + it('should handle paths with spaces', () => { + const path = '/path/to my special file.ts'; + const escaped = escapeShellArg(path); + expect(escaped).toBe("'/path/to my special file.ts'"); + }); + + it('should handle empty path', () => { + const path = ''; + const escaped = escapeShellArg(path); + expect(escaped).toBe("''"); + }); + + it('should handle paths with special characters', () => { + const path = '/path/to/file@2.0.ts'; + const escaped = escapeShellArg(path); + expect(escaped).toBe("'/path/to/file@2.0.ts'"); + }); + + // Shell-unsafe character tests + it('should properly escape paths with double quotes', () => { + const path = '/path/to/"quoted"file.ts'; + const escaped = escapeShellArg(path); + // Single-quoted strings don't need double quotes escaped + expect(escaped).toBe('\'/path/to/"quoted"file.ts\''); + }); + + it('should properly escape paths with dollar signs', () => { + const path = '/path/to/$HOME/file.ts'; + const escaped = escapeShellArg(path); + // Single-quoted strings prevent shell expansion + expect(escaped).toBe("'/path/to/$HOME/file.ts'"); + }); + + it('should properly escape paths with backticks', () => { + const path = '/path/to/`command`/file.ts'; + const escaped = escapeShellArg(path); + // Single-quoted strings prevent command substitution + expect(escaped).toBe("'/path/to/`command`/file.ts'"); + }); + + it('should properly escape paths with single quotes', () => { + const path = "/path/to/it's/file.ts"; + const escaped = escapeShellArg(path); + // Single quotes within single quotes need special handling: '\'' + expect(escaped).toBe("'/path/to/it'\\''s/file.ts'"); + }); + + it('should properly escape paths with backslashes', () => { + const path = '/path/to/file\\name.ts'; + const escaped = escapeShellArg(path); + // Backslashes are literal inside single quotes + expect(escaped).toBe("'/path/to/file\\name.ts'"); + }); + + it('should handle complex paths with multiple shell metacharacters', () => { + const path = '/path/to/$USER\'s "files"`cmd`/test.ts'; + const escaped = escapeShellArg(path); + // Only single quotes need special escaping + expect(escaped).toBe("'/path/to/$USER'\\''s \"files\"`cmd`/test.ts'"); + }); + + it('should handle paths with newlines', () => { + const path = '/path/to/file\nwith\nnewlines.ts'; + const escaped = escapeShellArg(path); + // Newlines are literal inside single quotes + expect(escaped).toBe("'/path/to/file\nwith\nnewlines.ts'"); + }); +}); + +/** + * Integration test using a minimal component wrapper + * + * This verifies that the useTerminalFileDrop hook works correctly when used + * in a component context with actual DOM event handlers attached. + * + * Note: Testing the full Terminal component would require extensive mocking + * of xterm.js, @dnd-kit, zustand stores, and electron APIs. Instead, we: + * 1. Test the hook directly using renderHook() (above) + * 2. Test a minimal component that uses the hook to verify DOM integration + * + * This approach follows the same pattern as useImageUpload.fileref.test.ts + * and ensures the actual drop handling logic is tested, not duplicated. + */ +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; + +describe('Terminal File Drop - Component Integration', () => { + const mockSendInput = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Minimal component that uses the hook exactly like Terminal.tsx does + function TestDropZone({ terminalId }: { terminalId: string }) { + const { isNativeDragOver, handleNativeDragOver, handleNativeDragLeave, handleNativeDrop } = + useTerminalFileDrop({ + terminalId, + sendTerminalInput: mockSendInput + }); + + return ( +
+ Drop files here +
+ ); + } + + // Helper to create a native DragEvent for fireEvent + function createDropEvent(fileRefData: FileReferenceDropData | null): Partial { + const getData = (type: string): string => { + if (type === 'application/json' && fileRefData) { + return JSON.stringify(fileRefData); + } + return ''; + }; + + return { + dataTransfer: { + types: fileRefData ? ['application/json'] : [], + getData, + setData: vi.fn(), + effectAllowed: 'none', + dropEffect: 'none' + } as unknown as DataTransfer + }; + } + + it('should call sendTerminalInput when valid file is dropped on component', () => { + const { getByTestId } = render(); + const dropZone = getByTestId('drop-zone'); + + const dropEvent = createDropEvent({ + type: 'file-reference', + path: '/path/to/dropped-file.ts', + name: 'dropped-file.ts', + isDirectory: false + }); + + fireEvent.drop(dropZone, dropEvent); + + expect(mockSendInput).toHaveBeenCalledWith('test-terminal', "'/path/to/dropped-file.ts' "); + }); + + it('should not call sendTerminalInput when invalid data is dropped', () => { + const { getByTestId } = render(); + const dropZone = getByTestId('drop-zone'); + + const dropEvent = createDropEvent({ + type: 'other-type', + path: '/path/to/file.ts', + name: 'file.ts', + isDirectory: false + } as unknown as FileReferenceDropData); + + fireEvent.drop(dropZone, dropEvent); + + expect(mockSendInput).not.toHaveBeenCalled(); + }); + + it('should escape paths with spaces when dropped on component', () => { + const { getByTestId } = render(); + const dropZone = getByTestId('drop-zone'); + + const dropEvent = createDropEvent({ + type: 'file-reference', + path: '/path/to/my file.ts', + name: 'my file.ts', + isDirectory: false + }); + + fireEvent.drop(dropZone, dropEvent); + + expect(mockSendInput).toHaveBeenCalledWith('test-terminal', "'/path/to/my file.ts' "); + }); + + it('should escape paths with single quotes when dropped on component', () => { + const { getByTestId } = render(); + const dropZone = getByTestId('drop-zone'); + + const dropEvent = createDropEvent({ + type: 'file-reference', + path: "/path/to/it's-a-file.ts", + name: "it's-a-file.ts", + isDirectory: false + }); + + fireEvent.drop(dropZone, dropEvent); + + // Single quotes are escaped as '\'' + expect(mockSendInput).toHaveBeenCalledWith('test-terminal', "'/path/to/it'\\''s-a-file.ts' "); + }); +}); diff --git a/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts b/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts index 2a01b3b4..e8c65bd7 100644 --- a/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts +++ b/apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts @@ -292,16 +292,58 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { } }, [task.id]); - // Load merge preview (conflict detection) + // Load merge preview (conflict detection) and refresh worktree status const loadMergePreview = useCallback(async () => { setIsLoadingPreview(true); + // Clear any previous workspace error before loading + setWorkspaceError(null); + try { - const result = await window.electronAPI.mergeWorktreePreview(task.id); - if (result.success && result.data?.preview) { - setMergePreview(result.data.preview); + // Fetch both merge preview and updated worktree status in parallel + // This ensures the branch information (currentProjectBranch) is refreshed + // when the user clicks the refresh button after switching branches locally + // Use Promise.allSettled to handle partial failures - if one API call fails, + // the other's result is still processed rather than being discarded + const [previewResult, statusResult] = await Promise.allSettled([ + window.electronAPI.mergeWorktreePreview(task.id), + window.electronAPI.getWorktreeStatus(task.id) + ]); + + const errors: string[] = []; + + // Process merge preview result if fulfilled + if (previewResult.status === 'fulfilled') { + const result = previewResult.value; + if (result.success && result.data?.preview) { + setMergePreview(result.data.preview); + } else if (!result.success && result.error) { + errors.push(`Merge preview: ${result.error}`); + } + } else { + console.error('[useTaskDetail] Failed to load merge preview:', previewResult.reason); + errors.push('Failed to load merge preview'); + } + + // Update worktree status with fresh branch information if fulfilled + if (statusResult.status === 'fulfilled') { + const result = statusResult.value; + if (result.success && result.data) { + setWorktreeStatus(result.data); + } else if (!result.success && result.error) { + errors.push(`Worktree status: ${result.error}`); + } + } else { + console.error('[useTaskDetail] Failed to load worktree status:', statusResult.reason); + errors.push('Failed to load worktree status'); + } + + // Set workspace error if any API calls failed + if (errors.length > 0) { + setWorkspaceError(errors.join('; ')); } } catch (err) { - console.error('[useTaskDetail] Failed to load merge preview:', err); + console.error('[useTaskDetail] Unexpected error in loadMergePreview:', err); + setWorkspaceError('An unexpected error occurred while loading workspace information'); } finally { hasLoadedPreviewRef.current = task.id; setIsLoadingPreview(false); diff --git a/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx b/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx index 6c3d960a..a5127f29 100644 --- a/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx +++ b/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx @@ -18,7 +18,7 @@ import { Textarea } from '../ui/textarea'; import { Checkbox } from '../ui/checkbox'; import { AgentProfileSelector } from '../AgentProfileSelector'; import { ClassificationFields } from './ClassificationFields'; -import { useImageUpload } from './useImageUpload'; +import { useImageUpload, type FileReferenceData } from './useImageUpload'; import { cn } from '../../lib/utils'; import type { TaskCategory, @@ -87,6 +87,9 @@ interface TaskFormFieldsProps { /** Optional children to render after description (e.g., @ mention highlight overlay) */ children?: ReactNode; + + /** Callback when a file reference is dropped (from FileTreeItem drag) */ + onFileReferenceDrop?: (reference: string, data: FileReferenceData) => void; } export function TaskFormFields({ @@ -125,7 +128,8 @@ export function TaskFormFields({ error, onError, idPrefix = '', - children + children, + onFileReferenceDrop }: TaskFormFieldsProps) { const { t } = useTranslation(['tasks', 'common']); // Use external ref if provided (for @ mention autocomplete), otherwise use internal ref @@ -152,7 +156,8 @@ export function TaskFormFields({ invalidImageType: t('tasks:form.errors.invalidImageType'), processPasteFailed: t('tasks:form.errors.processPasteFailed'), processDropFailed: t('tasks:form.errors.processDropFailed') - } + }, + onFileReferenceDrop }); return ( diff --git a/apps/frontend/src/renderer/components/task-form/__tests__/useImageUpload.fileref.test.ts b/apps/frontend/src/renderer/components/task-form/__tests__/useImageUpload.fileref.test.ts new file mode 100644 index 00000000..3175267c --- /dev/null +++ b/apps/frontend/src/renderer/components/task-form/__tests__/useImageUpload.fileref.test.ts @@ -0,0 +1,628 @@ +/** + * Unit tests for useImageUpload file reference handling + * Tests file reference drops from FileTreeItem (separate from image handling) + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useImageUpload, type FileReferenceData } from '../useImageUpload'; +import type { ImageAttachment } from '../../../../shared/types'; + +// Type-safe mock function types +type OnImagesChangeFn = (images: ImageAttachment[]) => void; +type OnFileReferenceDropFn = (reference: string, data: FileReferenceData) => void; + +// Helper to create mock DragEvent with file reference data +function createMockFileRefDragEvent( + fileRefData: FileReferenceData | null, + textPlain?: string +): React.DragEvent { + const getData = vi.fn((type: string): string => { + if (type === 'application/json' && fileRefData) { + return JSON.stringify(fileRefData); + } + if (type === 'text/plain' && textPlain) { + return textPlain; + } + return ''; + }); + + return { + dataTransfer: { + types: fileRefData ? ['application/json', 'text/plain'] : [], + getData, + files: { length: 0 } as FileList, + items: [] as unknown as DataTransferItemList, + setData: vi.fn(), + clearData: vi.fn(), + effectAllowed: 'none' as DataTransfer['effectAllowed'], + dropEffect: 'none' as DataTransfer['dropEffect'] + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } as unknown as React.DragEvent; +} + +// Helper to create mock DragEvent for image drops +function createMockImageDragEvent(files: File[]): React.DragEvent { + const fileList = { + length: files.length, + item: (index: number) => files[index] || null, + [Symbol.iterator]: function* () { + for (let i = 0; i < files.length; i++) { + yield files[i]; + } + } + }; + + // Add numeric indexers + files.forEach((file, index) => { + (fileList as Record)[index] = file; + }); + + return { + dataTransfer: { + types: ['Files'], + getData: vi.fn(() => ''), + files: fileList as FileList, + items: [] as unknown as DataTransferItemList, + setData: vi.fn(), + clearData: vi.fn(), + effectAllowed: 'none' as DataTransfer['effectAllowed'], + dropEffect: 'none' as DataTransfer['dropEffect'] + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } as unknown as React.DragEvent; +} + +// Helper to create file reference data (matches FileTreeItem format) +function createFileReferenceData( + path: string, + name: string, + isDirectory = false +): FileReferenceData { + return { + type: 'file-reference', + path, + name, + isDirectory + }; +} + +describe('useImageUpload - File Reference Handling', () => { + // Use typed vi.fn() for proper type inference + const mockOnImagesChange = vi.fn(); + const mockOnFileReferenceDrop = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('parseFileReferenceData (via handleDrop)', () => { + it('should detect valid file reference drops and call onFileReferenceDrop callback', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData = createFileReferenceData('/path/to/file.ts', 'file.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@file.ts', fileRefData); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + it('should use @filename fallback when text/plain is not set', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData = createFileReferenceData('/path/to/myfile.ts', 'myfile.ts'); + // No text/plain data - should fall back to @{name} + const mockEvent = createMockFileRefDragEvent(fileRefData, ''); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@myfile.ts', fileRefData); + }); + + it('should not call onFileReferenceDrop when callback is not provided', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange + // No onFileReferenceDrop callback + }) + ); + + const fileRefData = createFileReferenceData('/path/to/file.ts', 'file.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + // Should not throw or cause issues + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it('should handle directory references the same as file references', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const dirRefData = createFileReferenceData('/path/to/directory', 'directory', true); + const mockEvent = createMockFileRefDragEvent(dirRefData, '@directory'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@directory', dirRefData); + expect(dirRefData.isDirectory).toBe(true); + }); + }); + + describe('Invalid File Reference Data', () => { + it('should not call onFileReferenceDrop when type is not "file-reference"', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + // Create invalid data with wrong type - use unknown first to bypass type check + const invalidData = { + type: 'other-type', + path: '/path/to/file.ts', + name: 'file.ts', + isDirectory: false + } as unknown as FileReferenceData; + + const mockEvent = createMockFileRefDragEvent(invalidData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + }); + + it('should not call onFileReferenceDrop when path is missing', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + // Create invalid data without path + const invalidData = { + type: 'file-reference', + name: 'file.ts', + isDirectory: false + } as FileReferenceData; + + const mockEvent = createMockFileRefDragEvent(invalidData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + }); + + it('should not call onFileReferenceDrop when name is missing', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + // Create invalid data without name + const invalidData = { + type: 'file-reference', + path: '/path/to/file.ts', + isDirectory: false + } as FileReferenceData; + + const mockEvent = createMockFileRefDragEvent(invalidData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + }); + + it('should handle invalid JSON gracefully', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + // Create mock event with invalid JSON + const mockEvent = { + dataTransfer: { + types: ['application/json', 'text/plain'], + getData: vi.fn((type: string) => { + if (type === 'application/json') { + return 'not valid json'; + } + return ''; + }), + files: { length: 0 } as FileList, + items: [] as unknown as DataTransferItemList + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } as unknown as React.DragEvent; + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + // Should not throw, should not call callback + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + }); + + it('should not call onFileReferenceDrop when application/json data is empty', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const mockEvent = createMockFileRefDragEvent(null, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + }); + }); + + describe('File Reference vs Image Drop Separation', () => { + it('should prioritize file reference over image drop', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + // Create event with both file reference data AND image files + // This shouldn't normally happen but tests priority + const fileRefData = createFileReferenceData('/path/to/file.png', 'file.png'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@file.png'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + // Should call file reference callback, not process as image + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@file.png', fileRefData); + expect(mockOnImagesChange).not.toHaveBeenCalled(); + }); + + it('should process image files when no file reference data is present', async () => { + // Note: This is a simplified test - full image processing requires + // more complex mocking of File objects and blobToBase64 + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + // Create mock event with no file reference data, just empty types + const mockEvent = createMockImageDragEvent([]); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + // Should not call file reference callback when no JSON data + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + }); + }); + + describe('Disabled State', () => { + it('should not process file reference drops when disabled', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop, + disabled: true + }) + ); + + const fileRefData = createFileReferenceData('/path/to/file.ts', 'file.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + // When disabled, the drop should be rejected without processing + expect(mockOnFileReferenceDrop).not.toHaveBeenCalled(); + // preventDefault should not be called when disabled - drop should be rejected + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('Drag State Management', () => { + it('should set isDragOver to true on dragOver', () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange + }) + ); + + expect(result.current.isDragOver).toBe(false); + + const mockEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } as unknown as React.DragEvent; + + act(() => { + result.current.handleDragOver(mockEvent); + }); + + expect(result.current.isDragOver).toBe(true); + }); + + it('should set isDragOver to false on dragLeave', () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange + }) + ); + + const mockEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } as unknown as React.DragEvent; + + // First set to true + act(() => { + result.current.handleDragOver(mockEvent); + }); + expect(result.current.isDragOver).toBe(true); + + // Then leave + act(() => { + result.current.handleDragLeave(mockEvent); + }); + expect(result.current.isDragOver).toBe(false); + }); + + it('should set isDragOver to false on drop', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const dragOverEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } as unknown as React.DragEvent; + + // Set drag over state + act(() => { + result.current.handleDragOver(dragOverEvent); + }); + expect(result.current.isDragOver).toBe(true); + + // Drop + const fileRefData = createFileReferenceData('/path/to/file.ts', 'file.ts'); + const dropEvent = createMockFileRefDragEvent(fileRefData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(dropEvent); + }); + + expect(result.current.isDragOver).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('should handle files with spaces in the name', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData = createFileReferenceData('/path/to/my file.ts', 'my file.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@my file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@my file.ts', fileRefData); + }); + + it('should handle files with special characters in the name', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData = createFileReferenceData('/path/to/file@2.0.ts', 'file@2.0.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@file@2.0.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@file@2.0.ts', fileRefData); + }); + + it('should handle files with unicode characters in the name', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData = createFileReferenceData('/path/to/文件.ts', '文件.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@文件.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@文件.ts', fileRefData); + }); + + it('should handle very long file paths', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const longPath = '/path/' + 'a'.repeat(200) + '/file.ts'; + const fileRefData = createFileReferenceData(longPath, 'file.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@file.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledWith('@file.ts', fileRefData); + expect(mockOnFileReferenceDrop.mock.calls[0][1].path).toBe(longPath); + }); + + it('should handle multiple rapid drops', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData1 = createFileReferenceData('/path/to/file1.ts', 'file1.ts'); + const fileRefData2 = createFileReferenceData('/path/to/file2.ts', 'file2.ts'); + const fileRefData3 = createFileReferenceData('/path/to/file3.ts', 'file3.ts'); + + const mockEvent1 = createMockFileRefDragEvent(fileRefData1, '@file1.ts'); + const mockEvent2 = createMockFileRefDragEvent(fileRefData2, '@file2.ts'); + const mockEvent3 = createMockFileRefDragEvent(fileRefData3, '@file3.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent1); + await result.current.handleDrop(mockEvent2); + await result.current.handleDrop(mockEvent3); + }); + + expect(mockOnFileReferenceDrop).toHaveBeenCalledTimes(3); + expect(mockOnFileReferenceDrop).toHaveBeenNthCalledWith(1, '@file1.ts', fileRefData1); + expect(mockOnFileReferenceDrop).toHaveBeenNthCalledWith(2, '@file2.ts', fileRefData2); + expect(mockOnFileReferenceDrop).toHaveBeenNthCalledWith(3, '@file3.ts', fileRefData3); + }); + }); + + describe('Callback Data Shape', () => { + it('should pass complete FileReferenceData to callback', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData: FileReferenceData = { + type: 'file-reference', + path: '/full/path/to/component.tsx', + name: 'component.tsx', + isDirectory: false + }; + const mockEvent = createMockFileRefDragEvent(fileRefData, '@component.tsx'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + const passedData = mockOnFileReferenceDrop.mock.calls[0][1] as FileReferenceData; + expect(passedData.type).toBe('file-reference'); + expect(passedData.path).toBe('/full/path/to/component.tsx'); + expect(passedData.name).toBe('component.tsx'); + expect(passedData.isDirectory).toBe(false); + }); + + it('should pass reference string as first argument', async () => { + const { result } = renderHook(() => + useImageUpload({ + images: [], + onImagesChange: mockOnImagesChange, + onFileReferenceDrop: mockOnFileReferenceDrop + }) + ); + + const fileRefData = createFileReferenceData('/path/to/utils.ts', 'utils.ts'); + const mockEvent = createMockFileRefDragEvent(fileRefData, '@utils.ts'); + + await act(async () => { + await result.current.handleDrop(mockEvent); + }); + + const reference = mockOnFileReferenceDrop.mock.calls[0][0] as string; + expect(reference).toBe('@utils.ts'); + expect(reference.startsWith('@')).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/task-form/useImageUpload.ts b/apps/frontend/src/renderer/components/task-form/useImageUpload.ts index 41d4c691..548670bf 100644 --- a/apps/frontend/src/renderer/components/task-form/useImageUpload.ts +++ b/apps/frontend/src/renderer/components/task-form/useImageUpload.ts @@ -18,6 +18,14 @@ import { ALLOWED_IMAGE_TYPES_DISPLAY } from '../../../shared/constants'; +/** Data structure for file reference drops from FileTreeItem */ +export interface FileReferenceData { + type: 'file-reference'; + path: string; + name: string; + isDirectory: boolean; +} + /** Error messages that can be customized/translated by callers */ interface ImageUploadErrorMessages { maxImagesReached?: string; @@ -37,6 +45,8 @@ interface UseImageUploadOptions { onError?: (error: string | null) => void; /** Custom error messages for i18n support */ errorMessages?: ImageUploadErrorMessages; + /** Callback when a file reference is dropped (from FileTreeItem drag) */ + onFileReferenceDrop?: (reference: string, data: FileReferenceData) => void; } interface UseImageUploadReturn { @@ -73,7 +83,8 @@ export function useImageUpload({ onImagesChange, disabled = false, onError, - errorMessages = {} + errorMessages = {}, + onFileReferenceDrop }: UseImageUploadOptions): UseImageUploadReturn { const [isDragOver, setIsDragOver] = useState(false); const [pasteSuccess, setPasteSuccess] = useState(false); @@ -229,15 +240,71 @@ export function useImageUpload({ }, []); /** - * Handle drop on textarea for image files - * Note: Only prevents default if image files are detected, allowing file reference - * drops (which use text/plain) to work via browser's default behavior + * Parse file reference data from drag event dataTransfer + * Returns the parsed data if valid, null otherwise + */ + const parseFileReferenceData = useCallback((dataTransfer: DataTransfer): FileReferenceData | null => { + // Check for application/json data (set by FileTreeItem) + const jsonData = dataTransfer.getData('application/json'); + if (!jsonData) return null; + + try { + const data = JSON.parse(jsonData) as Record; + // Validate required fields - path and name must be non-empty strings + // isDirectory is optional and defaults to false if missing or not a boolean + // This aligns with parseFileReferenceDrop in shell-escape.ts + if ( + data.type === 'file-reference' && + typeof data.path === 'string' && + data.path.length > 0 && + typeof data.name === 'string' && + data.name.length > 0 + ) { + return { + type: 'file-reference', + path: data.path, + name: data.name, + isDirectory: typeof data.isDirectory === 'boolean' ? data.isDirectory : false + }; + } + } catch { + // Invalid JSON, not a file reference + } + return null; + }, []); + + /** + * Handle drop on textarea for image files and file references + * File references from FileTreeItem (drag from file tree) are detected and handled + * via the onFileReferenceDrop callback. Image files are processed as attachments. */ const handleDrop = useCallback( async (e: DragEvent) => { + // Check disabled state first, before any state changes or preventDefault calls + // This ensures drops are properly rejected when the component is disabled + if (disabled) { + setIsDragOver(false); + return; + } + setIsDragOver(false); - if (disabled) return; + // First, check for file reference drops from FileTreeItem + // These have 'application/json' with type: 'file-reference' and 'text/plain' with @filename + const fileRefData = parseFileReferenceData(e.dataTransfer); + if (fileRefData) { + e.preventDefault(); + e.stopPropagation(); + + // Get the @filename reference text + const reference = e.dataTransfer.getData('text/plain') || `@${fileRefData.name}`; + + // Call the callback if provided + if (onFileReferenceDrop) { + onFileReferenceDrop(reference, fileRefData); + } + return; + } const files = e.dataTransfer?.files; @@ -253,7 +320,7 @@ export function useImageUpload({ } // Only prevent default if we have image files to process - // This allows file reference drops (@mention text) to work via default behavior + // This allows other drops to work via default behavior if (imageFiles.length === 0) return; e.preventDefault(); @@ -261,7 +328,7 @@ export function useImageUpload({ await processImageItems(imageFiles, { isFromPaste: false }); }, - [disabled, processImageItems] + [disabled, processImageItems, parseFileReferenceData, onFileReferenceDrop] ); /** diff --git a/apps/frontend/src/renderer/components/terminal/useTerminalFileDrop.ts b/apps/frontend/src/renderer/components/terminal/useTerminalFileDrop.ts new file mode 100644 index 00000000..a9d0346f --- /dev/null +++ b/apps/frontend/src/renderer/components/terminal/useTerminalFileDrop.ts @@ -0,0 +1,110 @@ +/** + * Custom hook for handling native HTML5 file drop events in Terminal. + * + * This hook encapsulates the file drop handling logic from FileTreeItem drag events, + * making it testable in isolation using renderHook() from React Testing Library. + * + * The hook handles: + * - Native drag over detection for application/json data + * - File reference parsing and validation + * - Shell argument escaping for safe command execution + * - Terminal input insertion via electronAPI + */ +import { useState, useCallback, type DragEvent } from 'react'; +import { parseFileReferenceDrop, escapeShellArg } from '../../../shared/utils/shell-escape'; + +export interface UseTerminalFileDropOptions { + /** Terminal ID for sending input */ + terminalId: string; + /** Callback to send input to terminal - defaults to window.electronAPI.sendTerminalInput */ + sendTerminalInput?: (terminalId: string, input: string) => void; +} + +export interface UseTerminalFileDropResult { + /** Whether a native file drag is currently over the drop zone */ + isNativeDragOver: boolean; + /** Handler for native dragover events */ + handleNativeDragOver: (e: DragEvent) => void; + /** Handler for native dragleave events */ + handleNativeDragLeave: (e: DragEvent) => void; + /** Handler for native drop events */ + handleNativeDrop: (e: DragEvent) => void; +} + +/** + * Hook for handling native file drag-and-drop in Terminal components. + * + * This hook is extracted from Terminal.tsx to enable proper unit testing + * using renderHook() rather than duplicating implementation logic in tests. + * + * @example + * ```tsx + * const { isNativeDragOver, handleNativeDragOver, handleNativeDragLeave, handleNativeDrop } = + * useTerminalFileDrop({ terminalId: 'term-1' }); + * + * return ( + *
+ * {isNativeDragOver && } + *
+ * ); + * ``` + */ +export function useTerminalFileDrop({ + terminalId, + sendTerminalInput = (id, input) => window.electronAPI.sendTerminalInput(id, input) +}: UseTerminalFileDropOptions): UseTerminalFileDropResult { + // Native HTML5 drag state for files dragged from FileTreeItem + // This is needed because FileTreeItem uses native HTML5 drag events, + // not @dnd-kit, so we must handle native drop events separately + const [isNativeDragOver, setIsNativeDragOver] = useState(false); + + // Handle native drag over (for files from FileTreeItem) + const handleNativeDragOver = useCallback((e: DragEvent) => { + // Check if it's a file reference drag (from FileTreeItem) + if (e.dataTransfer.types.includes('application/json')) { + e.preventDefault(); + e.stopPropagation(); + setIsNativeDragOver(true); + } + }, []); + + // Handle native drag leave + const handleNativeDragLeave = useCallback((e: DragEvent) => { + // Only reset if actually leaving the container, not just moving to a child element + // HTML5 drag events fire dragleave when moving from parent to child + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + // Note: dragleave is not cancelable, so preventDefault() has no effect + // We only call stopPropagation to prevent event bubbling + e.stopPropagation(); + setIsNativeDragOver(false); + }, []); + + // Handle native drop (for files from FileTreeItem) + const handleNativeDrop = useCallback((e: DragEvent) => { + setIsNativeDragOver(false); + // Use parseFileReferenceDrop utility to validate and extract file reference data + const fileRef = parseFileReferenceDrop(e.dataTransfer); + if (fileRef) { + e.preventDefault(); + e.stopPropagation(); + // Use escapeShellArg to safely escape path for shell execution + // This handles all shell metacharacters (quotes, $, backticks, etc.) + const escapedPath = escapeShellArg(fileRef.path); + // Insert the file path into the terminal with a trailing space + sendTerminalInput(terminalId, escapedPath + ' '); + } + }, [terminalId, sendTerminalInput]); + + return { + isNativeDragOver, + handleNativeDragOver, + handleNativeDragLeave, + handleNativeDrop + }; +} diff --git a/apps/frontend/src/shared/utils/shell-escape.ts b/apps/frontend/src/shared/utils/shell-escape.ts index 8bdd3b0d..93f42830 100644 --- a/apps/frontend/src/shared/utils/shell-escape.ts +++ b/apps/frontend/src/shared/utils/shell-escape.ts @@ -114,3 +114,52 @@ export function isPathSafe(path: string): boolean { return !suspiciousPatterns.some(pattern => pattern.test(path)); } + +/** + * File reference data structure from FileTreeItem drag events. + * This is the JSON payload set in dataTransfer by FileTreeItem components. + */ +export interface FileReferenceDropData { + type: 'file-reference'; + path: string; + name: string; + isDirectory: boolean; +} + +/** + * Parse file reference data from a drag event's DataTransfer. + * Extracts and validates the JSON payload set by FileTreeItem components. + * + * This function is used by Terminal drop handlers to safely extract file paths + * from drag-and-drop events originating from the file tree. + * + * @param dataTransfer - The DataTransfer object from a drag event + * @returns The parsed FileReferenceDropData if valid, null otherwise + */ +export function parseFileReferenceDrop(dataTransfer: DataTransfer): FileReferenceDropData | null { + const jsonData = dataTransfer.getData('application/json'); + if (!jsonData) { + return null; + } + + try { + const data = JSON.parse(jsonData) as Record; + // Validate required fields + if ( + data.type === 'file-reference' && + typeof data.path === 'string' && + data.path.length > 0 + ) { + return { + type: 'file-reference', + path: data.path, + name: typeof data.name === 'string' ? data.name : '', + isDirectory: typeof data.isDirectory === 'boolean' ? data.isDirectory : false + }; + } + } catch { + // Invalid JSON, return null + } + + return null; +}