diff --git a/webui/package.json b/webui/package.json index 4f6042c..533f5ea 100644 --- a/webui/package.json +++ b/webui/package.json @@ -13,6 +13,9 @@ }, "./styles/file-manager.css": "./src/styles/file-manager.css" }, + "scripts": { + "test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs" + }, "peerDependencies": { "@vitejs/plugin-react": "^4.3.4", "vite": "^6.0.6", diff --git a/webui/scripts/test-file-drop-target-structure.mjs b/webui/scripts/test-file-drop-target-structure.mjs new file mode 100644 index 0000000..459465d --- /dev/null +++ b/webui/scripts/test-file-drop-target-structure.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8"); +const normalized = source.replace(/\s+/g, " "); + +function assertIncludes(fragment, message) { + if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message); +} + +assertIncludes( + "const target = options.target ?? currentActionTarget();", + "uploads must prefer the explicit target supplied by the initiating interaction" +); +assertIncludes( + "const targetSpace = target ? findSpace(target.spaceId) : null;", + "the upload owner must be resolved from the selected target" +); +assertIncludes( + "owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: target.folderPath,", + "the upload request must use the selected target owner and folder" +); +assertIncludes( + "async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) { await handleFilesUpload(fileList, { target }); }", + "external drops must pass their concrete target without asynchronous dialog-state mutation" +); +assertIncludes( + "await uploadExternalFilesToTarget(event.dataTransfer.files, target);", + "drop handling must forward the target on which the files were dropped" +); +assertIncludes( + "onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })}", + "the dialog picker/drop zone must snapshot its selected target for upload" +); + +console.log("Files upload target routing structure is intact."); diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx index 5a5aed5..da75439 100644 --- a/webui/src/features/files/FilesPage.tsx +++ b/webui/src/features/files/FilesPage.tsx @@ -439,6 +439,16 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut return activeDialogTarget; } + function uploadRejectedReason(target: FileActionTarget | null): string { + if (busy || uploadActive) return "Another file operation is already running. Wait until it finishes before dropping files."; + if (!canUpload) return "You do not have permission to upload files here."; + if (!target) return "Choose a destination folder before dropping files."; + const targetSpace = findSpace(target.spaceId); + if (!targetSpace) return "The selected upload destination is no longer available."; + if (isConnectorSpace(targetSpace)) return "Files cannot be uploaded into connector spaces from this view."; + return "The dropped item cannot be uploaded here."; + } + function openDialog(kind: DialogKind, target: FileActionTarget | null = null) { if ((kind === "upload" || kind === "connector-sync") && !canUpload) return; if (kind === "connector-space" && !canOrganize) return; @@ -615,10 +625,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) { - if (!canUpload) return; const target = options.target ?? currentActionTarget(); const targetSpace = target ? findSpace(target.spaceId) : null; - if (!target || !targetSpace || isConnectorSpace(targetSpace)) return; + if (busy || uploadActive || !canUpload || !target || !targetSpace || isConnectorSpace(targetSpace)) { + setError(uploadRejectedReason(target)); + return; + } const selected = Array.from(fileList); if (selected.length === 0) return; if (!options.bypassConflictDialog && !unpackZip) { @@ -678,13 +690,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) { - const previousTarget = dialogTarget; - setDialogTarget(target); - try { - await handleFilesUpload(fileList); - } finally { - setDialogTarget(previousTarget); - } + await handleFilesUpload(fileList, { target }); } async function openConnectorSyncDialog(target: FileActionTarget | null = toolbarTarget()) { @@ -1339,8 +1345,20 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut setDropTargetKey(reason || noop ? "" : dropTargetId(target)); return; } - event.dataTransfer.dropEffect = canUpload ? "copy" : "none"; - setDropTargetKey(canUpload ? dropTargetId(target) : ""); + event.dataTransfer.dropEffect = canUpload && !busy && !uploadActive ? "copy" : "none"; + setDropTargetKey(canUpload && !busy && !uploadActive ? dropTargetId(target) : ""); + } + + function handleCurrentFolderDragOver(event: ReactDragEvent) { + const target = toolbarTarget(); + if (!target) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "none"; + setDropTargetKey(""); + return; + } + handleDropTargetDragOver(event, target); } async function handleDropOnTarget(event: ReactDragEvent, target: FileActionTarget) { @@ -1348,7 +1366,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut event.stopPropagation(); setDragActive(false); setDropTargetKey(""); - if (isConnectorSpace(findSpace(target.spaceId))) return; + const hasDroppedFiles = event.dataTransfer.files.length > 0; + if (isConnectorSpace(findSpace(target.spaceId))) { + if (hasDroppedFiles) setError(uploadRejectedReason(target)); + return; + } if (isInternalDrag(event)) { if (!canOrganize) return; const state = internalDrag ?? parseDragState(event.dataTransfer.getData(INTERNAL_DRAG_TYPE)); @@ -1361,9 +1383,27 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut await runTransfer(event.ctrlKey ? "copy" : "move", state.sourceSpaceId, { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) }, target); return; } - if (canUpload && event.dataTransfer.files.length > 0) { + if (hasDroppedFiles) { + if (!canUpload || busy || uploadActive) { + setError(uploadRejectedReason(target)); + return; + } await uploadExternalFilesToTarget(event.dataTransfer.files, target); + return; } + setError("Drop one or more files from your computer, or drag files and folders from this file space."); + } + + async function handleDropOnCurrentFolder(event: ReactDragEvent) { + const target = toolbarTarget(); + if (!target) { + event.preventDefault(); + event.stopPropagation(); + setDropTargetKey(""); + setError(uploadRejectedReason(null)); + return; + } + await handleDropOnTarget(event, target); } function clearDropState() { @@ -1940,6 +1980,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } + const currentFolderDropTarget = toolbarTarget(); + const currentFolderDropActive = currentFolderDropTarget ? dropTargetKey === dropTargetId(currentFolderDropTarget) : false; + return (
{error && @@ -2051,9 +2094,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut renderConnectorSpaceContent() :
setFileListScrollTop(event.currentTarget.scrollTop)} + onDragOver={handleCurrentFolderDragOver} + onDragLeave={clearDropState} + onDrop={(event) => void handleDropOnCurrentFolder(event)} onContextMenu={(event) => openContextMenu(event, "empty")} onClick={(event) => { if (event.target === event.currentTarget) applySelectionKeys(new Set()); @@ -2179,13 +2225,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut {dialog === "upload" && handleFilesUpload(files)} /> + onRejectedDrop={(reason) => setError(reason === "disabled" ? uploadRejectedReason(activeDialogTarget) : "The browser did not provide readable file data for this drop. Use the file picker, or drag local files from a file manager that exposes file contents to the browser.")} + onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })} />