import { Disclosure } from '@headlessui/react'; import { ChevronRightIcon, FolderIcon, PlusIcon, HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/solid'; import * as React from 'react'; import { useMemo } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { Page } from '../../models'; import { StructureItem } from './StructureItem'; import { parseWinPath } from '../../../helpers/parseWinPath'; import { SelectedStructureFolderAtom, SettingsSelector } from '../../state'; import { Messenger } from '@estruyf/vscode/dist/client'; import { DashboardMessage } from '../../DashboardMessage'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../../../localization'; export interface IStructureViewProps { pages: Page[]; } interface FolderNode { name: string; path: string; children: FolderNode[]; pages: Page[]; } export const StructureView: React.FunctionComponent = ({ pages }: React.PropsWithChildren) => { const [selectedFolder, setSelectedFolder] = useRecoilState(SelectedStructureFolderAtom); const settings = useRecoilValue(SettingsSelector); const folderTree = useMemo(() => { const root: FolderNode = { name: '', path: '', children: [], pages: [] }; const folderMap = new Map(); folderMap.set('', root); // Helper to compute the normalized workspace-relative folder path for a page. // This returns the actual folder path relative to the workspace, not just titles. const computeNormalizedFolderPath = (page: Page): string => { if (!page.fmFolder) { return ''; } const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); // Use fmRelFilePath which is already workspace-relative if (page.fmRelFilePath) { const relPath = parseWinPath(page.fmRelFilePath).replace(/^\/+|\/+$/g, ''); const relDir = relPath.includes('/') ? relPath.substring(0, relPath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : ''; if (relDir) { return relDir; } } // Fallback: use fmFolder title if we can't determine the path return fmFolder; }; // First pass: create all folder nodes (ensure nodes exist even if a page lacks fmFilePath) for (const page of pages) { if (!page.fmFolder) { continue; } const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); if (!normalizedPath) { continue; } const parts = normalizedPath.split('/').filter(part => part.length > 0); let currentPath = ''; let currentNode = root; for (const part of parts) { const fullPath = currentPath ? `${currentPath}/${part}` : part; if (!folderMap.has(fullPath)) { const newNode: FolderNode = { name: part, path: fullPath, children: [], pages: [] }; folderMap.set(fullPath, newNode); currentNode.children.push(newNode); } const nextNode = folderMap.get(fullPath); if (nextNode) { currentNode = nextNode; } currentPath = fullPath; } } // Second pass: assign pages to their exact folder node (including subfolders) for (const page of pages) { if (!page.fmFolder) { root.pages.push(page); continue; } const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); const folderNode = normalizedPath ? folderMap.get(normalizedPath) : folderMap.get(page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')); if (folderNode) { folderNode.pages.push(page); } else { // If folder not found, add to root as fallback root.pages.push(page); } } return root; }, [pages]); // Filter the folder tree based on the selected folder const displayedNode = useMemo(() => { if (!selectedFolder) { return folderTree; } // Find the selected folder node in the tree const findNode = (node: FolderNode, path: string): FolderNode | null => { if (node.path === path) { return node; } for (const child of node.children) { const found = findNode(child, path); if (found) { return found; } } return null; }; const foundNode = findNode(folderTree, selectedFolder); return foundNode || folderTree; }, [folderTree, selectedFolder]); const handleFolderClick = (folderPath: string) => { setSelectedFolder(folderPath); }; const handleBackClick = () => { if (!selectedFolder) { return; } // Navigate to parent folder const parts = selectedFolder.split('/'); if (parts.length > 1) { const parentPath = parts.slice(0, -1).join('/'); setSelectedFolder(parentPath); } else { setSelectedFolder(null); } }; const handleHomeClick = () => { setSelectedFolder(null); }; const handleCreateContent = () => { Messenger.send(DashboardMessage.createContentInFolder, { folderPath: selectedFolder }); }; const renderFolderNode = (node: FolderNode, depth = 0): React.ReactNode => { const hasContent = node.pages.length > 0 || node.children.length > 0; if (!hasContent) { return null; } const isRoot = depth === 0; const paddingLeft = depth * 20; if (isRoot) { // For root node, render children and pages directly return (
{/* Root level folders */} {node.children.map(child => renderFolderNode(child, depth + 1))} {/* Root level pages */} {node.pages.length > 0 && (

Root Files

    {node.pages.map((page, idx) => (
  • ))}
)}
); } return (
{({ open }) => ( <>
{node.name} {node.pages.length > 0 && ( ({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'}) )}
{/* Child folders */} {node.children.map(child => renderFolderNode(child, depth + 1))} {/* Pages in this folder */} {node.pages.length > 0 && (
    {node.pages.map((page, idx) => (
  • ))}
)}
)}
); }; return (
{/* Toolbar */}
{/* Breadcrumb navigation */} {selectedFolder && (
/ {selectedFolder.split('/').join(' / ')}
)} {/* Create content button */}
{selectedFolder && ( in {selectedFolder} )}
{/* Folder tree */} {renderFolderNode(displayedNode)}
); };