diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 9f75324003c..60d7f21f5ea 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -166,6 +166,7 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void { args: | { paths: string[]; target: 'editor' } | { paths: string[]; target: 'terminal' } + | { paths: string[]; target: 'composer' } | { paths: string[]; target: 'file-explorer'; destinationDir: string } ) => { if (mainWindow.isDestroyed()) { diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 13861930e8f..9667cb46691 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -582,6 +582,7 @@ export type PreloadApi = { data: | { paths: string[]; target: 'editor' } | { paths: string[]; target: 'terminal' } + | { paths: string[]; target: 'composer' } | { paths: string[]; target: 'file-explorer'; destinationDir: string } ) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 3bf1fb70352..b0ad717b047 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import { type NativeDropResolution = | { target: 'editor' } | { target: 'terminal' } + | { target: 'composer' } | { target: 'file-explorer'; destinationDir: string } // Why: returned when the explorer marker was found but no destinationDir // could be resolved. The caller must suppress the drop entirely instead of @@ -51,7 +52,7 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { } const target = entry.dataset.nativeFileDropTarget - if (target === 'editor' || target === 'terminal') { + if (target === 'editor' || target === 'terminal' || target === 'composer') { return { target } } if (target === 'file-explorer') { @@ -1089,6 +1090,7 @@ const api = { data: | { paths: string[]; target: 'editor' } | { paths: string[]; target: 'terminal' } + | { paths: string[]; target: 'composer' } | { paths: string[]; target: 'file-explorer'; destinationDir: string } ) => void ): (() => void) => { @@ -1097,6 +1099,7 @@ const api = { data: | { paths: string[]; target: 'editor' } | { paths: string[]; target: 'terminal' } + | { paths: string[]; target: 'composer' } | { paths: string[]; target: 'file-explorer'; destinationDir: string } ) => callback(data) ipcRenderer.on('terminal:file-drop', listener) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 63c9a06e631..58ea22626f9 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -265,6 +265,77 @@ function SetupCommandPreview({ ) } +function useComposerFileDragOver(): { + isFileDragOver: boolean + dragHandlers: { + onDragEnter: (event: React.DragEvent) => void + onDragLeave: (event: React.DragEvent) => void + } +} { + const [isFileDragOver, setIsFileDragOver] = React.useState(false) + const dragCounterRef = React.useRef(0) + + const reset = React.useCallback(() => { + dragCounterRef.current = 0 + setIsFileDragOver(false) + }, []) + + const onDragEnter = React.useCallback((event: React.DragEvent): void => { + // Why: "Files" is the DataTransfer type the OS adds for native file drags; + // internal in-app drags (text/x-orca-file-path) must not trigger the + // attachment-drop highlight so they still route to their own handlers. + if (!event.dataTransfer.types.includes('Files')) { + return + } + if (event.dataTransfer.types.includes('text/x-orca-file-path')) { + return + } + dragCounterRef.current += 1 + setIsFileDragOver(true) + }, []) + + const onDragLeave = React.useCallback( + (event: React.DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) { + return + } + // Why: mirror the onDragEnter guard so internal in-app drags (which may + // carry both 'Files' and 'text/x-orca-file-path' types) don't decrement + // the counter when enter skipped incrementing it — otherwise the counter + // goes negative and the native-drag highlight state desyncs. + if (event.dataTransfer.types.includes('text/x-orca-file-path')) { + return + } + dragCounterRef.current -= 1 + if (dragCounterRef.current <= 0) { + reset() + } + }, + [reset] + ) + + // Why: the preload bridge calls stopPropagation on native `drop` events so + // React's onDrop never fires on the composer card. Listen at the document + // level (also capture-phase) to reset the drag highlight whenever any drop + // or dragend occurs anywhere in the window. + React.useEffect(() => { + const handler = (): void => { + reset() + } + document.addEventListener('drop', handler, true) + document.addEventListener('dragend', handler, true) + return () => { + document.removeEventListener('drop', handler, true) + document.removeEventListener('dragend', handler, true) + } + }, [reset]) + + return { + isFileDragOver, + dragHandlers: { onDragEnter, onDragLeave } + } +} + export default function NewWorkspaceComposerCard({ containerClassName, composerRef, @@ -314,12 +385,22 @@ export default function NewWorkspaceComposerCard({ resolvedSetupDecision, createError }: NewWorkspaceComposerCardProps): React.JSX.Element { + const { isFileDragOver, dragHandlers } = useComposerFileDragOver() return (
diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3d40c55112b..b0cc182e703 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -112,6 +112,15 @@ export type UseComposerStateResult = { createDisabled: boolean } +// Why: both the full-page NewWorkspacePage composer and the Cmd+J modal can +// be mounted simultaneously. Without instance scoping, a single native file +// drop fires every subscriber and duplicates attachments/prompt edits across +// the background draft and the visible modal. Route drops to the +// most-recently-mounted composer only — the modal stacks on top, so the +// modal wins when both are present, and the page takes over once the modal +// closes. +const composerDropStack: symbol[] = [] + // Why: agent detection runs `which` for every agent binary on PATH — an IPC // round-trip that takes 50–200ms. The set of installed agents doesn't change // within a session, so cache the promise at module scope to collapse all @@ -268,6 +277,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const composerRef = useRef(null) const promptTextareaRef = useRef(null) const nameInputRef = useRef(null) + // Why: the native-file-drop effect below subscribes once on mount and must + // read the latest agentPrompt when computing the caret-scoped insertion. + // Mirror the value into a ref so the listener sees fresh state without + // re-subscribing (which would reorder the composerDropStack and break + // multi-instance routing). + const agentPromptRef = useRef(agentPrompt) + agentPromptRef.current = agentPrompt const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) const parsedLinkedIssueNumber = useMemo( @@ -687,6 +703,115 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } }, []) + // Why: native OS file drops onto the composer are captured by the preload + // bridge (see `data-native-file-drop-target="composer"` markers) and relayed + // as a gesture-scoped IPC event. Files become attachments (matching the + // manual picker behavior); folders are pasted inline at the textarea caret + // so the user can reference them as working directories in their prompt + // without attaching a path we can't embed as file content. + const instanceIdRef = useRef(Symbol('composer')) + useEffect(() => { + const instanceId = instanceIdRef.current + composerDropStack.push(instanceId) + const unsubscribe = window.api.ui.onFileDrop((data) => { + if (data.target !== 'composer') { + return + } + // Why: only the top-of-stack composer (most recently mounted) owns the + // drop. Earlier subscribers stay bound to keep their own cleanup tidy + // but short-circuit so the event doesn't double-apply when page+modal + // are both alive. + if (composerDropStack.at(-1) !== instanceId) { + return + } + void (async () => { + const fileAttachments: string[] = [] + const folderPaths: string[] = [] + for (const filePath of data.paths) { + try { + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + const stat = await window.api.fs.stat({ filePath }) + if (stat.isDirectory) { + folderPaths.push(filePath) + } else { + fileAttachments.push(filePath) + } + } catch { + // Skip paths we cannot authorize or stat. + } + } + + if (fileAttachments.length > 0) { + setAttachmentPaths((current) => { + const next = [...current] + for (const p of fileAttachments) { + if (!next.includes(p)) { + next.push(p) + } + } + return next + }) + } + + if (folderPaths.length > 0) { + // Why: de-dup within a single drop — the OS occasionally delivers + // the same folder twice when a user drags from a selection that + // includes both the item and its parent, and we don't want to + // insert it multiple times. + const uniqueFolderPaths = Array.from(new Set(folderPaths)) + // Why: wrap paths containing shell metacharacters in double quotes + // (and escape embedded quotes) so the inserted text reads as a + // single token if the user pastes it into a terminal. Simple paths + // stay unadorned to match how Finder/Explorer drops appear. + const formatPath = (p: string): string => { + if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) { + return `"${p.replace(/(["\\$`])/g, '\\$1')}"` + } + return p + } + const insertion = uniqueFolderPaths.map(formatPath).join(' ') + const textarea = promptTextareaRef.current + // Why: compute selection, insertion, and caret target OUTSIDE the + // setAgentPrompt updater so the updater stays pure. React Strict + // Mode double-invokes updaters in dev, and batching can delay + // execution — reading `textarea.selectionStart` inside the updater + // risks seeing a shifted caret. Read `agentPromptRef.current` for + // the latest prompt because this effect subscribes once and the + // outer closure's `agentPrompt` would be stale. + const current = agentPromptRef.current + const selStart = textarea?.selectionStart ?? current.length + const selEnd = textarea?.selectionEnd ?? current.length + const before = current.slice(0, selStart) + const after = current.slice(selEnd) + // Why: pad with single spaces when the caret sits directly against + // other text so the folder path doesn't merge into an adjacent word. + const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) + const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) + const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}` + const caret = before.length + padded.length + if (textarea) { + // Restore the caret to the end of the inserted text after React flushes. + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(caret, caret) + }) + } + // Why: pass a plain value (not an updater) since `before`/`after` + // were already resolved from `agentPromptRef.current`; this keeps + // the state write side-effect-free under Strict-Mode double-render. + setAgentPrompt(before + padded + after) + } + })() + }) + return () => { + unsubscribe() + const idx = composerDropStack.lastIndexOf(instanceId) + if (idx !== -1) { + composerDropStack.splice(idx, 1) + } + } + }, []) + const handlePromptKeyDown = useCallback( (event: React.KeyboardEvent): void => { const mod = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey