mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
A user-requested drag target should reuse the existing attachment pipeline instead of creating a second upload path. The composer now handles browser DataTransfer drops and Tauri native drag-drop events, while keeping desktop attachments path-only so large files are not serialized into chat payloads. The fallback /goal metadata is also aligned with the existing desktop command surface because the desktop gate exercises that menu while validating composer behavior. Constraint: Desktop attachments must remain path-only to avoid inflated websocket payloads Rejected: Read dropped desktop files with FileReader | would reintroduce large inline data payloads Confidence: high Scope-risk: moderate Directive: Do not replace the Tauri drag-drop path conversion with data URLs without rerunning payload-size regression coverage Tested: cd desktop && bun run test -- --run src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun run check:desktop Tested: agent-browser overlay and dropped-chip smoke at http://127.0.0.1:1421/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456 Tested: bun run verify Not-tested: Finder-to-packaged-.app manual drag smoke
164 lines
4.5 KiB
TypeScript
164 lines
4.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
|
|
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
|
import {
|
|
dataTransferHasFiles,
|
|
dataTransferToComposerAttachments,
|
|
pathsToComposerAttachments,
|
|
type ComposerAttachment,
|
|
} from '../../lib/composerAttachments'
|
|
|
|
type TauriDropPosition = {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
type TauriDragDropPayload =
|
|
| { type: 'enter'; paths: string[]; position: TauriDropPosition }
|
|
| { type: 'over'; position: TauriDropPosition }
|
|
| { type: 'drop'; paths: string[]; position: TauriDropPosition }
|
|
| { type: 'leave' }
|
|
| { type: 'cancel' }
|
|
|
|
type TauriDragDropEvent = {
|
|
payload: TauriDragDropPayload
|
|
}
|
|
|
|
type UseComposerFileDropOptions = {
|
|
disabled?: boolean
|
|
panelRef: RefObject<HTMLElement | null>
|
|
onAttachments: (attachments: ComposerAttachment[]) => void
|
|
onError?: (error: unknown) => void
|
|
}
|
|
|
|
function isPointInsideElement(element: HTMLElement | null, position: TauriDropPosition): boolean {
|
|
if (!element) return false
|
|
const rect = element.getBoundingClientRect()
|
|
return (
|
|
position.x >= rect.left &&
|
|
position.x <= rect.right &&
|
|
position.y >= rect.top &&
|
|
position.y <= rect.bottom
|
|
)
|
|
}
|
|
|
|
export function useComposerFileDrop({
|
|
disabled = false,
|
|
panelRef,
|
|
onAttachments,
|
|
onError,
|
|
}: UseComposerFileDropOptions) {
|
|
const [isDragActive, setIsDragActive] = useState(false)
|
|
const dragDepthRef = useRef(0)
|
|
const disabledRef = useRef(disabled)
|
|
const onAttachmentsRef = useRef(onAttachments)
|
|
const onErrorRef = useRef(onError)
|
|
|
|
useEffect(() => {
|
|
disabledRef.current = disabled
|
|
}, [disabled])
|
|
|
|
useEffect(() => {
|
|
onAttachmentsRef.current = onAttachments
|
|
}, [onAttachments])
|
|
|
|
useEffect(() => {
|
|
onErrorRef.current = onError
|
|
}, [onError])
|
|
|
|
useEffect(() => {
|
|
if (!isTauriRuntime()) return
|
|
|
|
let disposed = false
|
|
let unlisten: (() => void) | undefined
|
|
|
|
void import('@tauri-apps/api/webview')
|
|
.then(({ getCurrentWebview }) =>
|
|
getCurrentWebview().onDragDropEvent((event) => {
|
|
if (disposed) return
|
|
|
|
const payload = event.payload as TauriDragDropEvent['payload']
|
|
if (payload.type === 'cancel' || payload.type === 'leave') {
|
|
dragDepthRef.current = 0
|
|
setIsDragActive(false)
|
|
return
|
|
}
|
|
|
|
const isInside = isPointInsideElement(panelRef.current, payload.position)
|
|
if (payload.type === 'enter' || payload.type === 'over') {
|
|
setIsDragActive(!disabledRef.current && isInside)
|
|
return
|
|
}
|
|
|
|
dragDepthRef.current = 0
|
|
setIsDragActive(false)
|
|
if (disabledRef.current || !isInside) return
|
|
|
|
const attachments = pathsToComposerAttachments(payload.paths)
|
|
if (attachments.length > 0) onAttachmentsRef.current(attachments)
|
|
}),
|
|
)
|
|
.then((nextUnlisten) => {
|
|
if (disposed) {
|
|
nextUnlisten()
|
|
return
|
|
}
|
|
unlisten = nextUnlisten
|
|
})
|
|
.catch((error) => {
|
|
onErrorRef.current?.(error)
|
|
})
|
|
|
|
return () => {
|
|
disposed = true
|
|
unlisten?.()
|
|
}
|
|
}, [panelRef])
|
|
|
|
const onDragEnter = useCallback((event: DragEvent) => {
|
|
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
|
event.preventDefault()
|
|
event.dataTransfer.dropEffect = 'copy'
|
|
dragDepthRef.current += 1
|
|
setIsDragActive(true)
|
|
}, [disabled])
|
|
|
|
const onDragOver = useCallback((event: DragEvent) => {
|
|
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
|
event.preventDefault()
|
|
event.dataTransfer.dropEffect = 'copy'
|
|
setIsDragActive(true)
|
|
}, [disabled])
|
|
|
|
const onDragLeave = useCallback((event: DragEvent) => {
|
|
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
|
event.preventDefault()
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
|
if (dragDepthRef.current === 0) setIsDragActive(false)
|
|
}, [disabled])
|
|
|
|
const onDrop = useCallback((event: DragEvent) => {
|
|
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
|
event.preventDefault()
|
|
dragDepthRef.current = 0
|
|
setIsDragActive(false)
|
|
|
|
void dataTransferToComposerAttachments(event.dataTransfer)
|
|
.then((attachments) => {
|
|
if (attachments.length > 0) onAttachments(attachments)
|
|
})
|
|
.catch((error) => {
|
|
onError?.(error)
|
|
})
|
|
}, [disabled, onAttachments, onError])
|
|
|
|
return {
|
|
isDragActive,
|
|
dragHandlers: {
|
|
onDragEnter,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDrop,
|
|
},
|
|
}
|
|
}
|