import { useState, type ComponentProps } from 'react' import { act, fireEvent, render, screen, within } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { FolderTree } from './FolderTree' import { FOLDER_ROW_SINGLE_CLICK_DELAY_MS } from './folder-tree/useFolderRowInteractions' import { FOLDER_ROW_NESTING_INDENT, getFolderConnectorLeft } from './folder-tree/folderTreeLayout' import { CREATE_NOTE_IN_FOLDER_EVENT } from '../hooks/noteCreationRequests' import type { FolderNode, SidebarSelection } from '../types' const mockFolders: FolderNode[] = [ { name: 'projects', path: 'projects', children: [ { name: 'laputa', path: 'projects/laputa', children: [] }, { name: 'portfolio', path: 'projects/portfolio', children: [] }, ], }, { name: 'areas', path: 'areas', children: [] }, { name: 'journal', path: 'journal', children: [] }, ] const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' } const vaultRootPath = '/Users/luca/Laputa' function renderTree(props: Partial> = {}) { const onSelect = props.onSelect ?? vi.fn() render( , ) return { onSelect } } function clickFolderRow(path: string) { fireEvent.click(screen.getByTestId(`folder-row:${path}`)) } async function submitNewFolder(name: string) { fireEvent.click(screen.getByTestId('create-folder-btn')) const input = screen.getByTestId('new-folder-input') fireEvent.change(input, { target: { value: name } }) fireEvent.keyDown(input, { key: 'Enter' }) } describe('FolderTree', () => { it('renders nothing when folders is empty', () => { const { container } = render( , ) expect(container.firstChild).toBeNull() }) it('renders FOLDERS header and top-level folders', () => { render() expect(screen.getByText('FOLDERS')).toBeInTheDocument() expect(screen.getByText('projects')).toBeInTheDocument() expect(screen.getByText('areas')).toBeInTheDocument() expect(screen.getByText('journal')).toBeInTheDocument() }) it('renders the vault root as the top-level folder when a vault path is available', () => { render( , ) expect(screen.getByText('Laputa')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Laputa' })).toBeInTheDocument() expect(screen.getByText('projects')).toBeInTheDocument() expect(screen.getByText('areas')).toBeInTheDocument() expect(screen.getByText('journal')).toBeInTheDocument() }) it('renders the vault root even when the vault has no subfolders', () => { render( , ) expect(screen.getByText('Laputa')).toBeInTheDocument() }) it('renders one scoped root per mounted workspace and selects folders inside that root', () => { const onSelect = vi.fn() const folders: FolderNode[] = [ { name: 'Personal', path: '', rootPath: '/Users/luca/Personal', children: [{ name: 'projects', path: 'projects', rootPath: '/Users/luca/Personal', children: [] }], }, { name: 'Team', path: '', rootPath: '/Users/luca/Team', children: [{ name: 'projects', path: 'projects', rootPath: '/Users/luca/Team', children: [] }], }, ] render( , ) expect(screen.getByText('Personal')).toBeInTheDocument() expect(screen.getByText('Team')).toBeInTheDocument() expect(screen.getAllByTestId('folder-row:projects')).toHaveLength(2) fireEvent.click(screen.getAllByTestId('folder-row:projects')[1]) expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects', rootPath: '/Users/luca/Team', }) }) it('lets the vault root collapse and expand from the row', () => { vi.useFakeTimers() render( , ) fireEvent.click(screen.getByTestId('folder-row:')) act(() => { vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS) }) expect(screen.queryByText('projects')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('folder-row:')) act(() => { vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS) }) expect(screen.getByText('projects')).toBeInTheDocument() vi.useRealTimers() }) it('expands children when clicking a folder row', () => { vi.useFakeTimers() render() expect(screen.queryByText('laputa')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('folder-row:projects')) act(() => { vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS) }) expect(screen.getByText('laputa')).toBeInTheDocument() expect(screen.getByText('portfolio')).toBeInTheDocument() vi.useRealTimers() }) it('calls onSelect with folder kind when clicking a folder row', () => { const onSelect = vi.fn() render() fireEvent.click(screen.getByTestId('folder-row:projects')) expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' }) }) it('selects the vault root with the root path attached', () => { const { onSelect } = renderTree({ vaultRootPath }) clickFolderRow('') expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: '', rootPath: vaultRootPath }) }) it('selects child folders with the vault root path attached when the tree has a root', () => { const { onSelect } = renderTree({ vaultRootPath }) clickFolderRow('areas') expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'areas', rootPath: vaultRootPath }) }) it('expands children when single-clicking a folder row with children', () => { vi.useFakeTimers() function FolderTreeHarness() { const [selection, setSelection] = useState(defaultSelection) return } render() fireEvent.click(screen.getByTestId('folder-row:projects')) act(() => { vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS) }) expect(screen.getByText('laputa')).toBeInTheDocument() expect(screen.getByText('portfolio')).toBeInTheDocument() fireEvent.click(screen.getByTestId('folder-row:projects')) act(() => { vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS) }) expect(screen.queryByText('laputa')).not.toBeInTheDocument() vi.useRealTimers() }) it('collapses section when clicking the FOLDERS header', () => { render() expect(screen.getByText('projects')).toBeInTheDocument() fireEvent.click(screen.getByText('FOLDERS')) expect(screen.queryByText('projects')).not.toBeInTheDocument() }) it('highlights the selected folder row', () => { const selection: SidebarSelection = { kind: 'folder', path: 'areas' } render() expect(screen.getByTestId('folder-row:areas').className).toContain('text-primary') }) it('opens the create-folder input from the header action', () => { render( , ) fireEvent.click(screen.getByTestId('create-folder-btn')) expect(screen.getByTestId('new-folder-input')).toBeInTheDocument() }) it('passes the selected folder as the parent when creating a new folder', async () => { const onCreateFolder = vi.fn().mockResolvedValue(true) renderTree({ selection: { kind: 'folder', path: 'projects', rootPath: vaultRootPath }, onCreateFolder, vaultRootPath, }) await submitNewFolder('laputa') await vi.waitFor(() => { expect(onCreateFolder).toHaveBeenCalledWith('laputa', { path: 'projects', rootPath: vaultRootPath, }) }) }) it('passes the target vault root when the vault root is selected', async () => { const onCreateFolder = vi.fn().mockResolvedValue(true) const otherVault = '/Users/luca/Team' const folders: FolderNode[] = [ { name: 'Personal', path: '', rootPath: vaultRootPath, children: [{ name: 'areas', path: 'areas', rootPath: vaultRootPath, children: [] }], }, { name: 'Team', path: '', rootPath: otherVault, children: [{ name: 'areas', path: 'areas', rootPath: otherVault, children: [] }], }, ] renderTree({ folders, selection: { kind: 'folder', path: '', rootPath: otherVault }, onCreateFolder, vaultRootPath, }) await submitNewFolder('inbox') await vi.waitFor(() => { expect(onCreateFolder).toHaveBeenCalledWith('inbox', { path: '', rootPath: otherVault, }) }) }) it('omits parent context when nothing folder-like is selected', async () => { const onCreateFolder = vi.fn().mockResolvedValue(true) renderTree({ onCreateFolder, vaultRootPath }) await submitNewFolder('inbox') await vi.waitFor(() => { expect(onCreateFolder).toHaveBeenCalledWith('inbox', undefined) }) }) it('expands the selected parent after a successful create so the new child is visible', async () => { const onCreateFolder = vi.fn().mockResolvedValue(true) render( , ) // Sanity: selecting a folder auto-expands its ancestors but not the folder // itself, so 'projects' starts collapsed. expect(screen.getByRole('button', { name: 'projects' })).toHaveAttribute('aria-expanded', 'false') fireEvent.click(screen.getByTestId('create-folder-btn')) const input = screen.getByTestId('new-folder-input') fireEvent.change(input, { target: { value: 'laputa' } }) fireEvent.keyDown(input, { key: 'Enter' }) await vi.waitFor(() => { expect(onCreateFolder).toHaveBeenCalled() }) await vi.waitFor(() => { expect(screen.getByRole('button', { name: 'projects' })).toHaveAttribute('aria-expanded', 'true') }) }) it('leaves expansion alone when create resolves falsy (validation rejected, etc.)', async () => { const onCreateFolder = vi.fn().mockResolvedValue(false) render( , ) fireEvent.click(screen.getByTestId('create-folder-btn')) const input = screen.getByTestId('new-folder-input') fireEvent.change(input, { target: { value: 'laputa' } }) fireEvent.keyDown(input, { key: 'Enter' }) await vi.waitFor(() => { expect(onCreateFolder).toHaveBeenCalled() }) expect(screen.getByRole('button', { name: 'projects' })).toHaveAttribute('aria-expanded', 'false') }) it('starts rename on folder double-click', () => { const onStartRenameFolder = vi.fn() render( , ) fireEvent.doubleClick(screen.getByTestId('folder-row:projects')) expect(onStartRenameFolder).toHaveBeenCalledWith('projects') }) it('keeps rename and delete out of row hover actions', () => { render( , ) expect(screen.queryByTestId('rename-folder-btn:projects')).not.toBeInTheDocument() expect(screen.queryByTestId('delete-folder-btn:projects')).not.toBeInTheDocument() }) it('does not render folder-level disclosure buttons', () => { render() const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement const parentRowContainer = screen.getByTestId('folder-row:projects').parentElement expect(leafRowContainer).not.toBeNull() expect(parentRowContainer).not.toBeNull() expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1) expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1) expect(screen.queryByLabelText('Expand projects')).not.toBeInTheDocument() }) it('aligns nested folders with the parent folder name and centers connectors on parent icons', () => { render( , ) expect(screen.getByTestId('folder-row:').parentElement).toHaveStyle({ paddingLeft: '0px' }) expect(screen.getByTestId('folder-row:projects').parentElement).toHaveStyle({ paddingLeft: `${FOLDER_ROW_NESTING_INDENT}px` }) expect(screen.getByTestId('folder-connector:')).toHaveStyle({ left: `${getFolderConnectorLeft(0)}px` }) }) it('shows the rename input when a folder is being renamed', () => { render( , ) expect(screen.getByTestId('rename-folder-input')).toBeInTheDocument() }) it('keeps folder toggling healthy after cancelling rename', () => { vi.useFakeTimers() const onCancelRenameFolder = vi.fn() const { rerender } = render( , ) fireEvent.keyDown(screen.getByTestId('rename-folder-input'), { key: 'Escape' }) expect(onCancelRenameFolder).toHaveBeenCalledTimes(1) rerender( , ) const wasExpanded = screen.queryByText('laputa') !== null fireEvent.click(screen.getByTestId('folder-row:projects')) act(() => { vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS) }) expect(screen.queryByText('laputa') !== null).toBe(!wasExpanded) vi.useRealTimers() }) it('opens a context menu with a delete action on right-click', () => { const onDeleteFolder = vi.fn() render( , ) fireEvent.contextMenu(screen.getByText('projects')) expect(screen.getByTestId('folder-context-menu')).toBeInTheDocument() fireEvent.click(screen.getByTestId('delete-folder-menu-item')) expect(onDeleteFolder).toHaveBeenCalledWith('projects') }) it('dismisses the folder context menu on Escape', () => { render( , ) fireEvent.contextMenu(screen.getByText('projects')) fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByTestId('folder-context-menu')).not.toBeInTheDocument() }) it('opens folder file actions from the context menu', () => { const onRevealFolder = vi.fn() const onCopyFolderPath = vi.fn() render( , ) fireEvent.contextMenu(screen.getByText('projects')) fireEvent.click(screen.getByTestId('reveal-folder-menu-item')) expect(onRevealFolder).toHaveBeenCalledWith('projects') fireEvent.contextMenu(screen.getByText('projects')) fireEvent.click(screen.getByTestId('copy-folder-path-menu-item')) expect(onCopyFolderPath).toHaveBeenCalledWith('projects') }) it('creates a note in the right-clicked mounted folder', () => { const onCreateNoteInFolder = vi.fn() const folders: FolderNode[] = [ { name: 'Personal', path: '', rootPath: '/Users/luca/Personal', children: [{ name: 'projects', path: 'projects', rootPath: '/Users/luca/Personal', children: [] }], }, { name: 'Team', path: '', rootPath: '/Users/luca/Team', children: [{ name: 'projects', path: 'projects', rootPath: '/Users/luca/Team', children: [] }], }, ] render( , ) window.addEventListener(CREATE_NOTE_IN_FOLDER_EVENT, onCreateNoteInFolder) fireEvent.contextMenu(screen.getAllByTestId('folder-row:projects')[1]) fireEvent.click(screen.getByTestId('create-node-in-folder-menu-item')) expect(onCreateNoteInFolder).toHaveBeenCalledOnce() expect((onCreateNoteInFolder.mock.calls[0][0] as CustomEvent).detail).toEqual({ folderPath: 'projects', rootPath: '/Users/luca/Team', }) window.removeEventListener(CREATE_NOTE_IN_FOLDER_EVENT, onCreateNoteInFolder) }) it('keeps destructive folder actions off the vault root row and menu', () => { render( , ) expect(screen.queryByTestId('rename-folder-btn:')).not.toBeInTheDocument() expect(screen.queryByTestId('delete-folder-btn:')).not.toBeInTheDocument() fireEvent.contextMenu(screen.getByText('Laputa')) expect(screen.getByTestId('reveal-folder-menu-item')).toBeInTheDocument() expect(screen.getByTestId('copy-folder-path-menu-item')).toBeInTheDocument() expect(screen.queryByText('Rename folder...')).not.toBeInTheDocument() expect(screen.queryByTestId('delete-folder-menu-item')).not.toBeInTheDocument() }) })