From a8cbb5bd8d1540992e3d32835ac162c053f0ef8c Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Sat, 6 Jun 2026 00:26:01 +0100 Subject: [PATCH 1/2] fix: files drag drop --- .../src/pages/hosting/manage/Files.vue | 57 ++++++++++++- .../upload/FileUploadDragAndDrop.vue | 12 +-- .../src/layouts/shared/files-tab/layout.vue | 79 +++++++++++++++++++ .../files-tab/providers/file-manager.ts | 15 ++++ .../shared/files-tab/providers/index.ts | 2 +- .../layouts/wrapped/hosting/manage/files.vue | 3 + 6 files changed, 161 insertions(+), 7 deletions(-) diff --git a/apps/app-frontend/src/pages/hosting/manage/Files.vue b/apps/app-frontend/src/pages/hosting/manage/Files.vue index 9bd07ee2e9..79a507881d 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Files.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Files.vue @@ -2,14 +2,69 @@ import { injectModrinthClient, injectModrinthServerContext, + type NativeFileDropAdapter, ServersManageFilesPage, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' +import type { DragDropEvent } from '@tauri-apps/api/webview' +import { getCurrentWebview } from '@tauri-apps/api/webview' +import { readFile } from '@tauri-apps/plugin-fs' const client = injectModrinthClient() const { serverId } = injectModrinthServerContext() const queryClient = useQueryClient() +function getFileName(path: string) { + return path.split(/[\\/]/).pop() || 'file' +} + +function toLogicalPosition(position: { x: number; y: number }) { + const scale = window.devicePixelRatio || 1 + return { + x: position.x / scale, + y: position.y / scale, + } +} + +let nativeFileDropPaths: string[] = [] + +const nativeFileDrop: NativeFileDropAdapter = { + async listen(handler) { + return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => { + const payload = event.payload + + if (payload.type === 'leave') { + nativeFileDropPaths = [] + void handler({ + type: 'leave', + paths: [], + position: { x: 0, y: 0 }, + }) + return + } + + if (payload.type === 'enter' || payload.type === 'drop') { + nativeFileDropPaths = payload.paths + } + + void handler({ + type: payload.type, + paths: nativeFileDropPaths, + position: toLogicalPosition(payload.position), + }) + + if (payload.type === 'drop') { + nativeFileDropPaths = [] + } + }) + }, + async createFiles(paths) { + return await Promise.all( + paths.map(async (path) => new File([await readFile(path)], getFileName(path))), + ) + }, +} + try { await queryClient.ensureQueryData({ queryKey: ['files', serverId, '/'], @@ -22,5 +77,5 @@ try { diff --git a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue index c9b5ed6911..e62d0cfd02 100644 --- a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue +++ b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue @@ -7,10 +7,10 @@ >
@@ -18,7 +18,7 @@

{{ formatMessage(messages.dropToUpload, { - type: formatFileItemType(formatMessage, type?.toLocaleLowerCase(), true), + type: formatFileItemType(formatMessage, props.type?.toLocaleLowerCase(), true), }) }}

@@ -29,7 +29,7 @@ diff --git a/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts b/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts index 4eb5ba6e35..eab1984014 100644 --- a/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts +++ b/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts @@ -10,6 +10,20 @@ import type { UploadState, } from '../types' +export type NativeFileDropEvent = { + type: 'enter' | 'over' | 'drop' | 'leave' + paths: string[] + position: { + x: number + y: number + } +} + +export type NativeFileDropAdapter = { + listen: (handler: (event: NativeFileDropEvent) => void | Promise) => Promise<() => void> + createFiles: (paths: string[]) => Promise +} + export interface FileManagerContext { items: Ref loading: Ref @@ -33,6 +47,7 @@ export interface FileManagerContext { downloadFile: (path: string, fileName: string) => Promise uploadFiles: (files: File[]) => void + nativeFileDrop?: NativeFileDropAdapter cancelUpload?: () => void uploadState?: Ref | ComputedRef diff --git a/packages/ui/src/layouts/shared/files-tab/providers/index.ts b/packages/ui/src/layouts/shared/files-tab/providers/index.ts index 8772a59621..fd03656cea 100644 --- a/packages/ui/src/layouts/shared/files-tab/providers/index.ts +++ b/packages/ui/src/layouts/shared/files-tab/providers/index.ts @@ -1,2 +1,2 @@ -export type { FileManagerContext } from './file-manager' +export type { FileManagerContext, NativeFileDropAdapter, NativeFileDropEvent } from './file-manager' export { injectFileManager, provideFileManager } from './file-manager' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/files.vue b/packages/ui/src/layouts/wrapped/hosting/manage/files.vue index b62fe58237..bbc23d6b61 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/files.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/files.vue @@ -18,9 +18,11 @@ import { commonMessages } from '#ui/utils/common-messages' import FilePageLayout from '../../../shared/files-tab/layout.vue' import { provideFileManager } from '../../../shared/files-tab/providers/file-manager' +import type { NativeFileDropAdapter } from '../../../shared/files-tab/providers/file-manager' import type { EditingFile, FileItem } from '../../../shared/files-tab/types' const props = defineProps<{ + nativeFileDrop?: NativeFileDropAdapter showDebugInfo?: boolean showRefreshButton?: boolean }>() @@ -456,6 +458,7 @@ provideFileManager({ writeFile, downloadFile, uploadFiles, + nativeFileDrop: props.nativeFileDrop, cancelUpload, uploadState, refresh: refreshList, From b149b869c0d15fd16b0ac32b171462835fba7b3f Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Sun, 7 Jun 2026 10:24:05 +0100 Subject: [PATCH 2/2] fix: standardize drag and drop + fix files tab permissions --- .../src/pages/hosting/manage/Files.vue | 57 +----- apps/app-frontend/src/providers/setup.ts | 2 + .../src/providers/setup/file-drop.ts | 62 ++++++ apps/app/build.rs | 6 +- apps/app/src/api/files.rs | 14 ++ packages/ui/src/composables/file-drop.ts | 177 ++++++++++++++++++ packages/ui/src/composables/index.ts | 1 + .../upload/FileUploadDragAndDrop.vue | 53 ++---- .../src/layouts/shared/files-tab/layout.vue | 85 +-------- .../files-tab/providers/file-manager.ts | 15 -- .../shared/files-tab/providers/index.ts | 2 +- .../layouts/wrapped/hosting/manage/files.vue | 3 - packages/ui/src/providers/file-drop.ts | 19 ++ packages/ui/src/providers/index.ts | 1 + 14 files changed, 307 insertions(+), 190 deletions(-) create mode 100644 apps/app-frontend/src/providers/setup/file-drop.ts create mode 100644 packages/ui/src/composables/file-drop.ts create mode 100644 packages/ui/src/providers/file-drop.ts diff --git a/apps/app-frontend/src/pages/hosting/manage/Files.vue b/apps/app-frontend/src/pages/hosting/manage/Files.vue index 79a507881d..9bd07ee2e9 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Files.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Files.vue @@ -2,69 +2,14 @@ import { injectModrinthClient, injectModrinthServerContext, - type NativeFileDropAdapter, ServersManageFilesPage, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' -import type { DragDropEvent } from '@tauri-apps/api/webview' -import { getCurrentWebview } from '@tauri-apps/api/webview' -import { readFile } from '@tauri-apps/plugin-fs' const client = injectModrinthClient() const { serverId } = injectModrinthServerContext() const queryClient = useQueryClient() -function getFileName(path: string) { - return path.split(/[\\/]/).pop() || 'file' -} - -function toLogicalPosition(position: { x: number; y: number }) { - const scale = window.devicePixelRatio || 1 - return { - x: position.x / scale, - y: position.y / scale, - } -} - -let nativeFileDropPaths: string[] = [] - -const nativeFileDrop: NativeFileDropAdapter = { - async listen(handler) { - return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => { - const payload = event.payload - - if (payload.type === 'leave') { - nativeFileDropPaths = [] - void handler({ - type: 'leave', - paths: [], - position: { x: 0, y: 0 }, - }) - return - } - - if (payload.type === 'enter' || payload.type === 'drop') { - nativeFileDropPaths = payload.paths - } - - void handler({ - type: payload.type, - paths: nativeFileDropPaths, - position: toLogicalPosition(payload.position), - }) - - if (payload.type === 'drop') { - nativeFileDropPaths = [] - } - }) - }, - async createFiles(paths) { - return await Promise.all( - paths.map(async (path) => new File([await readFile(path)], getFileName(path))), - ) - }, -} - try { await queryClient.ensureQueryData({ queryKey: ['files', serverId, '/'], @@ -77,5 +22,5 @@ try { diff --git a/apps/app-frontend/src/providers/setup.ts b/apps/app-frontend/src/providers/setup.ts index 56a2ccc7eb..877aa3030b 100644 --- a/apps/app-frontend/src/providers/setup.ts +++ b/apps/app-frontend/src/providers/setup.ts @@ -1,6 +1,7 @@ import type { AbstractPopupNotificationManager, AbstractWebNotificationManager } from '@modrinth/ui' import { setupCreationModal } from './setup/creation-modal' +import { setupFileDropProvider } from './setup/file-drop' import { setupFilePickerProvider } from './setup/file-picker' import { setupInstanceImportProvider } from './setup/instance-import' import { setupTagsProvider } from './setup/tags' @@ -10,6 +11,7 @@ export function setupProviders( popupNotificationManager: AbstractPopupNotificationManager, ) { setupTagsProvider(notificationManager) + setupFileDropProvider() setupFilePickerProvider() setupInstanceImportProvider(notificationManager) diff --git a/apps/app-frontend/src/providers/setup/file-drop.ts b/apps/app-frontend/src/providers/setup/file-drop.ts new file mode 100644 index 0000000000..bfa9fde605 --- /dev/null +++ b/apps/app-frontend/src/providers/setup/file-drop.ts @@ -0,0 +1,62 @@ +import { provideFileDrop } from '@modrinth/ui' +import { invoke } from '@tauri-apps/api/core' +import type { DragDropEvent } from '@tauri-apps/api/webview' +import { getCurrentWebview } from '@tauri-apps/api/webview' + +function getFileName(path: string) { + return path.split(/[\\/]/).pop() || 'file' +} + +function toLogicalPosition(position: { x: number; y: number }) { + const scale = window.devicePixelRatio || 1 + return { + x: position.x / scale, + y: position.y / scale, + } +} + +async function readDraggedFile(path: string) { + const data = await invoke('plugin:files|file_read_dragged_file', { path }) + return new Uint8Array(data) +} + +export function setupFileDropProvider() { + let nativeFileDropPaths: string[] = [] + + provideFileDrop({ + async listenNativeFileDrop(handler) { + return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => { + const payload = event.payload + + if (payload.type === 'leave') { + nativeFileDropPaths = [] + void handler({ + type: 'leave', + paths: [], + position: { x: 0, y: 0 }, + }) + return + } + + if (payload.type === 'enter' || payload.type === 'drop') { + nativeFileDropPaths = payload.paths + } + + void handler({ + type: payload.type, + paths: nativeFileDropPaths, + position: toLogicalPosition(payload.position), + }) + + if (payload.type === 'drop') { + nativeFileDropPaths = [] + } + }) + }, + async createFilesFromNativePaths(paths) { + return await Promise.all( + paths.map(async (path) => new File([await readDraggedFile(path)], getFileName(path))), + ) + }, + }) +} diff --git a/apps/app/build.rs b/apps/app/build.rs index f4d8c14a7b..d98d01de4c 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -270,7 +270,11 @@ fn main() { .plugin( "files", InlinedPlugin::new() - .commands(&["file_extract_zip", "file_save_as"]) + .commands(&[ + "file_extract_zip", + "file_save_as", + "file_read_dragged_file", + ]) .default_permission( DefaultPermissionRule::AllowAllCommands, ), diff --git a/apps/app/src/api/files.rs b/apps/app/src/api/files.rs index dbd7a5980b..81cb235953 100644 --- a/apps/app/src/api/files.rs +++ b/apps/app/src/api/files.rs @@ -11,6 +11,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { .invoke_handler(tauri::generate_handler![ file_extract_zip, file_save_as, + file_read_dragged_file, ]) .build() } @@ -21,6 +22,19 @@ pub struct ExtractDryRunResult { conflicting_files: Vec, } +#[tauri::command] +pub async fn file_read_dragged_file(path: String) -> Result> { + let metadata = tokio::fs::metadata(&path).await?; + if !metadata.is_file() { + return Err(theseus::Error::from(theseus::ErrorKind::OtherError( + "Dropped path is not a file".to_string(), + )) + .into()); + } + + Ok(tokio::fs::read(path).await?) +} + #[tauri::command] pub async fn file_extract_zip( instance_path: &str, diff --git a/packages/ui/src/composables/file-drop.ts b/packages/ui/src/composables/file-drop.ts new file mode 100644 index 0000000000..f4c2bc1c6f --- /dev/null +++ b/packages/ui/src/composables/file-drop.ts @@ -0,0 +1,177 @@ +import type { ComputedRef, Ref } from 'vue' +import { computed, onMounted, onUnmounted, ref, unref } from 'vue' + +import type { NativeFileDropEvent } from '#ui/providers/file-drop' +import { injectFileDrop } from '#ui/providers/file-drop' + +type MaybeRef = T | Ref | ComputedRef + +export interface UseFileDropTargetOptions { + target: Ref + disabled?: MaybeRef + onFiles: (files: File[]) => void | Promise + onError?: (error: unknown) => void +} + +function isFileDrag(event: DragEvent) { + const dataTransfer = event.dataTransfer + if (!dataTransfer) return false + if (Array.from(dataTransfer.types).includes('Files')) return true + return Array.from(dataTransfer.items ?? []).some((item) => item.kind === 'file') +} + +function getDroppedFiles(event: DragEvent) { + const files = Array.from(event.dataTransfer?.files ?? []) + if (files.length > 0) return files + + return Array.from(event.dataTransfer?.items ?? []) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) +} + +export function useFileDropTarget(options: UseFileDropTargetOptions) { + const fileDrop = injectFileDrop(null) + const domDragCounter = ref(0) + const domDragging = ref(false) + const nativeDragging = ref(false) + + const disabled = computed(() => unref(options.disabled) ?? false) + const isDragging = computed(() => domDragging.value || nativeDragging.value) + + function resetDomDrag() { + domDragCounter.value = 0 + domDragging.value = false + } + + function isPositionOverTarget(position: NativeFileDropEvent['position']) { + const element = options.target.value + if (!element) return false + + const rect = element.getBoundingClientRect() + return ( + position.x >= rect.left && + position.x <= rect.right && + position.y >= rect.top && + position.y <= rect.bottom + ) + } + + function canHandleNativeFileDrop(event: NativeFileDropEvent) { + return event.paths.length > 0 && !disabled.value && isPositionOverTarget(event.position) + } + + async function handleNativeFileDrop(event: NativeFileDropEvent) { + if (event.type === 'leave') { + nativeDragging.value = false + return + } + + const canDrop = canHandleNativeFileDrop(event) + + if (event.type === 'enter' || event.type === 'over') { + nativeDragging.value = canDrop + return + } + + nativeDragging.value = false + if (!canDrop || !fileDrop) return + + try { + const files = await fileDrop.createFilesFromNativePaths(event.paths) + await options.onFiles(files) + } catch (error) { + options.onError?.(error) + } + } + + function handleDragEnter(event: DragEvent) { + if (disabled.value || !isFileDrag(event)) return + + event.preventDefault() + domDragCounter.value++ + domDragging.value = true + } + + function handleDragOver(event: DragEvent) { + if (disabled.value || !isFileDrag(event)) return + + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + } + + function handleDragLeave(event: DragEvent) { + if (!domDragging.value) return + + event.preventDefault() + domDragCounter.value-- + if (domDragCounter.value <= 0) { + resetDomDrag() + } + } + + async function handleDrop(event: DragEvent) { + if (!domDragging.value && !isFileDrag(event)) return + + event.preventDefault() + resetDomDrag() + + if (disabled.value) return + + const files = getDroppedFiles(event) + if (files.length === 0) return + + try { + await options.onFiles(files) + } catch (error) { + options.onError?.(error) + } + } + + let nativeFileDropUnlisten: (() => void) | null = null + let unmounted = false + + async function setupNativeFileDrop() { + if (!fileDrop) return + + let unlisten: () => void + try { + unlisten = await fileDrop.listenNativeFileDrop(handleNativeFileDrop) + } catch { + return + } + + if (unmounted) { + unlisten() + return + } + + nativeFileDropUnlisten = unlisten + } + + onMounted(() => { + void setupNativeFileDrop() + }) + + onUnmounted(() => { + unmounted = true + nativeDragging.value = false + resetDomDrag() + if (nativeFileDropUnlisten) { + nativeFileDropUnlisten() + nativeFileDropUnlisten = null + } + }) + + return { + isDragging, + dropTargetProps: { + onDragenter: handleDragEnter, + onDragover: handleDragOver, + onDragleave: handleDragLeave, + onDrop: handleDrop, + }, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index c476e133e1..284130a27d 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -1,5 +1,6 @@ export * from './debug-logger' export * from './dynamic-font-size' +export * from './file-drop' export * from './format-bytes' export * from './format-date-time' export * from './format-money' diff --git a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue index e62d0cfd02..91b0c9a14c 100644 --- a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue +++ b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue @@ -1,9 +1,10 @@