mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* fix(native-chat): scope composer file drops to the pane that received them A native OS file drop resolving to `target: 'composer'` carried no pane identity, so the window-wide payload was attached by every mounted composer. Because inactive chat tabs stay mounted (hidden), one drop populated every chat pane's attachment cache, and those chips replayed whenever the user returned to a tab they never dropped into. The workspace-creation composer and chat composers also leaked into each other, since neither could tell which surface actually received the drop. Composer drops now carry a `scopeKey` the way a terminal drop carries its tab and pane leaf id: the composer publishes its pane key as `data-composer-scope-key`, the preload harvests it during the composedPath walk, and each composer attaches only its own. The workspace composer's last-wins ownership stack now claims unscoped payloads only. * test(native-chat): supersede the bug-asserting drop repro with the scoping test The repro that landed on main asserts the pre-fix behavior (a drop reaching every mounted composer), so it fails once drops are scoped to the pane that received them. Its scoping cases now live in native-chat-composer-drop-scope.test.tsx, which keeps its editor-target control case verbatim and adds coverage for unscoped composers and a scope key published inside the drop-target marker. * test(native-chat): cover workspace composer drop isolation * fix(native-chat): authorize external attachment paths before preview --------- Co-authored-by: Merge Sim <sim@local>
279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
import { measureClipboardTextByteLength } from './clipboard-text'
|
|
|
|
export const ORCA_INTERNAL_FILE_DRAG_TYPE = 'text/x-orca-file-path'
|
|
|
|
export const NATIVE_FILE_DROP_MAX_PATHS = 256
|
|
export const NATIVE_FILE_DROP_MAX_PATH_BYTES = 256 * 1024
|
|
|
|
export const NATIVE_FILE_DROP_TARGET = {
|
|
editor: 'editor',
|
|
terminal: 'terminal',
|
|
composer: 'composer',
|
|
fileExplorer: 'file-explorer',
|
|
projectSidebar: 'project-sidebar'
|
|
} as const
|
|
|
|
export type NativeDropResolution =
|
|
| { target: typeof NATIVE_FILE_DROP_TARGET.editor }
|
|
| { target: typeof NATIVE_FILE_DROP_TARGET.terminal; tabId?: string; paneLeafId?: string }
|
|
| { target: typeof NATIVE_FILE_DROP_TARGET.composer; scopeKey?: string }
|
|
| { target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer; destinationDir: string }
|
|
| { target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar }
|
|
| { target: 'rejected' }
|
|
|
|
export type NativeFileDropPayload =
|
|
| { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.editor }
|
|
| {
|
|
paths: string[]
|
|
target: typeof NATIVE_FILE_DROP_TARGET.terminal
|
|
tabId?: string
|
|
paneLeafId?: string
|
|
}
|
|
| { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.composer; scopeKey?: string }
|
|
| {
|
|
paths: string[]
|
|
target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer
|
|
destinationDir: string
|
|
}
|
|
| { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar }
|
|
| NativeFileDropRejectedPayload
|
|
|
|
export type NativeFileDropRejectedPayload = {
|
|
byteLength: number
|
|
pathCount: number
|
|
reason: 'paths-too-large' | 'too-many-paths'
|
|
target: 'rejected'
|
|
}
|
|
|
|
export type NativeFileDropPathEntry = {
|
|
nativeFileDropTarget?: string
|
|
nativeFileDropDir?: string
|
|
composerScopeKey?: string
|
|
terminalTabId?: string
|
|
terminalPaneLeafId?: string
|
|
}
|
|
|
|
export type NativeFileDropPathValidation =
|
|
| { byteLength: number; pathCount: number; status: 'accepted' }
|
|
| {
|
|
byteLength: number
|
|
pathCount: number
|
|
reason: NativeFileDropRejectedPayload['reason']
|
|
status: 'rejected'
|
|
}
|
|
|
|
function isNativeFileDropRejectedReason(
|
|
reason: unknown
|
|
): reason is NativeFileDropRejectedPayload['reason'] {
|
|
return reason === 'paths-too-large' || reason === 'too-many-paths'
|
|
}
|
|
|
|
function isNativeFileDropTarget(target: unknown): target is NativeFileDropPayload['target'] {
|
|
return Object.values(NATIVE_FILE_DROP_TARGET).includes(target as never) || target === 'rejected'
|
|
}
|
|
|
|
function isOptionalNativeFileDropString(value: unknown): value is string | undefined {
|
|
return value === undefined || typeof value === 'string'
|
|
}
|
|
|
|
function isNativeFileDropPathList(value: unknown): value is string[] {
|
|
return Array.isArray(value) && value.every((path) => typeof path === 'string')
|
|
}
|
|
|
|
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
}
|
|
|
|
function getDataTransferTypes(
|
|
types: Iterable<string> | ArrayLike<string> | null | undefined
|
|
): string[] {
|
|
return types ? Array.from(types) : []
|
|
}
|
|
|
|
export function hasNativeFileDragTypes(
|
|
types: Iterable<string> | ArrayLike<string> | null | undefined
|
|
): boolean {
|
|
const values = getDataTransferTypes(types)
|
|
return values.includes('Files') && !values.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)
|
|
}
|
|
|
|
export function resolveNativeFileDropPath(
|
|
path: readonly NativeFileDropPathEntry[]
|
|
): NativeDropResolution | null {
|
|
let foundExplorer = false
|
|
let destinationDir: string | undefined
|
|
let terminalPaneLeafId: string | undefined
|
|
let composerScopeKey: string | undefined
|
|
|
|
for (const entry of path) {
|
|
terminalPaneLeafId ??= entry.terminalPaneLeafId
|
|
composerScopeKey ??= entry.composerScopeKey
|
|
const target = entry.nativeFileDropTarget
|
|
if (target === NATIVE_FILE_DROP_TARGET.terminal) {
|
|
return { target, tabId: entry.terminalTabId, paneLeafId: terminalPaneLeafId }
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.composer) {
|
|
// Composer drops fan out window-wide, so carry the receiving composer's
|
|
// scope key the way a terminal drop carries its pane leaf id.
|
|
return { target, ...(composerScopeKey ? { scopeKey: composerScopeKey } : {}) }
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.editor) {
|
|
return { target }
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.projectSidebar) {
|
|
return { target }
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.fileExplorer) {
|
|
foundExplorer = true
|
|
}
|
|
|
|
// Pick the nearest (innermost) destination directory marker.
|
|
if (destinationDir === undefined && entry.nativeFileDropDir) {
|
|
destinationDir = entry.nativeFileDropDir
|
|
}
|
|
}
|
|
|
|
if (foundExplorer) {
|
|
if (!destinationDir) {
|
|
return { target: 'rejected' }
|
|
}
|
|
return { target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function validateNativeFileDropPaths(
|
|
paths: readonly string[],
|
|
options: {
|
|
maxPathBytes?: number
|
|
maxPaths?: number
|
|
} = {}
|
|
): NativeFileDropPathValidation {
|
|
const pathCount = paths.length
|
|
const maxPaths = options.maxPaths ?? NATIVE_FILE_DROP_MAX_PATHS
|
|
if (pathCount > maxPaths) {
|
|
return {
|
|
byteLength: 0,
|
|
pathCount,
|
|
reason: 'too-many-paths',
|
|
status: 'rejected'
|
|
}
|
|
}
|
|
|
|
const maxPathBytes = options.maxPathBytes ?? NATIVE_FILE_DROP_MAX_PATH_BYTES
|
|
let byteLength = 0
|
|
for (const path of paths) {
|
|
const measurement = measureClipboardTextByteLength(path, {
|
|
stopAfterBytes: maxPathBytes - byteLength
|
|
})
|
|
byteLength += measurement.byteLength
|
|
if (byteLength > maxPathBytes) {
|
|
return {
|
|
byteLength,
|
|
pathCount,
|
|
reason: 'paths-too-large',
|
|
status: 'rejected'
|
|
}
|
|
}
|
|
}
|
|
|
|
return { byteLength, pathCount, status: 'accepted' }
|
|
}
|
|
|
|
export function createRejectedNativeFileDropPayload(
|
|
validation: Extract<NativeFileDropPathValidation, { status: 'rejected' }>
|
|
): NativeFileDropRejectedPayload {
|
|
return {
|
|
byteLength: validation.byteLength,
|
|
pathCount: validation.pathCount,
|
|
reason: validation.reason,
|
|
target: 'rejected'
|
|
}
|
|
}
|
|
|
|
export function createNativeFileDropPayload(
|
|
resolution: NativeDropResolution | null,
|
|
paths: readonly string[]
|
|
): NativeFileDropPayload | null {
|
|
const validation = validateNativeFileDropPaths(paths)
|
|
if (validation.status === 'rejected') {
|
|
return createRejectedNativeFileDropPayload(validation)
|
|
}
|
|
|
|
if (resolution?.target === 'rejected') {
|
|
return null
|
|
}
|
|
|
|
if (resolution?.target === NATIVE_FILE_DROP_TARGET.fileExplorer) {
|
|
return {
|
|
paths: [...paths],
|
|
target: NATIVE_FILE_DROP_TARGET.fileExplorer,
|
|
destinationDir: resolution.destinationDir
|
|
}
|
|
}
|
|
|
|
if (resolution?.target === NATIVE_FILE_DROP_TARGET.composer) {
|
|
return {
|
|
paths: [...paths],
|
|
target: resolution.target,
|
|
...(resolution.scopeKey ? { scopeKey: resolution.scopeKey } : {})
|
|
}
|
|
}
|
|
|
|
const target = resolution?.target ?? NATIVE_FILE_DROP_TARGET.editor
|
|
if (resolution?.target === NATIVE_FILE_DROP_TARGET.terminal) {
|
|
return {
|
|
paths: [...paths],
|
|
target: resolution.target,
|
|
...(resolution.tabId ? { tabId: resolution.tabId } : {}),
|
|
...(resolution.paneLeafId ? { paneLeafId: resolution.paneLeafId } : {})
|
|
}
|
|
}
|
|
|
|
return { paths: [...paths], target }
|
|
}
|
|
|
|
export function isNativeFileDropPayload(value: unknown): value is NativeFileDropPayload {
|
|
if (!value || typeof value !== 'object') {
|
|
return false
|
|
}
|
|
const payload = value as Record<string, unknown>
|
|
const { target } = payload
|
|
if (!isNativeFileDropTarget(target)) {
|
|
return false
|
|
}
|
|
|
|
if (target === 'rejected') {
|
|
return (
|
|
isNonNegativeFiniteNumber(payload.byteLength) &&
|
|
isNonNegativeFiniteNumber(payload.pathCount) &&
|
|
isNativeFileDropRejectedReason(payload.reason)
|
|
)
|
|
}
|
|
|
|
if (!isNativeFileDropPathList(payload.paths)) {
|
|
return false
|
|
}
|
|
if (validateNativeFileDropPaths(payload.paths).status !== 'accepted') {
|
|
return false
|
|
}
|
|
|
|
if (target === NATIVE_FILE_DROP_TARGET.terminal) {
|
|
return (
|
|
isOptionalNativeFileDropString(payload.tabId) &&
|
|
isOptionalNativeFileDropString(payload.paneLeafId)
|
|
)
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.fileExplorer) {
|
|
return typeof payload.destinationDir === 'string'
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.composer) {
|
|
return isOptionalNativeFileDropString(payload.scopeKey)
|
|
}
|
|
|
|
return (
|
|
target === NATIVE_FILE_DROP_TARGET.editor || target === NATIVE_FILE_DROP_TARGET.projectSidebar
|
|
)
|
|
}
|