From b167ed19b991a417ec664d41e32f8b0e7d983e1c Mon Sep 17 00:00:00 2001 From: Jammanb0 <151705970+Jammanb0@users.noreply.github.com> Date: Sun, 3 Aug 2025 16:51:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(FileTree):=20=EB=B9=88=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=EC=9C=BC=EB=A1=9C=20=ED=8C=8C=EC=9D=BC=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=EC=8B=9C=20=EB=A3=A8=ED=8A=B8=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20/?= =?UTF-8?q?=20DP-212?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/Repo/fileTree/FileTree.tsx | 26 ++- .../components/FileTreeItem/FileTreeItem.tsx | 1 + .../fileTree/hooks/useFileTreeDragDrop.ts | 162 ++++++++++++----- .../fileTree/hooks/useFileTreeOperations.ts | 166 +++++++++++++++--- 4 files changed, 288 insertions(+), 67 deletions(-) diff --git a/src/features/Repo/fileTree/FileTree.tsx b/src/features/Repo/fileTree/FileTree.tsx index 80241025..78aef1a9 100644 --- a/src/features/Repo/fileTree/FileTree.tsx +++ b/src/features/Repo/fileTree/FileTree.tsx @@ -91,7 +91,7 @@ const FileTree: React.FC = ({ } = useFileTreeOperations({ repositoryId: repositoryId || 0, onSuccess: handleOperationSuccess, - rootFolderId: treeData?.[0]?.fileId, + rootFolderId: treeData?.[0]?.fileId || undefined, }); // 내부 드래그앤드롭 훅 @@ -102,10 +102,13 @@ const FileTree: React.FC = ({ handleDragOver, handleDragLeave, handleDrop, + handleContainerDragOver, + handleContainerDrop, isDragging, isDropTarget, getDropPosition, canDrop, + isRootDropTarget, } = useFileTreeDragDrop({ onMoveNode: moveItem, }); @@ -380,12 +383,19 @@ const FileTree: React.FC = ({
{ + handleExternalDragOver(e); + handleContainerDragOver(e); + }} onDragLeave={handleExternalDragLeave} - onDrop={handleExternalDrop} + onDrop={e => { + handleExternalDrop(e); + handleContainerDrop(e); + }} > {/* 협업 상태 표시 */} {renderCollaborationStatus()} @@ -409,6 +419,16 @@ const FileTree: React.FC = ({
)} + {/* 최상위 폴더 드롭 피드백 */} + {isRootDropTarget && ( +
+
+ 📁 + 최상위 폴더로 이동 +
+
+ )} + {/* 외부 드래그 피드백 */} {isExternalDragActive() && (
diff --git a/src/features/Repo/fileTree/components/FileTreeItem/FileTreeItem.tsx b/src/features/Repo/fileTree/components/FileTreeItem/FileTreeItem.tsx index bb29781a..0ed47e72 100644 --- a/src/features/Repo/fileTree/components/FileTreeItem/FileTreeItem.tsx +++ b/src/features/Repo/fileTree/components/FileTreeItem/FileTreeItem.tsx @@ -298,6 +298,7 @@ const FileTreeItem: React.FC = ({ onDrop={handleCombinedDrop} // 최상단 레벨 여부를 data attribute로 전달 data-is-top-level={isTopLevel} + data-file-tree-item title={ tabStatus.isOpen ? `${node.fileName}${tabStatus.isActive ? ' (활성 탭)' : ' (열린 탭)'}${tabStatus.isDirty ? ' (변경됨)' : ''}` diff --git a/src/features/Repo/fileTree/hooks/useFileTreeDragDrop.ts b/src/features/Repo/fileTree/hooks/useFileTreeDragDrop.ts index 9de10ae6..d87c09f8 100644 --- a/src/features/Repo/fileTree/hooks/useFileTreeDragDrop.ts +++ b/src/features/Repo/fileTree/hooks/useFileTreeDragDrop.ts @@ -5,8 +5,8 @@ import type { DropPosition } from '../types'; interface UseFileTreeDragDropProps { onMoveNode: ( draggedNode: FileTreeNode, - targetNode: FileTreeNode, - position: 'inside' | 'before' | 'after' + targetNode: FileTreeNode | null, + position: 'inside' | 'before' | 'after' | 'root' ) => Promise; } @@ -17,10 +17,13 @@ interface UseFileTreeDragDropReturn { handleDragOver: (node: FileTreeNode, event: React.DragEvent) => void; handleDragLeave: () => void; handleDrop: (node: FileTreeNode, event: React.DragEvent) => void; + handleContainerDragOver: (event: React.DragEvent) => void; + handleContainerDrop: (event: React.DragEvent) => void; isDragging: (nodeId: string) => boolean; isDropTarget: (nodeId: string) => boolean; getDropPosition: (nodeId: string) => DropPosition | null; - canDrop: (draggedNode: FileTreeNode, targetNode: FileTreeNode) => boolean; + canDrop: (draggedNode: FileTreeNode, targetNode: FileTreeNode | null) => boolean; + isRootDropTarget: boolean; } export const useFileTreeDragDrop = ({ @@ -37,37 +40,48 @@ export const useFileTreeDragDrop = ({ nodeId: string; position: DropPosition; } | null>(null); + + const [isRootDropTarget, setIsRootDropTarget] = useState(false); const dragOverTimeoutRef = useRef(null); // 드롭 가능 여부 확인 - const canDrop = useCallback((draggedNode: FileTreeNode, targetNode: FileTreeNode): boolean => { - // 자기 자신에게는 드롭 불가 - if (draggedNode.fileId === targetNode.fileId) { - return false; - } - - // 자신의 하위 폴더로는 이동 불가 (무한 루프 방지) - if (draggedNode.fileType === 'FOLDER' && targetNode.path.startsWith(draggedNode.path + '/')) { - return false; - } + const canDrop = useCallback( + (draggedNode: FileTreeNode, targetNode: FileTreeNode | null): boolean => { + // 최상위 프로젝트 폴더로 이동하는 경우 + if (!targetNode) { + // 이미 최상위 프로젝트 폴더에 있는 경우 이동 불가 (level 1 체크) + return draggedNode.level > 1; + } - // 같은 부모를 가진 경우 체크 - if (targetNode.fileType === 'FOLDER') { - // 폴더로 드롭하려는데 이미 그 폴더 안에 있는 경우 - if (draggedNode.parentId === targetNode.fileId) { - console.log('⚠️ 이미 해당 폴더에 있는 파일입니다'); + // 자기 자신에게는 드롭 불가 + if (draggedNode.fileId === targetNode.fileId) { return false; } - } else { - // 파일과 같은 레벨로 드롭하려는데 이미 같은 레벨에 있는 경우 - if (draggedNode.parentId === targetNode.parentId) { - console.log('⚠️ 이미 같은 레벨에 있는 파일입니다'); + + // 자신의 하위 폴더로는 이동 불가 (무한 루프 방지) + if (draggedNode.fileType === 'FOLDER' && targetNode.path.startsWith(draggedNode.path + '/')) { return false; } - } - return true; - }, []); + // 같은 부모를 가진 경우 체크 + if (targetNode.fileType === 'FOLDER') { + // 폴더로 드롭하려는데 이미 그 폴더 안에 있는 경우 + if (draggedNode.parentId === targetNode.fileId) { + console.log('⚠️ 이미 해당 폴더에 있는 파일입니다'); + return false; + } + } else { + // 파일과 같은 레벨로 드롭하려는데 이미 같은 레벨에 있는 경우 + if (draggedNode.parentId === targetNode.parentId) { + console.log('⚠️ 이미 같은 레벨에 있는 파일입니다'); + return false; + } + } + + return true; + }, + [] + ); // 드롭 위치 계산 const calculateDropPosition = useCallback( @@ -153,9 +167,10 @@ export const useFileTreeDragDrop = ({ }); setDropPosition(null); + setIsRootDropTarget(false); }, []); - // 드래그 오버 + // 드래그 오버 (개별 노드) const handleDragOver = useCallback( (node: FileTreeNode, event: React.DragEvent) => { event.preventDefault(); @@ -184,10 +199,85 @@ export const useFileTreeDragDrop = ({ nodeId: node.fileId.toString(), position, }); + + setIsRootDropTarget(false); }, [dragDropState, canDrop, calculateDropPosition] ); + // 컨테이너 드래그 오버 (빈 공간) + const handleContainerDragOver = useCallback( + (event: React.DragEvent) => { + const { draggedItem } = dragDropState; + if (!draggedItem) return; + + // 내부 드래그가 아닌 경우 처리하지 않음 + const isInternalDrag = event.dataTransfer.types.includes('application/json'); + if (!isInternalDrag) return; + + // 이벤트가 특정 노드에서 발생한 경우 처리하지 않음 + const target = event.target as HTMLElement; + const isOnNode = target.closest('[data-file-tree-item]'); + if (isOnNode) return; + + event.preventDefault(); + + // 루트로 이동 가능한지 확인 + if (canDrop(draggedItem.node, null)) { + event.dataTransfer.dropEffect = 'move'; + setIsRootDropTarget(true); + setDragDropState(prev => ({ + ...prev, + dropTarget: null, + })); + setDropPosition(null); + } else { + event.dataTransfer.dropEffect = 'none'; + setIsRootDropTarget(false); + } + }, + [dragDropState, canDrop] + ); + + // 컨테이너 드롭 (빈 공간) + const handleContainerDrop = useCallback( + async (event: React.DragEvent) => { + const { draggedItem } = dragDropState; + if (!draggedItem) return; + + // 내부 드래그가 아닌 경우 처리하지 않음 + const isInternalDrag = event.dataTransfer.types.includes('application/json'); + if (!isInternalDrag) return; + + // 이벤트가 특정 노드에서 발생한 경우 처리하지 않음 + const target = event.target as HTMLElement; + const isOnNode = target.closest('[data-file-tree-item]'); + if (isOnNode) return; + + event.preventDefault(); + + if (!canDrop(draggedItem.node, null)) { + return; + } + + try { + console.log('🎯 루트로 드롭:', { + draggedItem: draggedItem.name, + currentParentId: draggedItem.node.parentId, + }); + + await onMoveNode(draggedItem.node, null, 'root'); + + console.log(`✅ 루트로 이동 완료: ${draggedItem.name}`); + } catch (error) { + console.error('❌ 루트로 이동 실패:', error); + } finally { + handleDragEnd(); + } + }, + [dragDropState, canDrop, onMoveNode, handleDragEnd] + ); + // 드래그 리브 const handleDragLeave = useCallback(() => { if (dragOverTimeoutRef.current) { @@ -200,10 +290,11 @@ export const useFileTreeDragDrop = ({ dropTarget: null, })); setDropPosition(null); + setIsRootDropTarget(false); }, 50); }, []); - // 드롭 + // 드롭 (개별 노드) const handleDrop = useCallback( async (node: FileTreeNode, event: React.DragEvent) => { event.preventDefault(); @@ -224,19 +315,7 @@ export const useFileTreeDragDrop = ({ targetType: node.fileType, }); - // 위치에 따른 실제 타겟 노드 결정 - let actualTargetNode = node; - - if (position === 'inside' && node.fileType === 'FOLDER') { - // 폴더 안으로 드롭 - 그대로 사용 - actualTargetNode = node; - } else if (position === 'before' || position === 'after') { - // 파일/폴더의 앞/뒤로 드롭 - 같은 레벨 (부모와 같은 레벨) - // 실제로는 부모 폴더를 타겟으로 해야 함 - actualTargetNode = node; // 현재 로직에서는 moveItem 함수에서 처리 - } - - await onMoveNode(draggedItem.node, actualTargetNode, position); + await onMoveNode(draggedItem.node, node, position); console.log(`✅ 이동 완료: ${draggedItem.name} → ${node.path} (${position})`); } catch (error) { @@ -277,9 +356,12 @@ export const useFileTreeDragDrop = ({ handleDragOver, handleDragLeave, handleDrop, + handleContainerDragOver, + handleContainerDrop, isDragging, isDropTarget, getDropPosition, canDrop, + isRootDropTarget, }; }; diff --git a/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts b/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts index 726c8941..fcd091fa 100644 --- a/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts +++ b/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts @@ -7,6 +7,7 @@ import { useUploadFileMutation, } from './useFileTreeApi'; import { useToast } from '@/hooks/common/useToast'; +import { useYjsFileTree } from '@/hooks/repo/useYjsFileTree'; import type { FileTreeNode } from '../types'; interface UseFileTreeOperationsParams { @@ -32,7 +33,11 @@ interface UseFileTreeOperationsResult { createItem: (fileName: string) => Promise; renameItem: (node: FileTreeNode, newName: string) => Promise; deleteItem: (node: FileTreeNode) => Promise; - moveItem: (sourceNode: FileTreeNode, targetNode: FileTreeNode) => Promise; + moveItem: ( + sourceNode: FileTreeNode, + targetNode: FileTreeNode | null, + position?: string + ) => Promise; uploadFiles: (files: File[], targetPath: string) => Promise; // 로딩 상태 @@ -73,6 +78,7 @@ export const useFileTreeOperations = ({ rootFolderId, }: UseFileTreeOperationsParams): UseFileTreeOperationsResult => { const toast = useToast(); + const { broadcastFileTreeUpdate } = useYjsFileTree(repositoryId); // 모달 상태 const [createModalOpen, setCreateModalOpen] = useState(false); @@ -189,17 +195,63 @@ export const useFileTreeOperations = ({ try { if (node.parentId === null) { console.warn('루트 레벨 항목 삭제 시도 - 삭제 불가'); - window.alert('최상위 프로젝트 폴더는 삭제할 수 없습니다.'); + toast.warning('최상위 프로젝트 폴더는 삭제할 수 없습니다.'); return; } - const confirmed = window.confirm( - `"${node.fileName}"을(를) 삭제하시겠습니까?${ - node.fileType === 'FOLDER' ? '\n폴더와 하위 모든 파일이 삭제됩니다.' : '' - }` - ); + const deleteMessage = `"${node.fileName}"을(를) 삭제하시겠습니까?${ + node.fileType === 'FOLDER' ? '\n폴더와 하위 모든 파일이 삭제됩니다.' : '' + }`; + + toast.warning(deleteMessage, 0, true); + + // 사용자 확인을 기다리기 위해 Promise 사용 + const userConfirmed = await new Promise(resolve => { + const confirmButton = document.createElement('button'); + confirmButton.textContent = '삭제'; + confirmButton.style.cssText = ` + margin-left: 8px; + padding: 4px 8px; + background: #dc2626; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + `; + confirmButton.onclick = () => { + resolve(true); + confirmButton.remove(); + cancelButton.remove(); + }; + + const cancelButton = document.createElement('button'); + cancelButton.textContent = '취소'; + cancelButton.style.cssText = ` + margin-left: 4px; + padding: 4px 8px; + background: #6b7280; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + `; + cancelButton.onclick = () => { + resolve(false); + confirmButton.remove(); + cancelButton.remove(); + }; + + const toastElement = document.querySelector('[data-toast-id]:last-child .toast-content'); + if (toastElement) { + toastElement.appendChild(confirmButton); + toastElement.appendChild(cancelButton); + } else { + // fallback: 기본 confirm 사용 + resolve(window.confirm(deleteMessage)); + } + }); - if (!confirmed) return; + if (!userConfirmed) return; console.log('파일 삭제 시작:', { fileName: node.fileName, @@ -213,62 +265,128 @@ export const useFileTreeOperations = ({ onSuccess(); } + toast.success(`"${node.fileName}"이(가) 삭제되었습니다.`); + console.log('파일 삭제 완료 - YJS 동기화됨'); } catch (error) { console.error('파일 삭제 실패:', error); + toast.error('파일 삭제에 실패했습니다.'); throw error; } }; - const moveItem = async (sourceNode: FileTreeNode, targetNode: FileTreeNode) => { - if (!sourceNode || !sourceNode.fileId || !targetNode || !targetNode.fileId) { - console.error('moveItem: 유효하지 않은 nodes:', { sourceNode, targetNode }); + const moveItem = async ( + sourceNode: FileTreeNode, + targetNode: FileTreeNode | null, + position?: string + ) => { + if (!sourceNode || !sourceNode.fileId) { + console.error('moveItem: 유효하지 않은 sourceNode:', sourceNode); return; } try { + // 동적으로 최상위 폴더 ID 계산 + const getRootFolderId = () => { + if (rootFolderId) return rootFolderId; + + // sourceNode의 path를 분석해서 최상위 폴더 찾기 + const pathParts = sourceNode.path.split('/'); + if (pathParts.length <= 1) { + // 이미 최상위에 있는 경우 + return sourceNode.parentId; + } + + // path를 역추적해서 최상위 폴더 ID 찾기 + const currentParentId = sourceNode.parentId; + let currentPath = sourceNode.path; + + while (currentPath.includes('/')) { + const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')); + if (!parentPath.includes('/')) { + // 최상위 폴더 발견 + break; + } + currentPath = parentPath; + } + + return currentParentId; + }; + + const dynamicRootFolderId = getRootFolderId(); + console.log('파일 이동 시작:', { source: { id: sourceNode.fileId, name: sourceNode.fileName, path: sourceNode.path, currentParentId: sourceNode.parentId, + level: sourceNode.level, }, - target: { - id: targetNode.fileId, - name: targetNode.fileName, - path: targetNode.path, - type: targetNode.fileType, - }, + target: targetNode + ? { + id: targetNode.fileId, + name: targetNode.fileName, + path: targetNode.path, + type: targetNode.fileType, + } + : 'TOP_LEVEL_FOLDER', + position, + rootFolderId, + dynamicRootFolderId, }); - let newParentId: number | null; + let newParentId: number | null = null; - if (targetNode.fileType === 'FOLDER') { + if (!targetNode || position === 'root') { + // 최상위 프로젝트 폴더로 이동 + if (!dynamicRootFolderId) { + throw new Error('최상위 폴더를 찾을 수 없습니다.'); + } + newParentId = dynamicRootFolderId; + } else if (targetNode.fileType === 'FOLDER' && position === 'inside') { newParentId = targetNode.fileId; } else { + // before/after 또는 파일의 경우 부모와 같은 레벨 newParentId = targetNode.parentId; } - if (newParentId === null) { - throw new Error('파일을 루트로 이동할 수 없습니다. 폴더 안으로만 이동 가능합니다.'); - } - if (sourceNode.parentId === newParentId) { console.log('동일한 위치로 이동 시도 - 스킵'); return; } - await moveMutation.mutateAsync({ + const result = await moveMutation.mutateAsync({ fileId: sourceNode.fileId, data: { newParentId }, }); + // 최상위 폴더 이동의 경우 추가 브로드캐스트 (안전장치) + if ( + (!targetNode || position === 'root') && + broadcastFileTreeUpdate && + typeof broadcastFileTreeUpdate === 'function' + ) { + console.log('최상위 폴더 이동 추가 브로드캐스트 시도'); + broadcastFileTreeUpdate('move', { + node: result, + repositoryId, + timestamp: Date.now(), + isTopLevelMove: true, + }); + } + if (onSuccess && typeof onSuccess === 'function') { onSuccess(); } + + // 최상위 폴더 이동 성공 메시지 + if (!targetNode || position === 'root') { + toast.success(`"${sourceNode.fileName}"을(를) 최상위 폴더로 이동했습니다.`); + } } catch (error) { console.error('파일 이동 실패:', error); + toast.error('파일 이동에 실패했습니다.'); throw error; } }; From d55d1cfc03c70acf5dfe42850dc7b26d3c18719a Mon Sep 17 00:00:00 2001 From: ssoogit Date: Mon, 4 Aug 2025 02:44:33 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(fileTree):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20=ED=8C=8C=EC=9D=BC=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EC=9D=98=20=EB=B9=88=EA=B3=B3=EC=97=90=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=ED=95=A0=EB=95=8C,=20=EC=98=81=EC=97=AD?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20/=20DP-212?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repo/fileTree/FileTree.module.scss | 4 ++ src/features/Repo/fileTree/FileTree.tsx | 45 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/features/Repo/fileTree/FileTree.module.scss b/src/features/Repo/fileTree/FileTree.module.scss index 05d99ff4..b6b136c6 100644 --- a/src/features/Repo/fileTree/FileTree.module.scss +++ b/src/features/Repo/fileTree/FileTree.module.scss @@ -331,6 +331,10 @@ } } +.dragMessage { + color: gray; +} + // 애니메이션들 @keyframes slide-up { from { diff --git a/src/features/Repo/fileTree/FileTree.tsx b/src/features/Repo/fileTree/FileTree.tsx index 78aef1a9..7e2d9a49 100644 --- a/src/features/Repo/fileTree/FileTree.tsx +++ b/src/features/Repo/fileTree/FileTree.tsx @@ -207,6 +207,46 @@ const FileTree: React.FC = ({ }); }, [repoId, repositoryId, enableCollaboration, treeData?.length, yMap, isLoading, error]); + // 외부 드래그 오버레이 관리 + useEffect(() => { + const fileTreeContainer = document.querySelector('[data-file-tree-container]'); + if (!fileTreeContainer) return; + + const cleanupOverlay = () => { + const existingOverlay = fileTreeContainer.querySelector('.file-tree-drag-overlay'); + if (existingOverlay) { + existingOverlay.remove(); + } + }; + + if (externalDropState.isDragOver && !externalDropState.dropTarget) { + cleanupOverlay(); + + const overlay = document.createElement('div'); + overlay.className = 'file-tree-drag-overlay'; + overlay.style.cssText = ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 3px dashed var(--filetree-external-drag-border); + border-radius: 12px; + background: transparent; + animation: external-drag-border-pulse 2s ease-in-out infinite; + pointer-events: none; + z-index: 1; + `; + + fileTreeContainer.appendChild(overlay); + console.log('전체 영역 오버레이 생성됨'); + } else { + cleanupOverlay(); + } + + return cleanupOverlay; + }, [externalDropState.isDragOver, externalDropState.dropTarget]); + // 전역 드래그 이벤트 방지 useEffect(() => { const preventGlobalDrop = (e: DragEvent) => { @@ -359,10 +399,7 @@ const FileTree: React.FC = ({ onDrop={(node, e) => handleDrop(node, e)} getDropPosition={nodeId => getDropPosition(nodeId)} // 외부 파일 드롭 - isExternalDragOver={ - externalDropState.dropTarget?.nodeId === nodeId || - (externalDropState.isDragOver && !externalDropState.dropTarget) - } + isExternalDragOver={externalDropState.dropTarget?.nodeId === nodeId} onExternalDragOver={(node, e) => handleNodeExternalDragOver(node, e)} onExternalDragLeave={(node, e) => handleNodeExternalDragLeave(node, e)} onExternalDrop={(node, e) => handleNodeExternalDrop(node, e)} From 28e504920d7b7e82fec7b48de424c68487a04f87 Mon Sep 17 00:00:00 2001 From: ssoogit Date: Mon, 4 Aug 2025 04:08:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(fileTree):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20api=20=EC=97=B0=EB=8F=99=20/=20DP-212?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/fileTree.ts | 11 +- src/features/Repo/fileTree/FileTree.tsx | 1 + .../Repo/fileTree/hooks/useFileTreeApi.ts | 5 +- .../fileTree/hooks/useFileTreeExternalDrop.ts | 116 +++++----- .../fileTree/hooks/useFileTreeOperations.ts | 198 ++++-------------- src/features/Repo/fileTree/types.ts | 4 +- 6 files changed, 121 insertions(+), 214 deletions(-) diff --git a/src/api/fileTree.ts b/src/api/fileTree.ts index 307fbba5..c8f521a9 100644 --- a/src/api/fileTree.ts +++ b/src/api/fileTree.ts @@ -77,24 +77,22 @@ export const createFile = async ( return response.data; }; -// TODO: 파일 업로드 (외부 드래그앤드롭용). 추후 API 생성시 수정 필요 +// 파일 업로드 (외부 드래그앤드롭용) - 백엔드 API 스펙에 맞게 수정 export const uploadFile = async ( repositoryId: number, file: File, - parentPath?: string + parentId: number ): Promise => { const formData = new FormData(); formData.append('file', file); - if (parentPath) { - formData.append('parentPath', parentPath); - } + formData.append('parentId', parentId.toString()); try { console.log(`📤 파일 업로드 시작:`, { repositoryId, fileName: file.name, fileSize: file.size, - parentPath: parentPath || '(루트)', + parentId, url: `api/repositories/${repositoryId}/files/upload`, }); @@ -111,6 +109,7 @@ export const uploadFile = async ( console.log('📤 파일 업로드 성공:', { status: response.status, fileName: file.name, + uploadedFile: response.data.data, }); return response.data; diff --git a/src/features/Repo/fileTree/FileTree.tsx b/src/features/Repo/fileTree/FileTree.tsx index 7e2d9a49..45506994 100644 --- a/src/features/Repo/fileTree/FileTree.tsx +++ b/src/features/Repo/fileTree/FileTree.tsx @@ -125,6 +125,7 @@ const FileTree: React.FC = ({ handleNodeExternalDrop, } = useFileTreeExternalDrop({ onFileUpload: uploadFiles, + rootFolderId: treeData?.find(node => node.parentId === null)?.fileId, }); // 파일트리 데이터가 변경될 때마다 탭과 동기화 diff --git a/src/features/Repo/fileTree/hooks/useFileTreeApi.ts b/src/features/Repo/fileTree/hooks/useFileTreeApi.ts index b1e680ce..26982829 100644 --- a/src/features/Repo/fileTree/hooks/useFileTreeApi.ts +++ b/src/features/Repo/fileTree/hooks/useFileTreeApi.ts @@ -9,6 +9,7 @@ import { } from '@/api/fileTree'; import type { CreateFileRequest, MoveFileRequest, RenameFileRequest, FileTreeNode } from '../types'; import { useYjsFileTree } from '@/hooks/repo/useYjsFileTree'; + // 파일 트리 조회 export const useFileTreeQuery = (repositoryId: number) => { return useQuery({ @@ -164,8 +165,8 @@ export const useUploadFileMutation = (repositoryId: number) => { const { broadcastFileTreeUpdate } = useYjsFileTree(repositoryId); return useMutation({ - mutationFn: async ({ file, parentPath }: { file: File; parentPath?: string }) => { - const response = await uploadFile(repositoryId, file, parentPath); + mutationFn: async ({ file, parentId }: { file: File; parentId: number }) => { + const response = await uploadFile(repositoryId, file, parentId); return response.data as FileTreeNode; }, onSuccess: async (uploadedNode: FileTreeNode) => { diff --git a/src/features/Repo/fileTree/hooks/useFileTreeExternalDrop.ts b/src/features/Repo/fileTree/hooks/useFileTreeExternalDrop.ts index b81f9f29..3ac18783 100644 --- a/src/features/Repo/fileTree/hooks/useFileTreeExternalDrop.ts +++ b/src/features/Repo/fileTree/hooks/useFileTreeExternalDrop.ts @@ -1,18 +1,20 @@ import { useState, useCallback, useRef } from 'react'; +import { useToast } from '@/hooks/common/useToast'; import type { FileTreeNode } from '../types'; interface ExternalDropState { isDragOver: boolean; dropTarget: { nodeId: string; - path: string; + parentId: number; type: 'folder' | 'file' | 'root'; } | null; dragPreview: string | null; } interface UseFileTreeExternalDropProps { - onFileUpload: (files: File[], targetPath: string) => Promise; + onFileUpload: (files: File[], targetParentId: number) => Promise; + rootFolderId?: number; // 추가: 최상위 프로젝트 폴더 ID } interface UseFileTreeExternalDropReturn { @@ -29,7 +31,9 @@ interface UseFileTreeExternalDropReturn { export const useFileTreeExternalDrop = ({ onFileUpload, + rootFolderId, }: UseFileTreeExternalDropProps): UseFileTreeExternalDropReturn => { + const toast = useToast(); const [externalDropState, setExternalDropState] = useState({ isDragOver: false, dropTarget: null, @@ -42,7 +46,6 @@ export const useFileTreeExternalDrop = ({ // 외부 파일인지 확인하는 함수 const isExternalFile = useCallback((e: React.DragEvent): boolean => { const types = Array.from(e.dataTransfer.types); - // 내부 드래그(application/json)가 아니고 파일이 포함된 경우 return !types.includes('application/json') && types.includes('Files'); }, []); @@ -63,19 +66,32 @@ export const useFileTreeExternalDrop = ({ return { count, preview: `${files[0].name} 외 ${count - 1}개` }; }, []); - // 타겟 경로 계산 - const calculateTargetPath = useCallback((node: FileTreeNode | null): string => { - if (!node) return ''; // 루트 - - if (node.fileType === 'FOLDER') { - return node.path; // 폴더 내부 - } else { - // 파일과 같은 레벨 (부모 폴더) - const pathParts = node.path.split('/'); - pathParts.pop(); - return pathParts.join('/'); - } - }, []); + // 타겟 부모 ID 계산 + const calculateTargetParentId = useCallback( + (node: FileTreeNode | null): number => { + if (!node) { + // 빈 공간(루트)에 드롭하는 경우 → 최상위 프로젝트 폴더에 업로드 + if (!rootFolderId) { + throw new Error('최상위 폴더 ID를 찾을 수 없습니다.'); + } + return rootFolderId; + } + + if (node.fileType === 'FOLDER') { + return node.fileId; // 폴더 내부에 업로드 + } else { + // 파일과 같은 레벨 (부모 폴더에 업로드) + if (!node.parentId) { + if (!rootFolderId) { + throw new Error('최상위 폴더 ID를 찾을 수 없습니다.'); + } + return rootFolderId; + } + return node.parentId; + } + }, + [rootFolderId] + ); // 전체 파일트리 영역 드래그 엔터 const handleExternalDragEnter = useCallback( @@ -84,7 +100,6 @@ export const useFileTreeExternalDrop = ({ preventDefaultDrop(e); - // 드래그 진입 시에만 카운터 증가 if (dragCounterRef.current === 0) { dragCounterRef.current = 1; @@ -111,9 +126,14 @@ export const useFileTreeExternalDrop = ({ if (!isExternalFile(e)) return; preventDefaultDrop(e); - e.dataTransfer.dropEffect = 'copy'; + + if (rootFolderId) { + e.dataTransfer.dropEffect = 'copy'; + } else { + e.dataTransfer.dropEffect = 'none'; + } }, - [isExternalFile, preventDefaultDrop] + [isExternalFile, preventDefaultDrop, rootFolderId] ); // 전체 파일트리 영역 드래그 리브 @@ -123,11 +143,9 @@ export const useFileTreeExternalDrop = ({ preventDefaultDrop(e); - // 더 엄격한 영역 벗어남 감지 const currentTarget = e.currentTarget as HTMLElement; const relatedTarget = e.relatedTarget as HTMLElement; - // relatedTarget이 현재 요소의 자식이 아닌 경우에만 드래그 리브 처리 if (!currentTarget.contains(relatedTarget)) { dragCounterRef.current = 0; @@ -141,13 +159,13 @@ export const useFileTreeExternalDrop = ({ dropTarget: null, dragPreview: null, }); - }, 150); // 타임아웃을 늘려서 더 안정적으로 + }, 150); } }, [isExternalFile, preventDefaultDrop] ); - // 전체 파일트리 영역 드롭 (빈 공간 = 루트) + // 전체 파일트리 영역 드롭 (빈 공간 = 최상위 프로젝트 폴더) const handleExternalDrop = useCallback( async (e: React.DragEvent) => { if (!isExternalFile(e)) return; @@ -158,12 +176,14 @@ export const useFileTreeExternalDrop = ({ if (files.length === 0) return; try { - await onFileUpload(files, ''); // 루트 경로 - console.log(`📁 루트에 ${files.length}개 파일 업로드 완료`); + const targetParentId = calculateTargetParentId(null); + await onFileUpload(files, targetParentId); + + toast.success(`최상위 프로젝트 폴더에 ${files.length}개 파일 업로드 완료`); } catch (error) { - console.error('파일 업로드 실패:', error); + const errorMessage = error instanceof Error ? error.message : '파일 업로드 실패'; + toast.error(errorMessage); } finally { - // 상태 완전 초기화 if (dragLeaveTimeoutRef.current) { clearTimeout(dragLeaveTimeoutRef.current); dragLeaveTimeoutRef.current = null; @@ -177,7 +197,7 @@ export const useFileTreeExternalDrop = ({ }); } }, - [isExternalFile, preventDefaultDrop, onFileUpload] + [isExternalFile, preventDefaultDrop, calculateTargetParentId, onFileUpload, toast] ); // 특정 노드에 드래그 오버 @@ -186,20 +206,24 @@ export const useFileTreeExternalDrop = ({ if (!isExternalFile(e)) return; preventDefaultDrop(e); - e.dataTransfer.dropEffect = 'copy'; - const targetPath = calculateTargetPath(node); + try { + const targetParentId = calculateTargetParentId(node); + e.dataTransfer.dropEffect = 'copy'; - setExternalDropState(prev => ({ - ...prev, - dropTarget: { - nodeId: node.fileId.toString(), - path: targetPath, - type: node.fileType === 'FOLDER' ? 'folder' : 'file', - }, - })); + setExternalDropState(prev => ({ + ...prev, + dropTarget: { + nodeId: node.fileId.toString(), + parentId: targetParentId, + type: node.fileType === 'FOLDER' ? 'folder' : 'file', + }, + })); + } catch { + e.dataTransfer.dropEffect = 'none'; + } }, - [isExternalFile, preventDefaultDrop, calculateTargetPath] + [isExternalFile, preventDefaultDrop, calculateTargetParentId] ); // 특정 노드에서 드래그 리브 @@ -209,7 +233,6 @@ export const useFileTreeExternalDrop = ({ preventDefaultDrop(e); - // 노드에서 벗어났을 때 해당 노드 타겟 해제 setExternalDropState(prev => ({ ...prev, dropTarget: prev.dropTarget?.nodeId === node.fileId.toString() ? null : prev.dropTarget, @@ -228,21 +251,20 @@ export const useFileTreeExternalDrop = ({ const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; - const targetPath = calculateTargetPath(node); - try { - await onFileUpload(files, targetPath); + const targetParentId = calculateTargetParentId(node); + await onFileUpload(files, targetParentId); const locationDesc = node.fileType === 'FOLDER' ? `"${node.fileName}" 폴더 내부` : `"${node.fileName}" 파일과 같은 레벨`; - console.log(`📁 ${locationDesc}에 ${files.length}개 파일 업로드 완료`); + toast.success(`${locationDesc}에 ${files.length}개 파일 업로드 완료`); } catch (error) { - console.error('파일 업로드 실패:', error); + const errorMessage = error instanceof Error ? error.message : '파일 업로드 실패'; + toast.error(errorMessage); } finally { - // 상태 완전 초기화 if (dragLeaveTimeoutRef.current) { clearTimeout(dragLeaveTimeoutRef.current); dragLeaveTimeoutRef.current = null; @@ -256,7 +278,7 @@ export const useFileTreeExternalDrop = ({ }); } }, - [isExternalFile, preventDefaultDrop, calculateTargetPath, onFileUpload] + [isExternalFile, preventDefaultDrop, calculateTargetParentId, onFileUpload, toast] ); // 특정 노드가 드롭 타겟인지 확인 diff --git a/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts b/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts index fcd091fa..a55a22dc 100644 --- a/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts +++ b/src/features/Repo/fileTree/hooks/useFileTreeOperations.ts @@ -38,7 +38,7 @@ interface UseFileTreeOperationsResult { targetNode: FileTreeNode | null, position?: string ) => Promise; - uploadFiles: (files: File[], targetPath: string) => Promise; + uploadFiles: (files: File[], targetParentId?: number) => Promise; // 로딩 상태 isCreating: boolean; @@ -48,30 +48,6 @@ interface UseFileTreeOperationsResult { isUploading: boolean; } -// 안전한 ID 변환 함수 -const safeToString = (value: unknown): string => { - if (value === null || value === undefined) { - console.warn('safeToString: null 또는 undefined 값 감지'); - return ''; - } - - if (typeof value === 'string') { - return value; - } - - if (typeof value === 'number') { - return value.toString(); - } - - // 객체나 다른 타입인 경우 - try { - return String(value); - } catch (error) { - console.error('safeToString 변환 실패:', error); - return ''; - } -}; - export const useFileTreeOperations = ({ repositoryId, onSuccess, @@ -107,12 +83,7 @@ export const useFileTreeOperations = ({ }; const startEditing = (nodeId: string) => { - const safeNodeId = safeToString(nodeId); - if (safeNodeId) { - setEditingNode(safeNodeId); - } else { - // - } + setEditingNode(nodeId); }; const stopEditing = () => { @@ -122,7 +93,7 @@ export const useFileTreeOperations = ({ // CRUD 작업 함수들 const createItem = async (fileName: string) => { if (!createModalType) { - toast.error('createItem: createModalType이 없음'); + toast.error('생성할 파일 타입이 지정되지 않았습니다.'); return; } @@ -145,29 +116,23 @@ export const useFileTreeOperations = ({ closeCreateModal(); - // 성공 콜백 호출 if (onSuccess && typeof onSuccess === 'function') { onSuccess(); } } catch (error) { - console.error('파일 생성 실패:', error); + const errorMessage = error instanceof Error ? error.message : '파일 생성에 실패했습니다.'; + toast.error(errorMessage); throw error; } }; const renameItem = async (node: FileTreeNode, newName: string) => { if (!node || !node.fileId) { - console.error('renameItem: 유효하지 않은 node:', node); + toast.error('유효하지 않은 파일입니다.'); return; } try { - console.log('파일 이름 변경 시작:', { - currentName: node.fileName, - newName, - fileId: node.fileId, - }); - await renameMutation.mutateAsync({ fileId: node.fileId, data: { newFileName: newName }, @@ -179,22 +144,23 @@ export const useFileTreeOperations = ({ onSuccess(); } - console.log('파일 이름 변경 완료 - YJS 동기화됨'); + toast.success(`"${node.fileName}"의 이름이 "${newName}"으로 변경되었습니다.`); } catch (error) { - console.error('파일 이름 변경 실패:', error); + const errorMessage = + error instanceof Error ? error.message : '파일 이름 변경에 실패했습니다.'; + toast.error(errorMessage); throw error; } }; const deleteItem = async (node: FileTreeNode) => { if (!node || !node.fileId) { - console.error('deleteItem: 유효하지 않은 node:', node); + toast.error('유효하지 않은 파일입니다.'); return; } try { if (node.parentId === null) { - console.warn('루트 레벨 항목 삭제 시도 - 삭제 불가'); toast.warning('최상위 프로젝트 폴더는 삭제할 수 없습니다.'); return; } @@ -203,74 +169,18 @@ export const useFileTreeOperations = ({ node.fileType === 'FOLDER' ? '\n폴더와 하위 모든 파일이 삭제됩니다.' : '' }`; - toast.warning(deleteMessage, 0, true); - - // 사용자 확인을 기다리기 위해 Promise 사용 - const userConfirmed = await new Promise(resolve => { - const confirmButton = document.createElement('button'); - confirmButton.textContent = '삭제'; - confirmButton.style.cssText = ` - margin-left: 8px; - padding: 4px 8px; - background: #dc2626; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - `; - confirmButton.onclick = () => { - resolve(true); - confirmButton.remove(); - cancelButton.remove(); - }; - - const cancelButton = document.createElement('button'); - cancelButton.textContent = '취소'; - cancelButton.style.cssText = ` - margin-left: 4px; - padding: 4px 8px; - background: #6b7280; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - `; - cancelButton.onclick = () => { - resolve(false); - confirmButton.remove(); - cancelButton.remove(); - }; - - const toastElement = document.querySelector('[data-toast-id]:last-child .toast-content'); - if (toastElement) { - toastElement.appendChild(confirmButton); - toastElement.appendChild(cancelButton); - } else { - // fallback: 기본 confirm 사용 - resolve(window.confirm(deleteMessage)); - } - }); - - if (!userConfirmed) return; + if (confirm(deleteMessage)) { + await deleteMutation.mutateAsync(node.fileId); - console.log('파일 삭제 시작:', { - fileName: node.fileName, - fileId: node.fileId, - fileType: node.fileType, - }); - - await deleteMutation.mutateAsync(node.fileId); + if (onSuccess && typeof onSuccess === 'function') { + onSuccess(); + } - if (onSuccess && typeof onSuccess === 'function') { - onSuccess(); + toast.success(`"${node.fileName}"이(가) 삭제되었습니다.`); } - - toast.success(`"${node.fileName}"이(가) 삭제되었습니다.`); - - console.log('파일 삭제 완료 - YJS 동기화됨'); } catch (error) { - console.error('파일 삭제 실패:', error); - toast.error('파일 삭제에 실패했습니다.'); + const errorMessage = error instanceof Error ? error.message : '파일 삭제에 실패했습니다.'; + toast.error(errorMessage); throw error; } }; @@ -281,7 +191,7 @@ export const useFileTreeOperations = ({ position?: string ) => { if (!sourceNode || !sourceNode.fileId) { - console.error('moveItem: 유효하지 않은 sourceNode:', sourceNode); + toast.error('이동할 파일이 유효하지 않습니다.'); return; } @@ -290,21 +200,17 @@ export const useFileTreeOperations = ({ const getRootFolderId = () => { if (rootFolderId) return rootFolderId; - // sourceNode의 path를 분석해서 최상위 폴더 찾기 const pathParts = sourceNode.path.split('/'); if (pathParts.length <= 1) { - // 이미 최상위에 있는 경우 return sourceNode.parentId; } - // path를 역추적해서 최상위 폴더 ID 찾기 const currentParentId = sourceNode.parentId; let currentPath = sourceNode.path; while (currentPath.includes('/')) { const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')); if (!parentPath.includes('/')) { - // 최상위 폴더 발견 break; } currentPath = parentPath; @@ -315,31 +221,9 @@ export const useFileTreeOperations = ({ const dynamicRootFolderId = getRootFolderId(); - console.log('파일 이동 시작:', { - source: { - id: sourceNode.fileId, - name: sourceNode.fileName, - path: sourceNode.path, - currentParentId: sourceNode.parentId, - level: sourceNode.level, - }, - target: targetNode - ? { - id: targetNode.fileId, - name: targetNode.fileName, - path: targetNode.path, - type: targetNode.fileType, - } - : 'TOP_LEVEL_FOLDER', - position, - rootFolderId, - dynamicRootFolderId, - }); - let newParentId: number | null = null; if (!targetNode || position === 'root') { - // 최상위 프로젝트 폴더로 이동 if (!dynamicRootFolderId) { throw new Error('최상위 폴더를 찾을 수 없습니다.'); } @@ -347,12 +231,11 @@ export const useFileTreeOperations = ({ } else if (targetNode.fileType === 'FOLDER' && position === 'inside') { newParentId = targetNode.fileId; } else { - // before/after 또는 파일의 경우 부모와 같은 레벨 newParentId = targetNode.parentId; } if (sourceNode.parentId === newParentId) { - console.log('동일한 위치로 이동 시도 - 스킵'); + toast.info('동일한 위치로는 이동할 수 없습니다.'); return; } @@ -361,13 +244,12 @@ export const useFileTreeOperations = ({ data: { newParentId }, }); - // 최상위 폴더 이동의 경우 추가 브로드캐스트 (안전장치) + // 최상위 폴더 이동의 경우 추가 브로드캐스트 if ( (!targetNode || position === 'root') && broadcastFileTreeUpdate && typeof broadcastFileTreeUpdate === 'function' ) { - console.log('최상위 폴더 이동 추가 브로드캐스트 시도'); broadcastFileTreeUpdate('move', { node: result, repositoryId, @@ -380,50 +262,52 @@ export const useFileTreeOperations = ({ onSuccess(); } - // 최상위 폴더 이동 성공 메시지 if (!targetNode || position === 'root') { toast.success(`"${sourceNode.fileName}"을(를) 최상위 폴더로 이동했습니다.`); + } else { + toast.success(`"${sourceNode.fileName}"이(가) 이동되었습니다.`); } } catch (error) { - console.error('파일 이동 실패:', error); - toast.error('파일 이동에 실패했습니다.'); + const errorMessage = error instanceof Error ? error.message : '파일 이동에 실패했습니다.'; + toast.error(errorMessage); throw error; } }; - const uploadFiles = async (files: File[], targetPath: string) => { + const uploadFiles = async (files: File[], targetParentId?: number) => { if (!files || files.length === 0) { - console.warn('uploadFiles: 업로드할 파일이 없음'); + toast.warning('업로드할 파일이 없습니다.'); return; } try { - if (!targetPath) { - throw new Error('루트에는 파일을 업로드할 수 없습니다. 폴더 안으로 드래그해주세요.'); - } + let finalParentId = targetParentId; - console.log('파일 업로드 시작:', { - fileCount: files.length, - targetPath, - fileNames: files.map(f => f.name), - }); + if (!finalParentId) { + if (rootFolderId) { + finalParentId = rootFolderId; + } else { + throw new Error('업로드할 폴더를 찾을 수 없습니다. 폴더를 선택해주세요.'); + } + } const uploadPromises = files.map(file => uploadMutation.mutateAsync({ file, - parentPath: targetPath, + parentId: finalParentId!, }) ); await Promise.all(uploadPromises); - console.log('모든 파일 업로드 완료 - YJS 동기화됨'); - if (onSuccess && typeof onSuccess === 'function') { onSuccess(); } + + toast.success(`${files.length}개 파일 업로드가 완료되었습니다.`); } catch (error) { - console.error('파일 업로드 실패:', error); + const errorMessage = error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'; + toast.error(errorMessage); throw error; } }; diff --git a/src/features/Repo/fileTree/types.ts b/src/features/Repo/fileTree/types.ts index 330bb598..7be68629 100644 --- a/src/features/Repo/fileTree/types.ts +++ b/src/features/Repo/fileTree/types.ts @@ -40,8 +40,8 @@ export interface ExternalDropState { isDragOver: boolean; dropTarget: { nodeId: string; - path: string; - type: 'folder' | 'file' | 'root'; // NOTE: 드롭 타겟 분류용 소문자 + parentId: number; + type: 'folder' | 'file'; } | null; dragPreview: string | null; }