mirror of
https://github.com/lordmathis/lemma.git
synced 2025-12-23 01:54:28 +00:00
Add FolderSelector component and integrate with CreateFileModal
This commit is contained in:
266
app/src/components/files/FolderSelector.tsx
Normal file
266
app/src/components/files/FolderSelector.tsx
Normal file
@@ -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<HTMLElement>): Size | undefined => {
|
||||||
|
const [size, setSize] = useState<Size>();
|
||||||
|
|
||||||
|
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<FileNode>;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
paddingLeft: `${node.level * 16 + 8}px`,
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
paddingBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'var(--mantine-color-blue-filled)'
|
||||||
|
: 'transparent',
|
||||||
|
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.1s ease, color 0.1s ease',
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={node.data.name}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
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 && (
|
||||||
|
<IconChevronRight
|
||||||
|
size={14}
|
||||||
|
onClick={handleChevronClick}
|
||||||
|
style={{
|
||||||
|
marginRight: '4px',
|
||||||
|
transform: node.isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Spacer for items without chevron */}
|
||||||
|
{!hasChildren && <div style={{ width: '18px', flexShrink: 0 }} />}
|
||||||
|
|
||||||
|
{/* Folder icon */}
|
||||||
|
{node.isOpen ? (
|
||||||
|
<IconFolderOpen
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isSelected
|
||||||
|
? 'var(--mantine-color-white)'
|
||||||
|
: 'var(--mantine-color-yellow-filled)'
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconFolder
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isSelected
|
||||||
|
? 'var(--mantine-color-white)'
|
||||||
|
: 'var(--mantine-color-yellow-filled)'
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.data.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root node component
|
||||||
|
function RootNode({
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingLeft: '8px',
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
paddingBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'var(--mantine-color-blue-filled)'
|
||||||
|
: 'transparent',
|
||||||
|
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.1s ease, color 0.1s ease',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
'var(--mantine-color-default-hover)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '18px', flexShrink: 0 }} />
|
||||||
|
<IconFolder
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isSelected
|
||||||
|
? 'var(--mantine-color-white)'
|
||||||
|
: 'var(--mantine-color-yellow-filled)'
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/ (root)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FolderSelector: React.FC<FolderSelectorProps> = ({
|
||||||
|
files,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const target = useRef<HTMLDivElement>(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 (
|
||||||
|
<Box
|
||||||
|
ref={target}
|
||||||
|
style={{
|
||||||
|
maxHeight: '300px',
|
||||||
|
height: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Root option */}
|
||||||
|
<RootNode isSelected={selectedPath === ''} onSelect={() => onSelect('')} />
|
||||||
|
|
||||||
|
{/* Folder tree */}
|
||||||
|
{size && folders.length > 0 && (
|
||||||
|
<Tree
|
||||||
|
data={folders}
|
||||||
|
openByDefault={false}
|
||||||
|
width={size.width - 16}
|
||||||
|
height={treeHeight}
|
||||||
|
indent={24}
|
||||||
|
rowHeight={28}
|
||||||
|
idAccessor="id"
|
||||||
|
disableDrag={() => true}
|
||||||
|
disableDrop={() => true}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<FolderNode {...props} selectedPath={selectedPath} onSelect={onSelect} />
|
||||||
|
)}
|
||||||
|
</Tree>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FolderSelector;
|
||||||
@@ -29,6 +29,31 @@ vi.mock('../../../contexts/ModalContext', () => ({
|
|||||||
useModalContext: () => mockModalContext,
|
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
|
// Helper wrapper component for testing
|
||||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
@@ -47,6 +72,8 @@ describe('CreateFileModal', () => {
|
|||||||
mockOnCreateFile.mockReset();
|
mockOnCreateFile.mockReset();
|
||||||
mockOnCreateFile.mockResolvedValue(undefined);
|
mockOnCreateFile.mockResolvedValue(undefined);
|
||||||
mockModalContext.setNewFileModalVisible.mockClear();
|
mockModalContext.setNewFileModalVisible.mockClear();
|
||||||
|
mockLoadFileList.mockClear();
|
||||||
|
mockLoadFileList.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Modal Visibility and Content', () => {
|
describe('Modal Visibility and Content', () => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
import { useFileList } from '../../../hooks/useFileList';
|
||||||
|
import { FolderSelector } from '../../files/FolderSelector';
|
||||||
|
|
||||||
interface CreateFileModalProps {
|
interface CreateFileModalProps {
|
||||||
onCreateFile: (fileName: string) => Promise<void>;
|
onCreateFile: (fileName: string) => Promise<void>;
|
||||||
@@ -8,16 +11,49 @@ interface CreateFileModalProps {
|
|||||||
|
|
||||||
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
||||||
const [fileName, setFileName] = useState<string>('');
|
const [fileName, setFileName] = useState<string>('');
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<string>('');
|
||||||
|
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
|
||||||
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
||||||
|
const { files, loadFileList } = useFileList();
|
||||||
|
|
||||||
const handleSubmit = async (): Promise<void> => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
await onCreateFile(fileName.trim());
|
const fullPath = selectedFolder
|
||||||
|
? `${selectedFolder}/${fileName.trim()}`
|
||||||
|
: fileName.trim();
|
||||||
|
await onCreateFile(fullPath);
|
||||||
setFileName('');
|
setFileName('');
|
||||||
|
setSelectedFolder('');
|
||||||
setNewFileModalVisible(false);
|
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 => {
|
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -28,27 +64,82 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={newFileModalVisible}
|
opened={newFileModalVisible}
|
||||||
onClose={() => setNewFileModalVisible(false)}
|
onClose={handleClose}
|
||||||
title="Create New File"
|
title="Create New File"
|
||||||
centered
|
centered
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Box maw={400} mx="auto">
|
<Box maw={400} mx="auto">
|
||||||
|
{/* Location input with folder picker */}
|
||||||
|
<Popover
|
||||||
|
opened={popoverOpened}
|
||||||
|
onChange={setPopoverOpened}
|
||||||
|
position="bottom-start"
|
||||||
|
width="target"
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<TextInput
|
||||||
|
label="Location"
|
||||||
|
type="text"
|
||||||
|
placeholder="Select folder"
|
||||||
|
data-testid="location-input"
|
||||||
|
value={locationDisplay}
|
||||||
|
readOnly
|
||||||
|
mb="md"
|
||||||
|
w="100%"
|
||||||
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => setPopoverOpened((o) => !o)}
|
||||||
|
data-testid="folder-picker-button"
|
||||||
|
>
|
||||||
|
<IconFolderOpen size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => setPopoverOpened(true)}
|
||||||
|
/>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<FolderSelector
|
||||||
|
files={files}
|
||||||
|
selectedPath={selectedFolder}
|
||||||
|
onSelect={handleFolderSelect}
|
||||||
|
/>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* File name input */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="File Name"
|
label="File Name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter file name"
|
placeholder="example.md"
|
||||||
data-testid="file-name-input"
|
data-testid="file-name-input"
|
||||||
value={fileName}
|
value={fileName}
|
||||||
onChange={(event) => setFileName(event.currentTarget.value)}
|
onChange={(event) => setFileName(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
mb="md"
|
mb="xs"
|
||||||
w="100%"
|
w="100%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Hint text */}
|
||||||
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
|
Tip: Use / to create nested folders (e.g., folder/subfolder/file.md)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Full path preview */}
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Full path: {fullPathPreview}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setNewFileModalVisible(false)}
|
onClick={handleClose}
|
||||||
data-testid="cancel-create-file-button"
|
data-testid="cancel-create-file-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
44
app/src/utils/fileTreeUtils.ts
Normal file
44
app/src/utils/fileTreeUtils.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively filter tree to only include folders
|
||||||
|
* @param nodes - Array of FileNode objects
|
||||||
|
* @returns New tree structure with only folder nodes
|
||||||
|
*/
|
||||||
|
export const filterToFolders = (nodes: FileNode[]): FileNode[] => {
|
||||||
|
return nodes
|
||||||
|
.filter((node) => node.children !== undefined)
|
||||||
|
.map((node) => {
|
||||||
|
const filtered: FileNode = {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
path: node.path,
|
||||||
|
};
|
||||||
|
if (node.children) {
|
||||||
|
filtered.children = filterToFolders(node.children);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific folder node by its path
|
||||||
|
* @param nodes - Array of FileNode objects
|
||||||
|
* @param path - Path to search for
|
||||||
|
* @returns The found FileNode or null
|
||||||
|
*/
|
||||||
|
export const findFolderByPath = (
|
||||||
|
nodes: FileNode[],
|
||||||
|
path: string
|
||||||
|
): FileNode | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.path === path && node.children !== undefined) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
const found = findFolderByPath(node.children, path);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user