From bc49391b5cdac1f3e41e27bff42ad37ffb16189c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 11 Nov 2025 20:05:27 +0100 Subject: [PATCH] Add FolderSelector component and integrate with CreateFileModal --- app/src/components/files/FolderSelector.tsx | 266 ++++++++++++++++++ .../modals/file/CreateFileModal.test.tsx | 27 ++ .../modals/file/CreateFileModal.tsx | 103 ++++++- app/src/utils/fileTreeUtils.ts | 44 +++ 4 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 app/src/components/files/FolderSelector.tsx create mode 100644 app/src/utils/fileTreeUtils.ts diff --git a/app/src/components/files/FolderSelector.tsx b/app/src/components/files/FolderSelector.tsx new file mode 100644 index 0000000..c841e23 --- /dev/null +++ b/app/src/components/files/FolderSelector.tsx @@ -0,0 +1,266 @@ +import React, { useRef, useLayoutEffect, useState } from 'react'; +import { Box } from '@mantine/core'; +import { Tree, type NodeApi } from 'react-arborist'; +import { + IconFolder, + IconFolderOpen, + IconChevronRight, +} from '@tabler/icons-react'; +import useResizeObserver from '@react-hook/resize-observer'; +import { filterToFolders } from '../../utils/fileTreeUtils'; +import type { FileNode } from '@/types/models'; + +interface FolderSelectorProps { + files: FileNode[]; + selectedPath: string; + onSelect: (path: string) => void; +} + +interface Size { + width: number; + height: number; +} + +const useSize = (target: React.RefObject): Size | undefined => { + const [size, setSize] = useState(); + + useLayoutEffect(() => { + if (target.current) { + setSize(target.current.getBoundingClientRect()); + } + }, [target]); + + useResizeObserver(target, (entry) => setSize(entry.contentRect)); + return size; +}; + +// Node component for rendering folders +function FolderNode({ + node, + style, + selectedPath, + onSelect, +}: { + node: NodeApi; + style: React.CSSProperties; + selectedPath: string; + onSelect: (path: string) => void; +}) { + const isSelected = node.data.path === selectedPath; + const hasChildren = node.children && node.children.length > 0; + + const handleClick = () => { + onSelect(node.data.path); + }; + + const handleChevronClick = (e: React.MouseEvent) => { + e.stopPropagation(); + node.toggle(); + }; + + return ( +
{ + if (!isSelected) { + e.currentTarget.style.backgroundColor = + 'var(--mantine-color-default-hover)'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > + {/* Chevron for folders with children */} + {hasChildren && ( + + )} + {/* Spacer for items without chevron */} + {!hasChildren &&
} + + {/* Folder icon */} + {node.isOpen ? ( + + ) : ( + + )} + + {/* Name */} + + {node.data.name} + +
+ ); +} + +// Root node component +function RootNode({ + isSelected, + onSelect, +}: { + isSelected: boolean; + onSelect: () => void; +}) { + return ( +
{ + if (!isSelected) { + e.currentTarget.style.backgroundColor = + 'var(--mantine-color-default-hover)'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > +
+ + + / (root) + +
+ ); +} + +export const FolderSelector: React.FC = ({ + files, + selectedPath, + onSelect, +}) => { + const target = useRef(null); + const size = useSize(target); + + // Filter to only folders + const folders = filterToFolders(files); + + // Calculate tree height: root node (32px) + folders + const rootNodeHeight = 32; + const treeHeight = size ? size.height - rootNodeHeight : 0; + + return ( + + {/* Root option */} + onSelect('')} /> + + {/* Folder tree */} + {size && folders.length > 0 && ( + true} + disableDrop={() => true} + > + {(props) => ( + + )} + + )} + + ); +}; + +export default FolderSelector; diff --git a/app/src/components/modals/file/CreateFileModal.test.tsx b/app/src/components/modals/file/CreateFileModal.test.tsx index 4d7a605..aaa0b71 100644 --- a/app/src/components/modals/file/CreateFileModal.test.tsx +++ b/app/src/components/modals/file/CreateFileModal.test.tsx @@ -29,6 +29,31 @@ vi.mock('../../../contexts/ModalContext', () => ({ useModalContext: () => mockModalContext, })); +// Mock useFileList hook +const mockLoadFileList = vi.fn(); +const mockFiles = [ + { + id: '1', + name: 'docs', + path: 'docs', + children: [ + { + id: '2', + name: 'guides', + path: 'docs/guides', + children: [], + }, + ], + }, +]; + +vi.mock('../../../hooks/useFileList', () => ({ + useFileList: () => ({ + files: mockFiles, + loadFileList: mockLoadFileList, + }), +})); + // Helper wrapper component for testing const TestWrapper = ({ children }: { children: React.ReactNode }) => ( {children} @@ -47,6 +72,8 @@ describe('CreateFileModal', () => { mockOnCreateFile.mockReset(); mockOnCreateFile.mockResolvedValue(undefined); mockModalContext.setNewFileModalVisible.mockClear(); + mockLoadFileList.mockClear(); + mockLoadFileList.mockResolvedValue(undefined); }); describe('Modal Visibility and Content', () => { diff --git a/app/src/components/modals/file/CreateFileModal.tsx b/app/src/components/modals/file/CreateFileModal.tsx index f3c7d7e..e64e660 100644 --- a/app/src/components/modals/file/CreateFileModal.tsx +++ b/app/src/components/modals/file/CreateFileModal.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; -import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; +import { Modal, TextInput, Button, Group, Box, Popover, ActionIcon, Text } from '@mantine/core'; +import { IconFolderOpen } from '@tabler/icons-react'; import { useModalContext } from '../../../contexts/ModalContext'; +import { useFileList } from '../../../hooks/useFileList'; +import { FolderSelector } from '../../files/FolderSelector'; interface CreateFileModalProps { onCreateFile: (fileName: string) => Promise; @@ -8,16 +11,49 @@ interface CreateFileModalProps { const CreateFileModal: React.FC = ({ onCreateFile }) => { const [fileName, setFileName] = useState(''); + const [selectedFolder, setSelectedFolder] = useState(''); + const [popoverOpened, setPopoverOpened] = useState(false); const { newFileModalVisible, setNewFileModalVisible } = useModalContext(); + const { files, loadFileList } = useFileList(); const handleSubmit = async (): Promise => { if (fileName) { - await onCreateFile(fileName.trim()); + const fullPath = selectedFolder + ? `${selectedFolder}/${fileName.trim()}` + : fileName.trim(); + await onCreateFile(fullPath); setFileName(''); + setSelectedFolder(''); setNewFileModalVisible(false); } }; + const handleClose = () => { + setFileName(''); + setSelectedFolder(''); + setNewFileModalVisible(false); + }; + + const handleFolderSelect = (path: string) => { + setSelectedFolder(path); + // Keep popover open so users can continue browsing + }; + + // Load files when modal opens + React.useEffect(() => { + if (newFileModalVisible) { + void loadFileList(); + } + }, [newFileModalVisible, loadFileList]); + + // Generate full path preview + const fullPathPreview = selectedFolder + ? `${selectedFolder}/${fileName || 'filename'}` + : fileName || 'filename'; + + // Display text for location input + const locationDisplay = selectedFolder || '/ (root)'; + const handleKeyDown = (event: React.KeyboardEvent): void => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); @@ -28,27 +64,82 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { return ( setNewFileModalVisible(false)} + onClose={handleClose} title="Create New File" centered size="sm" > + {/* Location input with folder picker */} + + + setPopoverOpened((o) => !o)} + data-testid="folder-picker-button" + > + + + } + styles={{ + input: { + cursor: 'pointer', + }, + }} + onClick={() => setPopoverOpened(true)} + /> + + + + + + + {/* File name input */} setFileName(event.currentTarget.value)} onKeyDown={handleKeyDown} - mb="md" + mb="xs" w="100%" /> + + {/* Hint text */} + + Tip: Use / to create nested folders (e.g., folder/subfolder/file.md) + + + {/* Full path preview */} + + Full path: {fullPathPreview} + +