diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index 0db0d16..3f714fa 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -13,6 +13,7 @@ import { useSettingsStore } from '../stores/settings.store'; import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ 导入焦点切换 Store +++ import { useFileManagerContextMenu } from '../composables/file-manager/useFileManagerContextMenu'; // +++ 导入上下文菜单 Composable +++ import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection'; // +++ 导入选择 Composable +++ +import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop'; // +++ 导入拖放 Composable +++ // WebSocket composable 不再直接使用 import FileUploadPopup from './FileUploadPopup.vue'; import FileManagerContextMenu from './FileManagerContextMenu.vue'; // +++ 导入上下文菜单组件 +++ @@ -129,7 +130,11 @@ const fileInputRef = ref(null); // const contextMenuPosition = ref({ x: 0, y: 0 }); // const contextMenuItems = ref void; disabled?: boolean }>>([]); // const contextTargetItem = ref(null); -const isDraggingOver = ref(false); +// --- 拖放状态 (移至 useFileManagerDragAndDrop) --- +// const isDraggingOver = ref(false); +// const draggedItem = ref(null); +// const dragOverTarget = ref(null); +// const scrollIntervalId = ref(null); const sortKey = ref('filename'); const sortDirection = ref<'asc' | 'desc'>('asc'); const initialLoadDone = ref(false); @@ -141,10 +146,10 @@ const searchInputRef = ref(null); // 新增:搜索输 const pathInputRef = ref(null); const editablePath = ref(''); // const contextMenuRef = ref(null); // <-- 移至 useFileManagerContextMenu -const draggedItem = ref(null); // 新增:存储被拖拽的项 -const dragOverTarget = ref(null); // 新增:存储当前拖拽悬停的目标文件夹名称 -const fileListContainerRef = ref(null); // 新增:文件列表容器引用 -const scrollIntervalId = ref(null); // 新增:自动滚动计时器 ID +// const draggedItem = ref(null); // 已移至 useFileManagerDragAndDrop +// const dragOverTarget = ref(null); // 已移至 useFileManagerDragAndDrop +const fileListContainerRef = ref(null); // 文件列表容器引用 (保留,传递给 Composable) +// const scrollIntervalId = ref(null); // 已移至 useFileManagerDragAndDrop const rowSizeMultiplier = ref(1); // 新增:行大小(字体)乘数 const selectedIndex = ref(-1); // 新增:键盘选中索引 @@ -410,325 +415,34 @@ const { // --- 目录加载与导航 --- // loadDirectory is provided by props.sftpManager -// --- 拖放上传逻辑 --- -const handleDragEnter = (event: DragEvent) => { - if (props.wsDeps.isConnected.value && event.dataTransfer?.types.includes('Files')) { // 恢复使用 props.wsDeps.isConnected - isDraggingOver.value = true; - } -}; - -// --- 自动滚动相关常量 --- -const SCROLL_ZONE_HEIGHT = 50; // px,触发滚动的区域高度 -const SCROLL_SPEED = 10; // px per interval,基础滚动速度 - -const handleDragOver = (event: DragEvent) => { - event.preventDefault(); - const isExternalFileDrag = event.dataTransfer?.types.includes('Files') ?? false; - const isInternalDrag = !!draggedItem.value; // Check if an internal item is being dragged - - let effect: 'copy' | 'move' | 'none' = 'none'; - let currentTargetFilename: string | null = null; - let highlightContainer = false; // Flag to control container highlighting - - const targetElement = event.target as HTMLElement; - const targetRow = targetElement.closest('tr.file-row'); - const targetFilename = (targetRow instanceof HTMLElement) ? targetRow.dataset.filename : undefined; - const targetIsFolder = targetRow?.classList.contains('folder-row'); - - if (props.wsDeps.isConnected.value) { - if (isExternalFileDrag) { - // External Drag (Upload) - effect = 'copy'; // Always allow copy for external files - highlightContainer = true; // Highlight the container - - // Determine the specific target folder for potential drop and row highlighting - if (targetIsFolder && targetFilename && targetFilename !== '..') { - currentTargetFilename = targetFilename; // Target is a subfolder row - } else { - currentTargetFilename = null; // Target is the current directory (or invalid row) - } - - } else if (isInternalDrag && draggedItem.value) { - // Internal Drag (Move) - highlightContainer = false; // Do not highlight the container for internal moves - - if (targetIsFolder && targetFilename && targetFilename !== draggedItem.value.filename) { - // Allow dropping onto any folder row (including '..') except itself - effect = 'move'; - currentTargetFilename = targetFilename; // Target is the specific folder row - } else { - // Invalid target for internal move - effect = 'none'; - currentTargetFilename = null; - } - } else { - // Other drag types - effect = 'none'; - currentTargetFilename = null; - highlightContainer = false; - } - } else { - // Not connected - effect = 'none'; - currentTargetFilename = null; - highlightContainer = false; - } +// --- 拖放逻辑 (使用 Composable) --- +const { + isDraggingOver, // 容器拖拽悬停状态 (外部文件) + dragOverTarget, // 行拖拽悬停目标 (内部/外部) + // draggedItem, // 内部状态,不需要在 FileManager 中直接使用 + // --- 事件处理器 --- + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragStart, + handleDragEnd, + handleDragOverRow, + handleDragLeaveRow, + handleDropOnRow, +} = useFileManagerDragAndDrop({ + isConnected: props.wsDeps.isConnected, + currentPath: currentPath, // 从 sftpManager 获取 + fileListContainerRef: fileListContainerRef, // 传递容器 ref + joinPath: joinPath, // 从 sftpManager 获取 + onFileUpload: startFileUpload, // 从 useFileUploader 获取 + onItemMove: renameItem, // 从 sftpManager 获取 + selectedItems: selectedItems, // 从 useFileManagerSelection 获取 + fileList: fileList, // 从 sftpManager 获取 +}); - // --- Apply Drop Effect and Target Highlighting --- - if (event.dataTransfer) { - event.dataTransfer.dropEffect = effect; - } - isDraggingOver.value = highlightContainer; // Control container highlight based on flag - dragOverTarget.value = currentTargetFilename; // Set specific row target for highlighting - - // --- 处理自动滚动 --- - const container = fileListContainerRef.value; - // 仅在有效拖拽 (外部文件或内部文件) 且效果不是 'none' 时处理滚动 - if (container && (isExternalFileDrag || isInternalDrag) && effect !== 'none') { - const rect = container.getBoundingClientRect(); - const mouseY = event.clientY - rect.top; // 鼠标在容器内的 Y 坐标 - - if (mouseY < SCROLL_ZONE_HEIGHT) { - // 向上滚动 - if (scrollIntervalId.value === null) { - scrollIntervalId.value = window.setInterval(() => { - if (container.scrollTop > 0) { - container.scrollTop -= SCROLL_SPEED; - } else { - clearInterval(scrollIntervalId.value!); - scrollIntervalId.value = null; - } - }, 30); // 每 30ms 滚动一次 - } - } else if (mouseY > container.clientHeight - SCROLL_ZONE_HEIGHT) { - // 向下滚动 - if (scrollIntervalId.value === null) { - scrollIntervalId.value = window.setInterval(() => { - if (container.scrollTop < container.scrollHeight - container.clientHeight) { - container.scrollTop += SCROLL_SPEED; - } else { - clearInterval(scrollIntervalId.value!); - scrollIntervalId.value = null; - } - }, 30); // 每 30ms 滚动一次 - } - } else { - // 不在滚动区域,停止滚动 - if (scrollIntervalId.value !== null) { - clearInterval(scrollIntervalId.value); - scrollIntervalId.value = null; - } - } - } else { - // 如果拖拽无效、效果为 'none' 或容器不存在,确保停止滚动 - if (scrollIntervalId.value !== null) { - clearInterval(scrollIntervalId.value); - scrollIntervalId.value = null; - } - } - // console.log(`[FileManager ${props.sessionId}] Drag Over: effect=${effect}, target=${currentTargetFilename}, isDraggingOver=${isDraggingOver.value}`); -}; - -// --- 停止自动滚动的辅助函数 --- -const stopAutoScroll = () => { - if (scrollIntervalId.value !== null) { - clearInterval(scrollIntervalId.value); - scrollIntervalId.value = null; - // console.log("Auto scroll stopped"); - } -}; - -const handleDragLeave = (event: DragEvent) => { - const target = event.relatedTarget as Node | null; - const container = (event.currentTarget as HTMLElement); - - // Check if the mouse is leaving the container element itself - // This prevents flickering when moving between rows inside the container - if (!target || !container.contains(target)) { - isDraggingOver.value = false; // Clear general drag-over state - dragOverTarget.value = null; // Also clear specific target highlighting - stopAutoScroll(); // 停止自动滚动 - // console.log(`[FileManager ${props.sessionId}] Drag Leave Container`); - } - // Note: Leaving individual rows during drag is handled implicitly by handleDragOver recalculating the target. - // handleDragLeaveRow is primarily for internal drags, but clearing dragOverTarget here ensures cleanup if the drag exits the container entirely. -}; - -const handleDrop = (event: DragEvent) => { - const wasDraggingOver = isDraggingOver.value; // Store state before clearing - const currentDragTarget = dragOverTarget.value; // Store state before clearing - - // Clear drag states immediately - isDraggingOver.value = false; - dragOverTarget.value = null; - stopAutoScroll(); // 停止自动滚动 - - // Check if it was an external file drop and connection is active - const files = event.dataTransfer?.files; - if (!files || files.length === 0 || !props.wsDeps.isConnected.value) { - // If it wasn't a valid file drop, ensure internal drag state is also cleared - if (draggedItem.value) { - console.log(`[FileManager ${props.sessionId}] Drop detected, but not external files. Clearing internal drag state.`); - draggedItem.value = null; - } - return; - } - - // Prevent drop if it wasn't allowed by handleDragOver (e.g., dropping on a file row) - // We check wasDraggingOver for drops in the container, and currentDragTarget for drops on rows - if (!wasDraggingOver && !currentDragTarget) { - console.log(`[FileManager ${props.sessionId}] Drop ignored: Drop target was not valid according to handleDragOver.`); - return; - } - - - const fileListArray = Array.from(files); - let targetFolderPath = currentPath.value; // Default to current path - - // Use the dragOverTarget determined by handleDragOver - if (currentDragTarget && currentDragTarget !== '..') { - // Dropped onto a specific subfolder row - targetFolderPath = joinPath(currentPath.value, currentDragTarget); - console.log(`[FileManager ${props.sessionId}] Dropped ${fileListArray.length} external files onto folder '${currentDragTarget}'. Uploading to: ${targetFolderPath}`); - } else { - // Dropped onto the container background (current path) - console.log(`[FileManager ${props.sessionId}] Dropped ${fileListArray.length} external files onto current path '${currentPath.value}'.`); - } - - // Start uploads. Assuming startFileUpload uses the currentPath from its composable scope. - // If uploading to a specific subfolder via drag-and-drop is required, - // useFileUploader might need modification or a different approach. - fileListArray.forEach(startFileUpload); // Removed targetFolderPath argument - - // Ensure internal drag state is cleared if a drop occurs (shouldn't happen if external files are present, but good practice) - draggedItem.value = null; -}; - -// --- 应用内拖拽移动逻辑 --- -const handleDragStart = (item: FileListItem) => { - if (item.filename === '..') return; // 不允许拖拽 '..' - console.log(`[FileManager ${props.sessionId}] Drag Start: ${item.filename}`); - draggedItem.value = item; - // 可选:设置拖拽数据,虽然在此场景下主要依赖 draggedItem ref - // event.dataTransfer?.setData('text/plain', item.filename); - // event.dataTransfer?.setDragImage(...) // 可选:自定义拖拽图像 -}; - -const handleDragEnd = () => { - // console.log(`[FileManager ${props.sessionId}] Drag End`); - draggedItem.value = null; - dragOverTarget.value = null; // 清除悬停目标 - stopAutoScroll(); // 停止自动滚动 - // 移除所有可能的高亮(以防万一) - document.querySelectorAll('.file-row.drop-target').forEach(el => el.classList.remove('drop-target')); -}; - -const handleDragOverRow = (targetItem: FileListItem, event: DragEvent) => { - event.preventDefault(); // 必须阻止默认行为以允许 drop - // 允许拖到 '..' 上,但不能拖拽 '..' 自身,也不能拖到非目录项上(除了 '..') - if (!draggedItem.value || draggedItem.value.filename === '..' || (targetItem.filename !== '..' && (!targetItem.attrs.isDirectory || draggedItem.value.filename === targetItem.filename))) { - if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'; - dragOverTarget.value = null; - return; // 仅当拖拽有效项到有效文件夹(或 '..')时才处理 - } - if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; - dragOverTarget.value = targetItem.filename; // 记录悬停目标 - // console.log(`[FileManager ${props.sessionId}] Drag Over Row: ${targetItem.filename}`); -}; - -const handleDragLeaveRow = (targetItem: FileListItem) => { - // 只有当鼠标离开当前悬停的目标时才清除 - if (dragOverTarget.value === targetItem.filename) { - dragOverTarget.value = null; - // console.log(`[FileManager ${props.sessionId}] Drag Leave Row: ${targetItem.filename}`); - } -}; - -const handleDropOnRow = (targetItem: FileListItem, event: DragEvent) => { - event.preventDefault(); - // 检查是否是外部文件拖拽 - const files = event.dataTransfer?.files; - if (files && files.length > 0) { - // 如果是外部文件拖拽,不阻止冒泡,让父容器的 handleDrop 处理上传 - console.log(`[FileManager ${props.sessionId}] External file drop detected on row, letting parent handle.`); - // 不需要清除 draggedItem.value,因为外部拖拽时它应该为 null - // dragOverTarget.value = null; // 清除悬停状态 (父容器 handleDrop 会处理) - return; - } - - // --- 以下是处理内部文件移动的逻辑 --- - event.stopPropagation(); // 仅在处理内部移动时阻止冒泡 - const sourceItem = draggedItem.value; - dragOverTarget.value = null; // 清除悬停状态 - - // 验证内部拖放操作的有效性 - // 注意:这里的 !sourceItem 检查现在只会在非外部文件拖拽时发生, - // 如果 sourceItem 仍然是 null,说明不是有效的内部拖拽。 - if (!sourceItem || sourceItem.filename === '..' || (targetItem.filename !== '..' && !targetItem.attrs.isDirectory) || sourceItem.filename === targetItem.filename) { - console.log(`[FileManager ${props.sessionId}] Internal drop on row ignored: Invalid target or source. Source: ${sourceItem?.filename}, Target: ${targetItem.filename}`); - // 如果 sourceItem 存在但无效,才需要清除 - if (sourceItem) { - draggedItem.value = null; - } - return; - } - - // --- 重新计算路径 --- - const sourceFullPath = joinPath(currentPath.value, sourceItem.filename); - let targetDirectoryFullPath: string; - - if (targetItem.filename === '..') { - // 计算父目录路径 - const current = currentPath.value; - if (current === '/') { - console.warn(`[FileManager ${props.sessionId}] Cannot move item from root to its parent.`); - draggedItem.value = null; - return; // 不能从根目录移动到父目录 - } - // 找到最后一个 '/' - const lastSlashIndex = current.lastIndexOf('/'); - // 如果 lastSlashIndex 是 0 (例如 /file),父目录是 / - // 否则,父目录是最后一个 / 之前的部分 - targetDirectoryFullPath = lastSlashIndex <= 0 ? '/' : current.substring(0, lastSlashIndex); - // 确保父目录路径至少是 '/' (处理类似 '/dir' -> '/' 的情况) - if (!targetDirectoryFullPath) targetDirectoryFullPath = '/'; - - } else { - // 移动到子目录,目标目录就是子目录的完整路径 - targetDirectoryFullPath = joinPath(currentPath.value, targetItem.filename); - } - - // 使用目标目录路径和源文件名构建最终目标路径 - // 假设 joinPath 能正确处理 targetDirectoryFullPath 为 '/' 的情况 - const newFullPath = joinPath(targetDirectoryFullPath, sourceItem.filename); - - console.log(`[FileManager ${props.sessionId}] Drop ${sourceItem.filename} onto ${targetItem.filename}`); - console.log(`[FileManager ${props.sessionId}] Source Path: ${sourceFullPath}`); - console.log(`[FileManager ${props.sessionId}] Target Directory: ${targetDirectoryFullPath}`); - console.log(`[FileManager ${props.sessionId}] Calculated Destination Path: ${newFullPath}`); // 使用新变量名 - - // 检查源路径和计算出的目标路径是否相同 - if (sourceFullPath === newFullPath) { - console.warn(`[FileManager ${props.sessionId}] Source and destination paths are the same.`); - draggedItem.value = null; - return; - } - - // --- 调用 SFTP 操作 --- - // 注意:后端冲突检查通常更可靠,前端检查已注释掉 - console.log(`[FileManager ${props.sessionId}] Attempting to move '${sourceFullPath}' to '${newFullPath}'`); - renameItem(sourceItem, newFullPath); // 传递计算出的新完整路径 - - // 不再立即刷新,等待 sftp:rename:success 消息处理 - // loadDirectory(currentPath.value); - - // 清理拖拽状态 - draggedItem.value = null; -}; - - -// --- 文件上传逻辑 (handleFileSelected 保持在此处) --- +// --- 文件上传逻辑 (handleFileSelected 保持在此处,由 triggerFileUpload 调用) --- const handleFileSelected = (event: Event) => { const input = event.target as HTMLInputElement; // 恢复使用 props.wsDeps.isConnected @@ -1102,12 +816,11 @@ const handleWheel = (event: WheelEvent) => {
{ :class="[ 'file-row', { clickable: item.attrs.isDirectory || item.attrs.isFile }, - { selected: selectedItems.has(item.filename) }, /* 恢复:使用 selectedItems Set 控制选中高亮 */ - // { selected: index + (currentPath !== '/' ? 1 : 0) === selectedIndex }, /* 暂时移除键盘选中高亮 */ + { selected: selectedItems.has(item.filename) || (index + (currentPath !== '/' ? 1 : 0) === selectedIndex) }, /* 结合鼠标和键盘选中高亮 */ + // { selected: index + (currentPath !== '/' ? 1 : 0) === selectedIndex }, /* 移除单独的键盘高亮 */ { 'folder-row': item.attrs.isDirectory }, // 添加文件夹标识类 - { 'drop-target': item.attrs.isDirectory && dragOverTarget === item.filename } // 拖拽悬停高亮 + { 'drop-target': item.attrs.isDirectory && dragOverTarget === item.filename } // 使用 Composable 的 dragOverTarget ]" :data-filename="item.filename" @contextmenu.prevent.stop="showContextMenu($event, item)" - @dragover.prevent="handleDragOverRow(item, $event)" + @dragover.prevent="handleDragOverRow(item, $event)" @dragleave="handleDragLeaveRow(item)" - @drop.prevent="handleDropOnRow(item, $event)"> + @drop.prevent="handleDropOnRow(item, $event)"> diff --git a/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts b/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts new file mode 100644 index 0000000..b716228 --- /dev/null +++ b/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts @@ -0,0 +1,340 @@ +import { ref, type Ref } from 'vue'; +import type { FileListItem } from '../../types/sftp.types'; // 确保路径正确 + +// 定义 Composable 的输入参数类型 +export interface UseFileManagerDragAndDropOptions { + // 响应式引用 + isConnected: Ref; + currentPath: Ref; + fileListContainerRef: Ref; // 文件列表容器的引用 + selectedItems: Ref>; // 当前选中的项目集合 + fileList: Ref>; // 完整的文件列表 (用于查找选中项的对象) + + // 函数依赖 + joinPath: (base: string, target: string) => string; // 路径拼接函数 + onFileUpload: (file: File) => void; // 触发文件上传的回调 + onItemMove: (sourceItem: FileListItem, newFullPath: string) => void; // 触发文件/文件夹移动的回调 +} + +export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOptions) { + const { + isConnected, + currentPath, + fileListContainerRef, + joinPath, + onFileUpload, + onItemMove, + selectedItems, // 获取传入的 selectedItems + fileList, // 获取传入的 fileList + } = options; + + // --- 拖放状态 Refs --- + const isDraggingOver = ref(false); // 是否有文件拖拽悬停在容器上 (用于外部文件) + const draggedItem = ref(null); // 内部拖拽时,被拖拽的项 + const dragOverTarget = ref(null); // 内部/外部拖拽时,悬停的目标文件夹名称 (用于行高亮) + const scrollIntervalId = ref(null); // 自动滚动计时器 ID + + // --- 自动滚动常量 --- + const SCROLL_ZONE_HEIGHT = 50; // px + const SCROLL_SPEED = 10; // px per interval + + // --- 辅助函数:停止自动滚动 --- + const stopAutoScroll = () => { + if (scrollIntervalId.value !== null) { + clearInterval(scrollIntervalId.value); + scrollIntervalId.value = null; + } + }; + + // --- 事件处理函数 --- + const handleDragEnter = (event: DragEvent) => { + if (isConnected.value && event.dataTransfer?.types.includes('Files')) { + isDraggingOver.value = true; + } + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); // 必须阻止默认行为以允许 drop + const isExternalFileDrag = event.dataTransfer?.types.includes('Files') ?? false; + const isInternalDrag = !!draggedItem.value; + + let effect: 'copy' | 'move' | 'none' = 'none'; + let currentTargetFilename: string | null = null; + let highlightContainer = false; + + const targetElement = event.target as HTMLElement; + const targetRow = targetElement.closest('tr.file-row'); + const targetFilename = (targetRow instanceof HTMLElement) ? targetRow.dataset.filename : undefined; + const targetIsFolder = targetRow?.classList.contains('folder-row'); + + if (isConnected.value) { + if (isExternalFileDrag) { + effect = 'copy'; + highlightContainer = true; + if (targetIsFolder && targetFilename && targetFilename !== '..') { + currentTargetFilename = targetFilename; + } else { + currentTargetFilename = null; + } + } else if (isInternalDrag && draggedItem.value) { + highlightContainer = false; + if (targetIsFolder && targetFilename && targetFilename !== draggedItem.value.filename) { + effect = 'move'; + currentTargetFilename = targetFilename; + } else { + effect = 'none'; + currentTargetFilename = null; + } + } else { + effect = 'none'; + currentTargetFilename = null; + highlightContainer = false; + } + } else { + effect = 'none'; + currentTargetFilename = null; + highlightContainer = false; + } + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = effect; + } + isDraggingOver.value = highlightContainer; + dragOverTarget.value = currentTargetFilename; + + // --- 处理自动滚动 --- + const container = fileListContainerRef.value; + if (container && (isExternalFileDrag || isInternalDrag) && effect !== 'none') { + const rect = container.getBoundingClientRect(); + const mouseY = event.clientY - rect.top; + + if (mouseY < SCROLL_ZONE_HEIGHT) { + if (scrollIntervalId.value === null) { + scrollIntervalId.value = window.setInterval(() => { + if (container.scrollTop > 0) { + container.scrollTop -= SCROLL_SPEED; + } else { + stopAutoScroll(); + } + }, 30); + } + } else if (mouseY > container.clientHeight - SCROLL_ZONE_HEIGHT) { + if (scrollIntervalId.value === null) { + scrollIntervalId.value = window.setInterval(() => { + if (container.scrollTop < container.scrollHeight - container.clientHeight) { + container.scrollTop += SCROLL_SPEED; + } else { + stopAutoScroll(); + } + }, 30); + } + } else { + stopAutoScroll(); + } + } else { + stopAutoScroll(); + } + }; + + const handleDragLeave = (event: DragEvent) => { + const target = event.relatedTarget as Node | null; + const container = (event.currentTarget as HTMLElement); + if (!target || !container.contains(target)) { + isDraggingOver.value = false; + dragOverTarget.value = null; + stopAutoScroll(); + } + }; + + const handleDrop = (event: DragEvent) => { + const wasDraggingOver = isDraggingOver.value; + const currentDragTarget = dragOverTarget.value; + isDraggingOver.value = false; + dragOverTarget.value = null; + stopAutoScroll(); + + const files = event.dataTransfer?.files; + if (!files || files.length === 0 || !isConnected.value) { + if (draggedItem.value) draggedItem.value = null; // 清理内部拖拽状态 + return; + } + // 检查放置目标是否有效 (由 handleDragOver 决定) + // 对于外部文件,要么容器高亮 (wasDraggingOver),要么行高亮 (currentDragTarget) + if (!wasDraggingOver && !currentDragTarget) { + console.log(`[DragDrop] Drop ignored: Drop target was not valid according to handleDragOver.`); + return; + } + + + const fileListArray = Array.from(files); + let targetFolderPath = currentPath.value; // 默认上传到当前目录 + + // 如果是放置在特定子文件夹行上 + if (currentDragTarget && currentDragTarget !== '..') { + targetFolderPath = joinPath(currentPath.value, currentDragTarget); + console.log(`[DragDrop] Dropped ${fileListArray.length} external files onto folder '${currentDragTarget}'. Uploading to: ${targetFolderPath}`); + } else { + console.log(`[DragDrop] Dropped ${fileListArray.length} external files onto current path '${currentPath.value}'.`); + } + + // 注意:原始代码中 startFileUpload 没有使用 targetFolderPath,这里暂时保持一致 + // 如果需要上传到子目录,需要修改 useFileUploader 或此处的调用方式 + fileListArray.forEach(onFileUpload); + draggedItem.value = null; // 确保清理内部拖拽状态 + }; + + const handleDragStart = (item: FileListItem) => { + if (item.filename === '..') return; + // console.log(`[DragDrop] Drag Start: ${item.filename}`); + draggedItem.value = item; + }; + + const handleDragEnd = () => { + // console.log(`[DragDrop] Drag End`); + draggedItem.value = null; + dragOverTarget.value = null; + stopAutoScroll(); + // 最好由 CSS :active 或其他状态处理,但作为后备 + document.querySelectorAll('.file-row.drop-target').forEach(el => el.classList.remove('drop-target')); + }; + + const handleDragOverRow = (targetItem: FileListItem, event: DragEvent) => { + event.preventDefault(); // 允许 drop + // 内部拖拽逻辑: 只能拖拽非 '..' 项,目标必须是文件夹或 '..',且不能是自身 + if (!draggedItem.value || draggedItem.value.filename === '..' || (targetItem.filename !== '..' && (!targetItem.attrs.isDirectory || draggedItem.value.filename === targetItem.filename))) { + if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'; + dragOverTarget.value = null; // 清除可能存在的旧目标 + return; + } + // 设置放置效果为 'move' 并记录目标 + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + dragOverTarget.value = targetItem.filename; + }; + + const handleDragLeaveRow = (targetItem: FileListItem) => { + // 只有当鼠标离开当前高亮的目标行时才清除高亮状态 + if (dragOverTarget.value === targetItem.filename) { + dragOverTarget.value = null; + } + }; + + const handleDropOnRow = (targetItem: FileListItem, event: DragEvent) => { + event.preventDefault(); + // 检查是否是外部文件拖拽 (dataTransfer.files 存在) + const files = event.dataTransfer?.files; + if (files && files.length > 0) { + // 如果是外部文件拖拽,不阻止冒泡,让父容器的 handleDrop 处理上传 + // console.log(`[DragDrop] External file drop detected on row, letting parent handle.`); + // 不需要清除 draggedItem.value,因为外部拖拽时它应该为 null + // dragOverTarget.value = null; // 清除悬停状态 (父容器 handleDrop 会处理) + return; // 让事件冒泡到父 div 的 handleDrop + } + + // --- 以下是处理内部文件移动的逻辑 --- + event.stopPropagation(); // 仅在处理内部移动时阻止冒泡 + const sourceItem = draggedItem.value; + const currentDragOverTarget = dragOverTarget.value; // 保存当前目标,然后清除 + dragOverTarget.value = null; // 清除悬停状态 + + // 验证内部拖放操作的有效性 + if (!sourceItem || sourceItem.filename === '..' || (targetItem.filename !== '..' && !targetItem.attrs.isDirectory) || sourceItem.filename === targetItem.filename || targetItem.filename !== currentDragOverTarget) { + // console.log(`[DragDrop] Internal drop on row ignored: Invalid target, source, or drop occurred outside the intended target row. Source: ${sourceItem?.filename}, Target: ${targetItem.filename}, Drop Target: ${currentDragOverTarget}`); + if (sourceItem) draggedItem.value = null; // 清理拖拽状态 + return; + } + + // --- 计算路径 --- + const sourceFullPath = joinPath(currentPath.value, sourceItem.filename); + let targetDirectoryFullPath: string; + + if (targetItem.filename === '..') { + const current = currentPath.value; + if (current === '/') { // 不能从根目录移动到父目录 + // console.warn(`[DragDrop] Cannot move item from root to its parent.`); + draggedItem.value = null; + return; + } + const lastSlashIndex = current.lastIndexOf('/'); + targetDirectoryFullPath = lastSlashIndex <= 0 ? '/' : current.substring(0, lastSlashIndex); + if (!targetDirectoryFullPath) targetDirectoryFullPath = '/'; // 处理根目录下的文件/文件夹 + } else { + // 移动到子目录 + targetDirectoryFullPath = joinPath(currentPath.value, targetItem.filename); + } + + const newFullPath = joinPath(targetDirectoryFullPath, sourceItem.filename); + + // 检查源路径和计算出的目标路径是否相同 + if (sourceFullPath === newFullPath) { + // console.warn(`[DragDrop] Source and destination paths are the same.`); + draggedItem.value = null; + return; + } + + // --- 调用 SFTP 操作 (处理单选/多选) --- + const itemsToMove: FileListItem[] = []; + // 检查被拖拽的项是否在选区内 + if (selectedItems.value.has(sourceItem.filename)) { + // 多选拖拽:移动所有选中的项 + console.log(`[DragDrop] Multi-item drop detected. Moving ${selectedItems.value.size} selected items.`); + selectedItems.value.forEach(filename => { + // 从完整文件列表中查找对应的 FileListItem 对象 + const itemToMove = fileList.value.find(f => f.filename === filename); + if (itemToMove && itemToMove.filename !== '..') { // 确保找到且不是 '..' + // 检查目标路径是否与源路径相同 (对于每个项目) + const currentItemSourcePath = joinPath(currentPath.value, itemToMove.filename); + const currentItemNewPath = joinPath(targetDirectoryFullPath, itemToMove.filename); + if (currentItemSourcePath !== currentItemNewPath) { + itemsToMove.push(itemToMove); + } else { + console.warn(`[DragDrop] Skipping move for ${itemToMove.filename}: Source and destination paths are the same.`); + } + } + }); + // 清空选择是一个好习惯,可以在移动成功后进行,但这里为简化暂时不清空 + // clearSelection(); // 需要从 useFileManagerSelection 引入或作为参数传入 + } else { + // 单选拖拽 (拖拽了一个未选中的项) + console.log(`[DragDrop] Single unselected item drop detected. Moving ${sourceItem.filename}.`); + // 检查目标路径是否与源路径相同 + if (sourceFullPath !== newFullPath) { + itemsToMove.push(sourceItem); + } else { + console.warn(`[DragDrop] Skipping move for ${sourceItem.filename}: Source and destination paths are the same.`); + } + } + + // 统一执行移动操作 + if (itemsToMove.length > 0) { + console.log(`[DragDrop] Executing move for ${itemsToMove.length} items to target directory: ${targetDirectoryFullPath}`); + itemsToMove.forEach(item => { + const itemNewFullPath = joinPath(targetDirectoryFullPath, item.filename); + console.log(`[DragDrop] - Moving '${item.filename}' to '${itemNewFullPath}'`); + onItemMove(item, itemNewFullPath); // 调用移动回调 + }); + } else { + console.log("[DragDrop] No valid items to move."); + } + + // 清理拖拽状态 + draggedItem.value = null; + }; + + + // --- 返回状态和处理函数 --- + return { + isDraggingOver, + dragOverTarget, + draggedItem, // 需要暴露以供 handleDragOverRow 等函数内部判断 + // --- 事件处理器 --- + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragStart, + handleDragEnd, + handleDragOverRow, + handleDragLeaveRow, + handleDropOnRow, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/file-manager/useFileManagerSelection.ts b/packages/frontend/src/composables/file-manager/useFileManagerSelection.ts index c9431f2..5aa3276 100644 --- a/packages/frontend/src/composables/file-manager/useFileManagerSelection.ts +++ b/packages/frontend/src/composables/file-manager/useFileManagerSelection.ts @@ -17,15 +17,12 @@ export function useFileManagerSelection(options: UseFileManagerSelectionOptions) const lastClickedIndex = ref(-1); // 索引相对于 displayedFileList const handleItemClick = (event: MouseEvent, item: FileListItem) => { - console.log(`[Selection] handleItemClick called for item: ${item.filename}`, { event, item }); let shouldPerformAction = false; // 初始化标志 const ctrlOrMeta = event.ctrlKey || event.metaKey; const shift = event.shiftKey; - console.log(`[Selection] Modifiers: Ctrl/Meta=${ctrlOrMeta}, Shift=${shift}`); // 查找点击项在当前显示列表中的索引 const itemIndex = displayedFileList.value.findIndex((f) => f.filename === item.filename); - console.log(`[Selection] Item index in displayed list: ${itemIndex}`); // 如果找不到项(理论上不应发生),或者点击的是 '..' // (注意: '..' 通常是单独处理或在列表开头,这里假设它不在 displayedFileList 中,或者其点击事件由外部单独处理) @@ -34,17 +31,13 @@ export function useFileManagerSelection(options: UseFileManagerSelectionOptions) // 如果点击的是 '..' // 如果点击的是 '..' if (item.filename === '..') { - console.log("[Selection] Clicked on '..'"); // 只有在没有修饰键时才执行 '..' 的动作 if (!ctrlOrMeta && !shift) { - console.log("[Selection] '..' clicked without modifiers. Clearing selection and marking for action."); - console.log('[Selection] Before clear:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); selectedItems.value.clear(); lastClickedIndex.value = -1; shouldPerformAction = true; // 标记执行动作 - console.log('[Selection] After clear:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); } else { - console.log("[Selection] '..' clicked with modifiers. Ignoring action."); + // 如果有修饰键,则不执行动作 } // 如果有修饰键,则不执行动作,直接返回 (修改:移到下面统一处理) // (不需要修改 selectedItems 或 lastClickedIndex) @@ -57,12 +50,10 @@ export function useFileManagerSelection(options: UseFileManagerSelectionOptions) // 如果是 '..' 且有修饰键,则在此处返回 (因为上面没有 return) -> 不对,应该在上面 if block 里 return // 统一处理 '..' 的返回逻辑:如果有修饰键,则不继续执行后续的选择/动作逻辑 if (item.filename === '..' && (ctrlOrMeta || shift)) { - console.log("[Selection] Returning early for '..' click with modifiers."); return; } // 如果不是 '..' 且找不到索引,也返回 if (item.filename !== '..' && itemIndex === -1) { - console.log("[Selection] Item not found in displayed list (and not '..'). Returning."); return; } @@ -70,69 +61,50 @@ export function useFileManagerSelection(options: UseFileManagerSelectionOptions) // --- 主要选择逻辑 --- - console.log('[Selection] Before selection logic:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); - // --- 调整后的主要选择逻辑 --- if (ctrlOrMeta) { // 1. 检查 Ctrl/Meta - console.log('[Selection] Branch: Ctrl/Meta Click'); event.preventDefault(); event.stopPropagation(); // <-- 阻止冒泡 // Ctrl/Cmd + Click: Toggle selection // '..' 不应参与多选 (已在前面处理) // if (item.filename === '..') return; // '..' 已在前面处理 if (selectedItems.value.has(item.filename)) { - console.log(`[Selection] Ctrl/Meta: Removing ${item.filename}`); selectedItems.value.delete(item.filename); } else { - console.log(`[Selection] Ctrl/Meta: Adding ${item.filename}`); selectedItems.value.add(item.filename); // Keep the add operation } // Removed the extra else block here lastClickedIndex.value = itemIndex; // 更新最后点击的索引 - console.log('[Selection] After Ctrl/Meta:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); - console.log('[Selection] After Ctrl/Meta:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); } else if (shift) { // 2. 检查 Shift (移除 lastClickedIndex !== -1 条件) - console.log('[Selection] Branch: Shift Click'); event.preventDefault(); event.stopPropagation(); // <-- 阻止冒泡 // Shift + Click: Range selection // '..' 不应参与范围选择 (已在前面处理) // if (item.filename === '..') return; // '..' 已在前面处理 - console.log('[Selection] Shift: Clearing previous selection.'); selectedItems.value.clear(); // 如果 lastClickedIndex 是 -1 (例如第一次 Shift 点击),则只选中当前项 const start = lastClickedIndex.value === -1 ? itemIndex : Math.min(lastClickedIndex.value, itemIndex); const end = lastClickedIndex.value === -1 ? itemIndex : Math.max(lastClickedIndex.value, itemIndex); - console.log(`[Selection] Shift: Range from ${start} to ${end}`); for (let i = start; i <= end; i++) { // 确保索引有效且不是 '..' const fileToAdd = displayedFileList.value[i]; if (fileToAdd && fileToAdd.filename !== '..') { - console.log(`[Selection] Shift: Adding ${fileToAdd.filename}`); selectedItems.value.add(fileToAdd.filename); } } // Shift-click 也更新 lastClickedIndex 为当前点击项 lastClickedIndex.value = itemIndex; - console.log('[Selection] After Shift:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); - console.log('[Selection] After Shift:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); } else { // 3. 处理普通单击 (没有修饰键) - console.log('[Selection] Branch: Single Click'); // Single Click: Select only the clicked item and perform action - console.log('[Selection] Single Click: Clearing previous selection.'); selectedItems.value.clear(); // '..' 不应被加入 selectedItems (已在前面处理 shouldPerformAction) if (item.filename !== '..') { - console.log(`[Selection] Single Click: Adding ${item.filename}`); selectedItems.value.add(item.filename); lastClickedIndex.value = itemIndex; // 更新最后点击的索引 } else { // 点击 '..' 的 lastClickedIndex 已在前面处理 // lastClickedIndex.value = -1; } - console.log('[Selection] After Single Click selection update:', new Set(selectedItems.value), 'Last index:', lastClickedIndex.value); - - // --- 调用外部传入的动作回调 --- // 只有单击时才执行导航或打开文件 // 标记执行动作 (只在普通单击时) @@ -140,12 +112,8 @@ export function useFileManagerSelection(options: UseFileManagerSelectionOptions) } // 在函数末尾根据标志决定是否执行动作 - console.log(`[Selection] Final check: shouldPerformAction = ${shouldPerformAction}`); if (shouldPerformAction) { - console.log(`[Selection] Calling onItemAction for ${item.filename}`); onItemAction(item); - } else { - console.log(`[Selection] Skipping onItemAction for ${item.filename}`); } };