Add file move capabilities to docs editor file tree

Drag-and-drop tree nodes to move files/folders between directories (desktop),
and right-click "Move to..." with searchable directory picker modal (desktop + mobile).

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-31 13:44:03 -06:00
parent c306e061ab
commit d7ab8f0d99
4 changed files with 304 additions and 0 deletions

View File

@ -30,6 +30,7 @@ import {
PlusOutlined,
CloseOutlined,
FileOutlined,
FolderOpenOutlined,
} from '@ant-design/icons';
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
import { isImageFile } from '@/hooks/useDocsEditor';
@ -52,6 +53,7 @@ import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
import { MoveToModal } from '@/components/docs/MoveToModal';
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import { YTextareaBinding } from '@/lib/y-textarea';
@ -259,6 +261,8 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
const [moveSourcePath, setMoveSourcePath] = useState('');
const {
fileTree,
@ -287,6 +291,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
onContentChange,
handleDelete,
handleModalOk,
handleMoveFile,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,
@ -430,6 +435,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
}
items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
);
return items;
@ -910,6 +916,14 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
setWikiLinkPickerOpen(false);
}}
/>
<MoveToModal
open={moveToModalOpen}
fileTree={fileTree}
sourcePath={moveSourcePath}
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
onClose={() => setMoveToModalOpen(false)}
/>
</>
);
}

View File

@ -0,0 +1,154 @@
import { useState, useMemo } from 'react';
import { Modal, Input, List, theme, Typography } from 'antd';
import { FolderOutlined, HomeOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api';
interface DirEntry {
path: string;
depth: number;
}
function collectDirs(nodes: FileNode[], exclude?: string, depth = 0): DirEntry[] {
const dirs: DirEntry[] = [];
for (const node of nodes) {
if (!node.isDirectory) continue;
if (exclude && (node.path === exclude || node.path.startsWith(exclude + '/'))) continue;
dirs.push({ path: node.path, depth });
if (node.children) dirs.push(...collectDirs(node.children, exclude, depth + 1));
}
return dirs;
}
interface MoveToModalProps {
open: boolean;
fileTree: FileNode[];
sourcePath: string;
onMove: (targetDir: string) => void;
onClose: () => void;
}
export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: MoveToModalProps) {
const { token } = theme.useToken();
const [search, setSearch] = useState('');
const currentParent = useMemo(() => {
const lastSlash = sourcePath.lastIndexOf('/');
return lastSlash >= 0 ? sourcePath.substring(0, lastSlash) : '';
}, [sourcePath]);
const isSourceDir = useMemo(() => {
function find(nodes: FileNode[]): boolean {
for (const n of nodes) {
if (n.path === sourcePath) return n.isDirectory;
if (n.isDirectory && n.children && find(n.children)) return true;
}
return false;
}
return find(fileTree);
}, [fileTree, sourcePath]);
const allDirs = useMemo(
() => collectDirs(fileTree, isSourceDir ? sourcePath : undefined),
[fileTree, sourcePath, isSourceDir],
);
const filtered = useMemo(() => {
if (!search.trim()) return allDirs;
const q = search.toLowerCase();
return allDirs.filter(d => d.path.toLowerCase().includes(q));
}, [allDirs, search]);
const handleSelect = (dir: string) => {
onMove(dir);
setSearch('');
};
const handleClose = () => {
onClose();
setSearch('');
};
const fileName = sourcePath.split('/').pop() || sourcePath;
return (
<Modal
title={`Move "${fileName}"`}
open={open}
onCancel={handleClose}
footer={null}
destroyOnHidden
width={420}
>
<Input.Search
placeholder="Search directories..."
value={search}
onChange={e => setSearch(e.target.value)}
allowClear
autoFocus
style={{ marginBottom: 12 }}
/>
<div style={{ maxHeight: 360, overflow: 'auto' }}>
{/* Root directory option */}
{(!search.trim() || '/ (root)'.includes(search.toLowerCase())) && (
<div
style={{
padding: '8px 12px',
cursor: currentParent === '' ? 'not-allowed' : 'pointer',
opacity: currentParent === '' ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 4,
transition: 'background 0.15s',
}}
onClick={() => currentParent !== '' && handleSelect('')}
onMouseEnter={e => { if (currentParent !== '') (e.currentTarget.style.background = token.colorBgTextHover); }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<HomeOutlined style={{ color: token.colorTextSecondary }} />
<span style={{ flex: 1 }}>/ (root)</span>
{currentParent === '' && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>current</Typography.Text>
)}
</div>
)}
<List
size="small"
dataSource={filtered}
locale={{ emptyText: 'No matching directories' }}
renderItem={item => {
const isCurrent = item.path === currentParent;
return (
<div
style={{
padding: '8px 12px',
paddingLeft: 12 + item.depth * 16,
cursor: isCurrent ? 'not-allowed' : 'pointer',
opacity: isCurrent ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 4,
transition: 'background 0.15s',
}}
onClick={() => !isCurrent && handleSelect(item.path)}
onMouseEnter={e => { if (!isCurrent) (e.currentTarget.style.background = token.colorBgTextHover); }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<FolderOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.path}
</div>
{isCurrent && (
<Typography.Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>current</Typography.Text>
)}
</div>
);
}}
/>
</div>
</Modal>
);
}

