files can be dragged between windows

A custom MIME type on the DataTransfer survives between tabs and windows of the SAME browser, which is
the case somebody actually has: two Officer windows side by side, a folder in each. So a row carries
`application/x-officer-files` with its paths, a folder accepts the drop, and the file moves.

What it cannot do is cross different browsers, or exchange with Finder — dragging out to the desktop
needs a Chrome-only API and only works outward. Worth knowing before expecting it.

`Files` is deliberately untouched. That is the type the OS puts on a real file drag and the existing
upload path keys off it; a drag from inside carries only ours, so the two never meet. The container's
drop handler checks for ours FIRST, because a drag from inside has no `Files` and would otherwise fall
through to "read the dropped files", find none, and do nothing at all.

Dragging also writes the clipboard, which is cheap now that it is per-user server state: if a drop
lands somewhere that cannot read the payload, the paths are still held and a paste finishes the job.
The drag is a gesture over the same mechanism rather than a second one beside it.

Dragging one of several selected rows takes the whole selection; dragging an unselected row takes just
that one. Dropping a folder into itself or into its own child is refused here rather than by the
server, and the comparison uses a trailing separator so /a/bc is not read as living inside /a/b.

`onDragOver` calls preventDefault, which is the single line whose absence makes a drop target look
dead with no error anywhere. `dropEffect` is set by the target rather than the source, because only
the target knows whether it can accept — the cursor says "move" over a folder and "no" over a file,
which is the only feedback a drag has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:58:38 +01:00
co-authored by Claude Opus 5
parent e3071f5d50
commit a53c07d71f
3 changed files with 136 additions and 0 deletions
@@ -305,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onCompress={handleCompress} onCompress={handleCompress}
onDuplicate={handleDuplicate} onDuplicate={handleDuplicate}
onPasteInto={handlePasteInto} onPasteInto={handlePasteInto}
onDragStart={fileBrowserManager.handleEntryDragStart}
onDropInto={fileBrowserManager.handleEntryDrop}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
isPinned={isPinned(entryPath(entry.name))} isPinned={isPinned(entryPath(entry.name))}
canPaste={clipboard !== null} canPaste={clipboard !== null}
@@ -95,6 +95,15 @@ export type FileItemProps = {
onCompress: (entry: DirEntry) => void; onCompress: (entry: DirEntry) => void;
onDuplicate: (entry: DirEntry) => void; onDuplicate: (entry: DirEntry) => void;
onPasteInto: (entry: DirEntry) => void; onPasteInto: (entry: DirEntry) => void;
/**
* Dragging this row, and dropping onto it.
*
* A custom MIME type on the DataTransfer survives between windows of the same browser, which is what
* makes "drag from one Officer window into another" work at all. See useFileBrowserApp for the type
* and why `Files` is left alone.
*/
onDragStart: (ev: React.DragEvent, entry: DirEntry) => void;
onDropInto: (ev: React.DragEvent, entry: DirEntry) => Promise<void>;
onTogglePin: (entry: DirEntry) => void; onTogglePin: (entry: DirEntry) => void;
isPinned: boolean; isPinned: boolean;
canPaste: boolean; canPaste: boolean;
@@ -608,6 +617,8 @@ export const FileItem = ({
onCompress, onCompress,
onDuplicate, onDuplicate,
onPasteInto, onPasteInto,
onDragStart,
onDropInto,
onTogglePin, onTogglePin,
isPinned, isPinned,
canPaste, canPaste,
@@ -780,6 +791,24 @@ export const FileItem = ({
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()} onPointerDown={(ev) => ev.stopPropagation()}
draggable
onDragStart={(ev) => onDragStart(ev, entry)}
/*
A folder is a drop target; a file is not. `onDragOver` must preventDefault or the browser
refuses the drop entirely — that is the single line whose absence makes a target look
dead, with no error anywhere.
`dropEffect` is set here rather than at dragstart because only the target knows whether
it can accept: the cursor says "move" over a folder and "no" over a file, which is the
only feedback a drag has.
*/
onDragOver={(ev) => {
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
ev.preventDefault();
ev.stopPropagation();
ev.dataTransfer.dropEffect = 'move';
}}
onDrop={(ev) => void onDropInto(ev, entry)}
> >
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} /> <Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{listVisual}</span> <span className="shrink-0">{listVisual}</span>
@@ -827,6 +856,15 @@ export const FileItem = ({
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()} onPointerDown={(ev) => ev.stopPropagation()}
draggable
onDragStart={(ev) => onDragStart(ev, entry)}
onDragOver={(ev) => {
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
ev.preventDefault();
ev.stopPropagation();
ev.dataTransfer.dropEffect = 'move';
}}
onDrop={(ev) => void onDropInto(ev, entry)}
> >
<div className="absolute top-1 left-1"> <div className="absolute top-1 left-1">
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} /> <Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
@@ -1037,6 +1037,78 @@ export const useFileBrowserApp = (
} }
}; };
/*
Dragging rows between windows.
── The MIME type is the whole trick ──
A custom type on the DataTransfer survives between tabs and windows of the SAME browser, which is
the case somebody actually has: two Officer windows side by side, one folder in each. It does not
survive between different browsers, and it cannot exchange with Finder — dragging out to the
desktop needs a Chrome-only API and only works outward.
`Files` is deliberately untouched. That is the type the OS puts on a real file drag, and the
existing upload path keys off it; a drag from inside carries only ours, so the two never meet.
── It also writes the clipboard ──
Belt and braces, and cheap now that the clipboard is per-user server state: if a drop lands
somewhere that cannot read the payload, the paths are still held and a paste finishes the job. The
drag becomes a gesture over the same mechanism rather than a second one beside it.
*/
const DRAG_TYPE = 'application/x-officer-files';
const handleEntryDragStart = (ev: React.DragEvent, entry: DirEntry) => {
// Dragging one of several selected rows takes the whole selection; dragging an unselected row
// takes just that one, which is what every file manager does and what a hand expects.
const paths = selected.has(entry.name)
? [...selected].map((name) => entryPath(name))
: [entryPath(entry.name)];
ev.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths));
// `move` so the cursor shows what will happen. The drop decides for real; this is the hint.
ev.dataTransfer.effectAllowed = 'move';
setClipboard({ paths, mode: 'cut' });
};
/** Paths from a drag that started in an Officer window, or null if this is something else. */
const draggedPaths = (ev: React.DragEvent): string[] | null => {
if (!ev.dataTransfer.types.includes(DRAG_TYPE)) return null;
try {
const parsed = JSON.parse(ev.dataTransfer.getData(DRAG_TYPE));
return Array.isArray(parsed) && parsed.every((p) => typeof p === 'string') ? parsed : null;
} catch {
return null;
}
};
/**
* Drop onto a folder row — move into it.
*
* Refuses a folder being dropped into itself or into its own child, which the server would reject
* anyway but with an error rather than a shrug. Comparing with a trailing separator so `/a/bc` is
* not read as living inside `/a/b`.
*/
const handleEntryDrop = async (ev: React.DragEvent, entry: DirEntry) => {
const paths = draggedPaths(ev);
if (!paths || entry.type !== 'directory') return;
ev.preventDefault();
ev.stopPropagation();
const destination = entryPath(entry.name);
const into = paths.filter((path) => destination !== path && !destination.startsWith(`${path}/`));
if (into.length === 0) return;
try {
await files.move(into, destination);
setClipboard(null);
await refresh();
toast.success(t('files.pastedItems', { count: into.length }));
} catch {
toast.error(t('files.pasteFailed'));
}
};
const handleDragEnter = (ev: React.DragEvent) => { const handleDragEnter = (ev: React.DragEvent) => {
ev.preventDefault(); ev.preventDefault();
dragCounter.current++; dragCounter.current++;
@@ -1062,6 +1134,28 @@ export const useFileBrowserApp = (
dragCounter.current = 0; dragCounter.current = 0;
setDragging(false); setDragging(false);
/*
A drag that began in an Officer window, dropped on this folder's background.
Checked before the upload path, because a drag from inside carries no `Files` and would
otherwise fall through to "read the dropped files", find none, and do nothing at all.
*/
const dragged = draggedPaths(ev);
if (dragged) {
const into = dragged.filter((path) => currentPath !== path && !currentPath.startsWith(`${path}/`));
// Dropping a folder into the folder it already lives in. Nothing to do, and nothing to say.
if (into.length === 0) return;
try {
await files.move(into, currentPath);
setClipboard(null);
await refresh();
toast.success(t('files.pastedItems', { count: into.length }));
} catch {
toast.error(t('files.pasteFailed'));
}
return;
}
const items = ev.dataTransfer.items; const items = ev.dataTransfer.items;
if (!items || items.length === 0) return; if (!items || items.length === 0) return;
@@ -1155,6 +1249,8 @@ export const useFileBrowserApp = (
// Selection // Selection
selected, selected,
setSelected, setSelected,
handleEntryDragStart,
handleEntryDrop,
clipboard, clipboard,
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale // Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
// clipboard is something a person has to be able to clear rather than wait out. // clipboard is something a person has to be able to clear rather than wait out.