mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
270 lines
7.8 KiB
TypeScript
270 lines
7.8 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 }
|
|
| { 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 }
|
|
| {
|
|
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
|
|
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
|
|
|
|
for (const entry of path) {
|
|
terminalPaneLeafId ??= entry.terminalPaneLeafId
|
|
const target = entry.nativeFileDropTarget
|
|
if (target === NATIVE_FILE_DROP_TARGET.terminal) {
|
|
return { target, tabId: entry.terminalTabId, paneLeafId: terminalPaneLeafId }
|
|
}
|
|
if (target === NATIVE_FILE_DROP_TARGET.editor || target === NATIVE_FILE_DROP_TARGET.composer) {
|
|
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 measureNativeFileDropPathBytes(paths: readonly string[]): number {
|
|
let byteLength = 0
|
|
for (const path of paths) {
|
|
byteLength += measureClipboardTextByteLength(path).byteLength
|
|
}
|
|
return byteLength
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
return (
|
|
target === NATIVE_FILE_DROP_TARGET.editor ||
|
|
target === NATIVE_FILE_DROP_TARGET.composer ||
|
|
target === NATIVE_FILE_DROP_TARGET.projectSidebar
|
|
)
|
|
}
|