From 51d81f82fd8f99493e07003a7dd1607b08684ad9 Mon Sep 17 00:00:00 2001 From: grml Date: Mon, 8 Jun 2026 01:32:14 +0800 Subject: [PATCH] feat: added drag and drop for directories and file tree --- src/App.vue | 30 ++++++++++++++++---- src/components/FileExplorer/Directory.vue | 14 +++++++-- src/components/FileExplorer/File.vue | 9 +++++- src/components/FileExplorer/FileExplorer.vue | 1 + src/libs/tauri/DragDrop.ts | 29 +++++++++++++++++++ 5 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 src/libs/tauri/DragDrop.ts diff --git a/src/App.vue b/src/App.vue index 527638a5e..39398b766 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,20 +15,34 @@ onMounted(() => { setup() }) -async function drop(event: DragEvent) { +function drop(event: DragEvent) { const items = event.dataTransfer?.items if (!items) return - const promises: Promise[] = [] + // DataTransferItem entries are only valid synchronously inside the event handler, so collect everything + // before awaiting. Chromium/web exposes the File System Access API; WebKitGTK (the native build) does not, + // so we fall back to plain File objects there. + const handlePromises: Promise[] = [] + const files: File[] = [] for (const item of items) { - if (item.kind === 'file') { - promises.push(item.getAsFileSystemHandle()) + if (item.kind !== 'file') continue + + if (typeof (item as any).getAsFileSystemHandle === 'function') { + handlePromises.push((item as any).getAsFileSystemHandle()) + } else { + const file = item.getAsFile() + + if (file) files.push(file) } } - const handles = (await Promise.all(promises)).filter((file) => file !== null) + importDropped(handlePromises, files) +} + +async function importDropped(handlePromises: Promise[], files: File[]) { + const handles = (await Promise.all(handlePromises)).filter((handle) => handle !== null) for (const handle of handles) { if (handle.kind === 'file') { @@ -36,11 +50,15 @@ async function drop(event: DragEvent) { } else { const entry = await ImportedDirectoryEntry.fromHandle(handle as FileSystemDirectoryHandle) - if (!entry) return + if (!entry) continue await ImporterManager.importDirectory(entry) } } + + for (const file of files) { + await ImporterManager.importFile(new ImportedFileEntry('/' + file.name, await file.arrayBuffer())) + } } diff --git a/src/components/FileExplorer/Directory.vue b/src/components/FileExplorer/Directory.vue index 410d0d849..f6a8c7c45 100644 --- a/src/components/FileExplorer/Directory.vue +++ b/src/components/FileExplorer/Directory.vue @@ -79,7 +79,13 @@ const draggingOver = computed(() => draggingCount.value > 0) const draggingLocation: Ref<'inside' | 'above' | 'below'> = ref('inside') let expandTimeout: number | null = null -function dragStart() { +function dragStart(event: DragEvent) { + // WebKitGTK (and Firefox) cancel a drag immediately unless data is set on the transfer in dragstart. + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', props.path) + } + requestAnimationFrame(() => { FileExplorer.draggedItem.value = new BaseEntry(props.path, 'directory') }) @@ -143,7 +149,8 @@ function dragOver(event: DragEvent) { if (y < minY + height / 2) { draggingLocation.value = 'above' - } else if (y > minY + height / 2 && y < containerMinY + cotnainerHeight - 20) { + } else if (entries.value.length === 0 || y < containerMinY + cotnainerHeight - 20) { + // Empty folders have no children rows, so the "inside" zone would otherwise collapse to nothing. draggingLocation.value = 'inside' } else { draggingLocation.value = 'below' @@ -190,6 +197,7 @@ function drop(event: DragEvent) { class="flex items-center gap-2 cursor-pointer transition-colors duration-100 ease-out rounded pl-1" :class="{ 'hover:bg-background-tertiary': !FileExplorer.draggedItem.value, + 'opacity-50': preview, }" @click="expanded = !expanded" @contextmenu.prevent.stop="contextMenu?.open" @@ -211,7 +219,7 @@ function drop(event: DragEvent) { 'pl-3': get('fileExplorerIndentation') === 'large', 'pl-6': get('fileExplorerIndentation') === 'x-large', }" - v-if="expanded && entries.length > 0" + v-if="(expanded && entries.length > 0) || (draggingOver && draggingLocation === 'inside')" >
{ FileExplorer.draggedItem.value = new BaseEntry(props.path, 'file') }) @@ -71,6 +77,7 @@ onMounted(() => { class="flex items-center gap-2 cursor-pointer transition-colors duration-100 ease-out rounded pl-1" :class="{ 'hover:bg-background-tertiary': !FileExplorer.draggedItem.value, + 'opacity-50': preview, }" @click="click" @contextmenu.prevent.stop="contextMenu?.open" diff --git a/src/components/FileExplorer/FileExplorer.vue b/src/components/FileExplorer/FileExplorer.vue index a2d63ac62..c909104a2 100644 --- a/src/components/FileExplorer/FileExplorer.vue +++ b/src/components/FileExplorer/FileExplorer.vue @@ -242,6 +242,7 @@ function drop(event: DragEvent) { @contextmenu.prevent="contextMenu?.open" @dragenter="dragEnter" @dragleave="dragLeave" + @dragover.prevent @drop="drop" >
diff --git a/src/libs/tauri/DragDrop.ts b/src/libs/tauri/DragDrop.ts new file mode 100644 index 000000000..121692fb4 --- /dev/null +++ b/src/libs/tauri/DragDrop.ts @@ -0,0 +1,29 @@ +import { getCurrentWebview } from '@tauri-apps/api/webview' +import { readFile, stat } from '@tauri-apps/plugin-fs' +import { basename } from 'pathe' +import { ImporterManager } from '@/libs/import/ImporterManager' +import { ImportedDirectoryEntry, ImportedFileEntry } from '@/libs/fileSystem/FileSystem' +import { TauriFileSystem } from '@/libs/fileSystem/TauriFileSystem' + +// Wires up native file drag and drop for the Tauri build. +// With `dragDropEnabled: true` the webview no longer receives HTML5 file drop events; instead Tauri emits a drag-drop event carrying the absolute paths of the dropped items. +export async function setupTauriDragDrop() { + await getCurrentWebview().onDragDropEvent(async (event) => { + if (event.payload.type !== 'drop') return + + for (const path of event.payload.paths) { + const info = await stat(path) + + if (info.isDirectory) { + const directoryFileSystem = new TauriFileSystem() + directoryFileSystem.setBasePath(path) + + await ImporterManager.importDirectory(new ImportedDirectoryEntry('/' + basename(path), directoryFileSystem)) + } else { + const data = await readFile(path) + + await ImporterManager.importFile(new ImportedFileEntry('/' + basename(path), data.buffer as ArrayBuffer)) + } + } + }) +} \ No newline at end of file