fix(files): route drops through the selected target

This commit is contained in:
2026-07-20 16:57:34 +02:00
parent f3210234d3
commit 8c5f149f07
3 changed files with 101 additions and 16 deletions

View File

@@ -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",

View File

@@ -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.");

View File

@@ -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<HTMLElement>) {
const target = toolbarTarget();
if (!target) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "none";
setDropTargetKey("");
return;
}
handleDropTargetDragOver(event, target);
}
async function handleDropOnTarget(event: ReactDragEvent<HTMLElement>, 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<HTMLElement>) {
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 (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
{error &&
@@ -2051,9 +2094,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
renderConnectorSpaceContent() :
<div
className="file-list-drop-target"
className={`file-list-drop-target ${currentFolderDropActive ? "is-drop-target" : ""}`}
ref={fileListViewportRef}
onScroll={(event) => 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<EntrySelectionKey>());
@@ -2179,13 +2225,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
{dialog === "upload" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
<FileDropZone
disabled={busy || !activeDialogSpace || !canUpload}
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
busy={uploadActive}
progress={visibleUploadProgress}
busyLabel={uploadBusyLabel}
progressLabel={uploadProgressLabel}
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
onFiles={(files) => 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 })} />
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
<div className="field-block">