View File

@ -121,6 +121,7 @@ export interface UseDocsEditorReturn {
onContentChange: (value: string) => void;
handleDelete: (filePath: string) => void;
handleModalOk: () => Promise<void>;
handleMoveFile: (sourcePath: string, targetDir: string) => Promise<void>;
handleNewFileRoot: () => void;
handleNewFolderRoot: () => void;
refreshTree: () => void;
@ -377,6 +378,45 @@ export function useDocsEditor(): UseDocsEditorReturn {
setModalType(null);
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
const handleMoveFile = useCallback(async (sourcePath: string, targetDir: string) => {
const fileName = sourcePath.split('/').pop() || '';
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
if (newPath === sourcePath) return;
if (sourcePath === targetDir || targetDir.startsWith(sourcePath + '/')) {
messageApi.warning('Cannot move a folder into itself');
return;
}
try {
await api.post('/docs/files/rename', { from: sourcePath, to: newPath });
messageApi.success(`Moved to ${targetDir || '/'}`);
invalidateTreeCache();
setFileContentCache(prev => {
const next = new Map(prev);
// Move exact match
const cached = next.get(sourcePath);
next.delete(sourcePath);
if (cached) next.set(newPath, cached);
// Move children (for directory moves)
const prefix = sourcePath + '/';
for (const [key, val] of Array.from(next.entries())) {
if (key.startsWith(prefix)) {
next.delete(key);
next.set(newPath + key.substring(sourcePath.length), val);
}
}
return next;
});
if (selectedFile === sourcePath) {
setSelectedFile(newPath);
} else if (selectedFile && selectedFile.startsWith(sourcePath + '/')) {
setSelectedFile(newPath + selectedFile.substring(sourcePath.length));
}
fetchTree();
} catch {
messageApi.error('Failed to move');
}
}, [selectedFile, messageApi, fetchTree]);
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
@ -513,6 +553,7 @@ export function useDocsEditor(): UseDocsEditorReturn {
onContentChange,
handleDelete,
handleModalOk,
handleMoveFile,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,

View File

@ -63,6 +63,7 @@ import {
ShareAltOutlined,
LockOutlined,
HistoryOutlined,
FolderOpenOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -72,6 +73,7 @@ import { buildServiceUrl } from '@/lib/service-url';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import { useDocsEditor } from '@/hooks/useDocsEditor';
import { MobileDocsEditor } from '@/components/docs/MobileDocsEditor';
import { MoveToModal } from '@/components/docs/MoveToModal';
import type { FileNode, ServicesConfig } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
@ -623,6 +625,9 @@ export default function DocsPage() {
const [modalInput, setModalInput] = useState('');
const [contextPath, setContextPath] = useState<string>('');
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
const [moveSourcePath, setMoveSourcePath] = useState('');
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
@ -1412,6 +1417,7 @@ export default function DocsPage() {
}
items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
);
return items;
@ -1496,6 +1502,68 @@ export default function DocsPage() {
setModalType(null);
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
const handleMoveFile = useCallback(async (sourcePath: string, targetDir: string) => {
const fileName = sourcePath.split('/').pop() || '';
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
if (newPath === sourcePath) return;
if (sourcePath === targetDir || targetDir.startsWith(sourcePath + '/')) {
messageApi.warning('Cannot move a folder into itself');
return;
}
try {
await api.post('/docs/files/rename', { from: sourcePath, to: newPath });
messageApi.success(`Moved to ${targetDir || '/'}`);
invalidateTreeCache();
setFileContentCache(prev => {
const next = new Map(prev);
const cached = next.get(sourcePath);
next.delete(sourcePath);
if (cached) next.set(newPath, cached);
const prefix = sourcePath + '/';
for (const [key, val] of Array.from(next.entries())) {
if (key.startsWith(prefix)) {
next.delete(key);
next.set(newPath + key.substring(sourcePath.length), val);
}
}
return next;
});
if (selectedFile === sourcePath) {
setSelectedFile(newPath);
} else if (selectedFile && selectedFile.startsWith(sourcePath + '/')) {
setSelectedFile(newPath + selectedFile.substring(sourcePath.length));
}
fetchTree();
} catch {
messageApi.error('Failed to move');
}
}, [selectedFile, messageApi, fetchTree]);
const handleTreeNodeDrop = useCallback((info: {
node: TreeDataNode;
dragNode: TreeDataNode;
dropToGap: boolean;
}) => {
const dragPath = info.dragNode.key as string;
let targetDir: string;
if (info.dropToGap) {
const targetPath = info.node.key as string;
const lastSlash = targetPath.lastIndexOf('/');
targetDir = lastSlash >= 0 ? targetPath.substring(0, lastSlash) : '';
} else {
const targetPath = info.node.key as string;
if (info.node.isLeaf) {
const lastSlash = targetPath.lastIndexOf('/');
targetDir = lastSlash >= 0 ? targetPath.substring(0, lastSlash) : '';
} else {
targetDir = targetPath;
}
}
handleMoveFile(dragPath, targetDir);
}, [handleMoveFile]);
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
@ -1703,6 +1771,7 @@ export default function DocsPage() {
}, []);
const handleTreeDragOver = useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
}, []);
@ -1801,6 +1870,15 @@ export default function DocsPage() {
.docs-tree .ant-tree-list-holder-inner {
padding: 2px 0 !important;
}
.docs-tree .ant-tree-treenode.drag-over > .ant-tree-node-content-wrapper,
.docs-tree .ant-tree-treenode.drop-target:not(.drag-over-gap-top):not(.drag-over-gap-bottom) > .ant-tree-node-content-wrapper {
background: rgba(22, 119, 255, 0.15) !important;
outline: 1px solid rgba(22, 119, 255, 0.4);
border-radius: 3px !important;
}
.docs-tree .ant-tree-treenode.dragging {
opacity: 0.4;
}
.docs-filter .ant-input-affix-wrapper {
border-radius: 0 !important;
border: none !important;
@ -1960,6 +2038,15 @@ export default function DocsPage() {
showIcon={false}
showLine={false}
motion={false}
draggable={!filterQuery.trim() ? { icon: false, nodeDraggable: () => true } : false}
allowDrop={({ dragNode, dropNode }) => {
const dragPath = dragNode.key as string;
const dropPath = dropNode.key as string;
if (dragPath === dropPath) return false;
if (dropPath.startsWith(dragPath + '/')) return false;
return true;
}}
onDrop={handleTreeNodeDrop}
selectedKeys={selectedFile ? [selectedFile] : []}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys)}
@ -2434,6 +2521,14 @@ export default function DocsPage() {
}}
/>
<MoveToModal
open={moveToModalOpen}
fileTree={fileTree}
sourcePath={moveSourcePath}
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
onClose={() => setMoveToModalOpen(false)}
/>
{/* Custom right-click context menu with submenus */}
{ctxMenu && (
<div