mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(composer): support drag-and-drop files and folders onto the composer (#787)
Files dropped onto the new-workspace composer (modal or full page) are appended as attachment chips; folders are inserted inline at the textarea caret with shell-style quoting so users can reference working directories from the OS file browser without leaving the prompt.
This commit is contained in:
@@ -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()) {
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -265,6 +265,77 @@ function SetupCommandPreview({
|
||||
)
|
||||
}
|
||||
|
||||
function useComposerFileDragOver(): {
|
||||
isFileDragOver: boolean
|
||||
dragHandlers: {
|
||||
onDragEnter: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => 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<HTMLDivElement>): 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<HTMLDivElement>): 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 (
|
||||
<div className="grid gap-3">
|
||||
<div
|
||||
ref={composerRef}
|
||||
// Why: preload classifies native OS file drops by the nearest
|
||||
// `data-native-file-drop-target` marker in the composedPath. Tagging
|
||||
// the composer root makes drops anywhere on the card (modal or full
|
||||
// page) route to the composer attachment handler instead of falling
|
||||
// back to the default editor-open behavior.
|
||||
data-native-file-drop-target="composer"
|
||||
onDragEnter={dragHandlers.onDragEnter}
|
||||
onDragLeave={dragHandlers.onDragLeave}
|
||||
className={cn(
|
||||
'rounded-[20px] border border-border/50 bg-background/40 p-3 shadow-lg backdrop-blur-xl supports-[backdrop-filter]:bg-background/40',
|
||||
'rounded-[20px] border border-border/50 bg-background/40 p-3 shadow-lg backdrop-blur-xl supports-[backdrop-filter]:bg-background/40 transition',
|
||||
isFileDragOver && 'border-ring ring-2 ring-ring/30',
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null)
|
||||
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const nameInputRef = useRef<HTMLInputElement | null>(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>(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<HTMLTextAreaElement>): void => {
|
||||
const mod = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
|
||||
Reference in New Issue
Block a user