diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 57a145f1..2c4f4e1a 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -305,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { onCompress={handleCompress} onDuplicate={handleDuplicate} onPasteInto={handlePasteInto} + onDragStart={fileBrowserManager.handleEntryDragStart} + onDropInto={fileBrowserManager.handleEntryDrop} onTogglePin={handleTogglePin} isPinned={isPinned(entryPath(entry.name))} canPaste={clipboard !== null} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 5c927c6f..2c92a307 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -95,6 +95,15 @@ export type FileItemProps = { onCompress: (entry: DirEntry) => void; onDuplicate: (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; onTogglePin: (entry: DirEntry) => void; isPinned: boolean; canPaste: boolean; @@ -608,6 +617,8 @@ export const FileItem = ({ onCompress, onDuplicate, onPasteInto, + onDragStart, + onDropInto, onTogglePin, isPinned, canPaste, @@ -780,6 +791,24 @@ export const FileItem = ({ onClick={handleClick} onDoubleClick={handleDoubleClick} 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)} > onSelect(entry, ev)} /> {listVisual} @@ -827,6 +856,15 @@ export const FileItem = ({ onClick={handleClick} onDoubleClick={handleDoubleClick} 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)} >
onSelect(entry, ev)} /> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 2706fc3d..71b5f192 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -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) => { ev.preventDefault(); dragCounter.current++; @@ -1062,6 +1134,28 @@ export const useFileBrowserApp = ( dragCounter.current = 0; 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; if (!items || items.length === 0) return; @@ -1155,6 +1249,8 @@ export const useFileBrowserApp = ( // Selection selected, setSelected, + handleEntryDragStart, + handleEntryDrop, clipboard, // 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.