feat(desktop): unify folder drag-and-drop across runtimes
This commit is contained in:
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import {
|
||||
containsFileDrop,
|
||||
extractDroppedDirectoryPaths,
|
||||
listenForNativeFolderDrops,
|
||||
listenForNativeFolderDropState,
|
||||
normalizeDroppedDirectoryPaths,
|
||||
supportsDesktopFolderDrop,
|
||||
} from "../native/desktop-file-drop"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
interface UseFolderDropOptions {
|
||||
enabled: Accessor<boolean>
|
||||
onDrop: (paths: string[]) => void | Promise<void>
|
||||
onInvalidDrop?: () => void
|
||||
}
|
||||
|
||||
interface FolderDropBindings {
|
||||
onDragEnter: (event: DragEvent) => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
onDragLeave: (event: DragEvent) => void
|
||||
onDrop: (event: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
isActive: Accessor<boolean>
|
||||
isSupported: boolean
|
||||
bind: FolderDropBindings
|
||||
} {
|
||||
const [isActive, setIsActive] = createSignal(false)
|
||||
const [dragDepth, setDragDepth] = createSignal(0)
|
||||
const isSupported = supportsDesktopFolderDrop()
|
||||
|
||||
function reset() {
|
||||
setDragDepth(0)
|
||||
setIsActive(false)
|
||||
}
|
||||
|
||||
async function handleResolvedPaths(paths: string[]) {
|
||||
reset()
|
||||
if (!options.enabled()) {
|
||||
return
|
||||
}
|
||||
const directoryPaths = await normalizeDroppedDirectoryPaths(paths)
|
||||
if (directoryPaths.length === 0) {
|
||||
options.onInvalidDrop?.()
|
||||
return
|
||||
}
|
||||
await options.onDrop(directoryPaths)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!options.enabled()) {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!isSupported) {
|
||||
return
|
||||
}
|
||||
|
||||
let disposeNativeDrop = () => {}
|
||||
let disposeNativeState = () => {}
|
||||
|
||||
void listenForNativeFolderDrops((paths) => {
|
||||
if (!options.enabled()) {
|
||||
return
|
||||
}
|
||||
void handleResolvedPaths(paths)
|
||||
}).then((dispose) => {
|
||||
disposeNativeDrop = dispose
|
||||
})
|
||||
|
||||
void listenForNativeFolderDropState((state) => {
|
||||
if (!options.enabled()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (state === "enter") {
|
||||
setIsActive(true)
|
||||
return
|
||||
}
|
||||
reset()
|
||||
}).then((dispose) => {
|
||||
disposeNativeState = dispose
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposeNativeDrop()
|
||||
disposeNativeState()
|
||||
})
|
||||
})
|
||||
|
||||
const bind: FolderDropBindings = {
|
||||
onDragEnter(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setDragDepth((prev) => prev + 1)
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragOver(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy"
|
||||
}
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragLeave(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const nextDepth = Math.max(0, dragDepth() - 1)
|
||||
setDragDepth(nextDepth)
|
||||
if (nextDepth === 0) {
|
||||
setIsActive(false)
|
||||
}
|
||||
},
|
||||
onDrop(event) {
|
||||
if (!isSupported) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!options.enabled()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const paths = extractDroppedDirectoryPaths(event)
|
||||
if (paths.length === 0) {
|
||||
reset()
|
||||
options.onInvalidDrop?.()
|
||||
return
|
||||
}
|
||||
|
||||
void handleResolvedPaths(paths)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isSupported,
|
||||
bind,
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Starting instance...",
|
||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||
|
||||
"folderSelection.drop.title": "Drop a folder to open it",
|
||||
"folderSelection.drop.subtitle": "Start a new instance in the dropped folder.",
|
||||
"folderSelection.drop.invalidTitle": "Couldn't open dropped item",
|
||||
"folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.",
|
||||
|
||||
"folderSelection.dialog.title": "Select Workspace",
|
||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Iniciando instancia...",
|
||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
||||
|
||||
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||
"folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado",
|
||||
"folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.",
|
||||
|
||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Démarrage de l'instance...",
|
||||
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
||||
|
||||
"folderSelection.drop.title": "Déposez un dossier pour l'ouvrir",
|
||||
"folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.",
|
||||
"folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé",
|
||||
"folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.",
|
||||
|
||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "インスタンスを起動中...",
|
||||
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
||||
|
||||
"folderSelection.drop.title": "フォルダをドロップして開く",
|
||||
"folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。",
|
||||
"folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした",
|
||||
"folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。",
|
||||
|
||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Запуск экземпляра…",
|
||||
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
||||
|
||||
"folderSelection.drop.title": "Перетащите папку, чтобы открыть ее",
|
||||
"folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.",
|
||||
"folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент",
|
||||
"folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.",
|
||||
|
||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "正在启动实例...",
|
||||
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
||||
|
||||
"folderSelection.drop.title": "拖放文件夹以打开",
|
||||
"folderSelection.drop.subtitle": "在拖放的文件夹中启动一个新实例。",
|
||||
"folderSelection.drop.invalidTitle": "无法打开拖放的项目",
|
||||
"folderSelection.drop.invalidMessage": "请拖放一个文件夹来启动新实例。",
|
||||
|
||||
"folderSelection.dialog.title": "选择工作区",
|
||||
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
||||
} as const
|
||||
|
||||
155
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
155
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { getLogger } from "../logger"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type NativeFolderDropState = "enter" | "leave"
|
||||
|
||||
interface TauriFolderDropPayload {
|
||||
paths?: unknown
|
||||
}
|
||||
|
||||
function normalizePathList(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return []
|
||||
}
|
||||
return input.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
}
|
||||
|
||||
function getFilePath(file: File): string | null {
|
||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||
return file.path
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||
return electronPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||
if (!api?.getDirectoryPaths || paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await api.getDirectoryPaths(paths)
|
||||
} catch (error) {
|
||||
log.error("[native] failed to validate dropped directory paths", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsDesktopFolderDrop(): boolean {
|
||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
||||
}
|
||||
|
||||
export function containsFileDrop(event: DragEvent): boolean {
|
||||
const types = event.dataTransfer?.types
|
||||
if (!types) {
|
||||
return false
|
||||
}
|
||||
return Array.from(types).includes("Files")
|
||||
}
|
||||
|
||||
export function extractDroppedDirectoryPaths(event: DragEvent): string[] {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directoryHints = new Set<string>()
|
||||
for (const item of Array.from(dataTransfer.items ?? [])) {
|
||||
if (item.kind !== "file") {
|
||||
continue
|
||||
}
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
if (!entry?.isDirectory) {
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
const filePath = file ? getFilePath(file) : null
|
||||
if (filePath) {
|
||||
directoryHints.add(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
const paths = new Set<string>()
|
||||
for (const file of Array.from(dataTransfer.files ?? [])) {
|
||||
const filePath = getFilePath(file)
|
||||
if (!filePath) {
|
||||
continue
|
||||
}
|
||||
if (directoryHints.size > 0 && !directoryHints.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
paths.add(filePath)
|
||||
}
|
||||
|
||||
return Array.from(paths)
|
||||
}
|
||||
|
||||
export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||
const uniquePaths = Array.from(new Set(paths.filter((path) => typeof path === "string" && path.trim().length > 0)))
|
||||
if (uniquePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
return resolveElectronDirectoryPaths(uniquePaths)
|
||||
}
|
||||
return uniquePaths
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventApi = window.__TAURI__?.event
|
||||
if (!eventApi?.listen) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const unlisten = await eventApi.listen("desktop:folder-drop", (event) => {
|
||||
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
||||
const paths = normalizePathList(payload.paths)
|
||||
if (paths.length > 0) {
|
||||
onDrop(paths)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unlisten()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("[native] failed to listen for folder-drop event", error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventApi = window.__TAURI__?.event
|
||||
if (!eventApi?.listen) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
||||
eventApi.listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||
eventApi.listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||
])
|
||||
return () => {
|
||||
unlistenEnter()
|
||||
unlistenLeave()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("[native] failed to listen for folder-drop state", error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user