// src/layout/file-tree/__tests__/FileTree.test.tsx import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, cleanup, within, act, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { AutomergeUrl } from '@automerge/automerge-repo' import { FileTree } from '../FileTree' import { newBaseYaml } from '../../../bases/default-base' import { LayoutProvider, useLayout } from '../../LayoutContext' import type { App } from '../../../plugin/app' import type { Vault } from '../../../plugin/vault' import { createTFile, createTFolder, type FileStats } from '../../../plugin/files' import { createPromptlessNote } from '../../promptless-note-creation' vi.mock('../../promptless-note-creation', () => ({ createPromptlessNote: vi.fn() })) const stat: FileStats = { ctime: 0, mtime: 0, size: 0 } const root = createTFolder('', null) const testFile = createTFile('test-file.md', root, stat) const notesFolder = createTFolder('notes', root) const nestedFile = createTFile('note.md', notesFolder, stat) notesFolder.children.push(nestedFile) root.children.push(notesFolder, testFile) const FILE_URL = 'automerge:file1' as AutomergeUrl const ROOT_URL = 'automerge:root' as AutomergeUrl const NOTES_URL = 'automerge:notes' as AutomergeUrl const NOTE_URL = 'automerge:note' as AutomergeUrl const EXISTING_NOTE_URL = 'automerge:existing-note' as AutomergeUrl vi.mock('../../../services/SearchContext', () => ({ useSearch: () => ({ metadataCache: { subscribe: () => () => {}, getVersion: () => 0, getDisplayTitle: () => null, getIndexedDocuments: () => [], on: () => ({ e: {}, n: 'changed', fn: () => {} }), offref: () => {}, }, }), })) const mockFileTree = { rootChildren: root.children, isExpanded: vi.fn(() => false), toggleExpanded: vi.fn(), getFolderChildren: vi.fn((url: AutomergeUrl) => url === NOTES_URL ? notesFolder.children : []), } vi.mock('../useFileTree', () => ({ useFileTree: () => mockFileTree, })) function urlForPath(path: string): AutomergeUrl | undefined { if (path === 'test-file.md') return FILE_URL if (path === '') return ROOT_URL if (path === 'notes') return NOTES_URL if (path === 'notes/note.md') return NOTE_URL if (path === 'notes/existing.md') return EXISTING_NOTE_URL return undefined } const mockVault = { create: vi.fn(), createFolder: vi.fn(), createBinary: vi.fn(), adoptFolderByUrl: vi.fn().mockResolvedValue(undefined), getUrlForPath: vi.fn(urlForPath), getUrlForFile: vi.fn((file: { path: string }) => urlForPath(file.path)), getAbstractFileByUrl: vi.fn(), getPathForUrl: vi.fn((url: AutomergeUrl) => { if (url === ROOT_URL) return '' if (url === NOTES_URL) return 'notes' return undefined }), getFileByPath: vi.fn(), rename: vi.fn(), delete: vi.fn(), rootUrl: ROOT_URL, getRoot: () => root, getFileByUrl: vi.fn(), } as unknown as Vault function LayoutStateReader({ onStateRead }: { onStateRead: (mode: string) => void }) { const { state } = useLayout() onStateRead(state.sidebarMode) return null } // Spy passed as the FileTree `openDocument` prop. Tests assert against this // instead of the legacy LayoutContext.openTab injection. const openDocumentSpy = vi.fn<(docUrl: string, title: string) => void>() // Template plugin state for tests let mockTemplatePlugin: { getTemplates: ReturnType getDefaultTemplate: ReturnType getFilenamePattern: ReturnType expandFilenamePattern: ReturnType createFileFromTemplate: ReturnType } | null = null const pluginStateListeners = new Set<(pluginId: string, newState: string, oldState: string) => void>() // Minimal App stub: FileTree only reaches `app.vault`, // `app.workspace.{getActiveFile, on, offref}` (the last three via // useReamsActiveFile), and `app.plugins.{getPlugin, on, offref}` (via // useReactiveTemplatePlugin). No 'file-open' events are emitted in these tests, // so workspace `on` returns a no-op EventRef. const mockWorkspace = { getActiveFile: vi.fn(() => null), on: vi.fn(() => ({}) as unknown), offref: vi.fn(), } const mockPlugins = { getPlugin: vi.fn((id: string) => { if (id === 'reams-templates') return mockTemplatePlugin return null }), on: vi.fn((name: string, callback: (...args: any[]) => void) => { if (name === 'plugin-state-change') { pluginStateListeners.add(callback) } return { e: mockPlugins, n: name, fn: callback } }), offref: vi.fn((ref: { n: string; fn: (...args: any[]) => void }) => { if (ref.n === 'plugin-state-change') { pluginStateListeners.delete(ref.fn) } }), } const mockApp = { vault: undefined as unknown as Vault, // assigned in beforeEach (mockVault is defined below) workspace: mockWorkspace, plugins: mockPlugins, } as unknown as App // Helper to set the mock template plugin and trigger state change event function setMockTemplatePlugin(plugin: typeof mockTemplatePlugin) { mockTemplatePlugin = plugin pluginStateListeners.forEach(cb => cb('reams-templates', plugin ? 'active' : 'disabled', plugin ? 'disabled' : 'active')) } // Helper to get the tab titles that were opened via openDocumentSpy function getOpenedTabTitles(): string[] { return openDocumentSpy.mock.calls.map(([, title]) => title) } describe('FileTree', () => { beforeEach(() => { localStorage.clear() vi.clearAllMocks() vi.mocked(createPromptlessNote).mockReset().mockResolvedValue({ status: 'fallback' }) mockFileTree.rootChildren = root.children mockFileTree.isExpanded.mockReturnValue(false) mockFileTree.getFolderChildren.mockImplementation((url: AutomergeUrl) => url === NOTES_URL ? notesFolder.children : []) ;(mockVault.create as ReturnType).mockResolvedValue({ path: 'notes/note.md', name: 'note.md' }) ;(mockVault.createFolder as ReturnType).mockResolvedValue(undefined) ;(mockVault.getFileByPath as ReturnType).mockReturnValue(null) ;(mockApp as unknown as { vault: Vault }).vault = mockVault // Reset template plugin state mockTemplatePlugin = null pluginStateListeners.clear() }) afterEach(() => { cleanup(); vi.restoreAllMocks() }) it('renders file items from rootChildren', () => { render() expect(screen.getByRole('treeitem', { name: /test-file\.md/i })).toBeDefined() }) it('shows plain folder-context creation behavior when template plugin is not active', async () => { const user = userEvent.setup() render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) expect(createPromptlessNote).toHaveBeenCalledWith(mockApp, NOTES_URL) const dialog = screen.getByRole('dialog') expect(dialog).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('') expect(screen.getByText('notes/')).toBeInTheDocument() expect(screen.queryByRole('combobox', { name: /template/i })).not.toBeInTheDocument() expect(within(dialog).getByRole('button', { name: /^create$/i })).toBeDisabled() }) it('opens the helper-returned file and expands the requested folder on promptless success', async () => { const user = userEvent.setup() const returnedFile = { name: 'authoritative.md', path: 'notes/note.md' } vi.mocked(createPromptlessNote).mockResolvedValue({ status: 'created', file: returnedFile as any }) render() await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) await waitFor(() => expect(openDocumentSpy).toHaveBeenCalledWith(NOTE_URL, 'authoritative.md')) expect(mockVault.getUrlForFile).toHaveBeenCalledWith(returnedFile) expect(mockFileTree.toggleExpanded).toHaveBeenCalledWith(NOTES_URL) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('reports folder creation failures without opening or expanding', async () => { const user = userEvent.setup() const error = new Error('creation failed') vi.mocked(createPromptlessNote).mockRejectedValue(error) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) render() await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) await waitFor(() => expect(errorSpy).toHaveBeenCalledWith(`FileTree: failed to create note in folder ${NOTES_URL}:`, error)) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(mockFileTree.toggleExpanded).not.toHaveBeenCalled() }) it('opens the shared create modal for folder new file and creates a note in that folder', async () => { const user = userEvent.setup() render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) const dialog = screen.getByRole('dialog') await user.type(screen.getByRole('textbox'), 'note') await user.click(within(dialog).getByRole('button', { name: /^create$/i })) expect(mockVault.create).toHaveBeenCalledWith('notes/note.md', '') expect(getOpenedTabTitles()).toContain('note.md') expect(mockFileTree.toggleExpanded).toHaveBeenCalledWith(NOTES_URL) }) it('creates an ordinary unscoped .base from the shared file dialog', async () => { const user = userEvent.setup() ;(mockVault.create as ReturnType).mockResolvedValueOnce({ path: 'notes/Tasks.base', name: 'Tasks.base' }) render() await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) await user.type(screen.getByRole('textbox'), 'Tasks.base') await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: /^create$/i })) expect(mockVault.create).toHaveBeenCalledWith('notes/Tasks.base', newBaseYaml()) expect(newBaseYaml()).not.toContain('node.records[') }) it('still shows template-driven affordances when template plugin is active', async () => { const user = userEvent.setup() mockTemplatePlugin = { getTemplates: vi.fn().mockReturnValue([{ name: 'daily', path: 'templates/daily.md' }]), getDefaultTemplate: vi.fn().mockReturnValue(''), getFilenamePattern: vi.fn().mockReturnValue('{{date}}'), expandFilenamePattern: vi.fn().mockReturnValue('2026-04-15'), createFileFromTemplate: vi.fn(), } render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) const dialog = screen.getByRole('dialog') expect(screen.getByRole('textbox')).toHaveValue('2026-04-15') expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument() expect(within(dialog).getByRole('button', { name: /^create$/i })).toBeEnabled() }) // NOTE: Two former tests at this position exercised the root-level // "+" button that FileTree used to render inside its own header strip. // That header is now rendered by SidebarViewContainer (the unified chrome // row), and the root-level CreateItemModal is owned by AppShell (opened // via the `reams-create-file:create-file` command, which the chrome-row // "+" button dispatches). The equivalent template-plugin-reactivity // behavior is exercised by the AppShell modal path and by the // folder-context tests below. it('falls back to blank folder-context creation when Templates is disabled after the modal opens', async () => { const user = userEvent.setup() const templatePlugin = { getTemplates: vi.fn().mockReturnValue([{ name: 'daily', path: 'templates/daily.md' }]), getDefaultTemplate: vi.fn().mockReturnValue('templates/daily.md'), getFilenamePattern: vi.fn().mockReturnValue('{{title}}'), expandFilenamePattern: vi.fn().mockReturnValue(''), createFileFromTemplate: vi.fn(), } mockTemplatePlugin = templatePlugin render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument() act(() => { setMockTemplatePlugin(null) }) await waitFor(() => { expect(screen.queryByRole('combobox', { name: /template/i })).not.toBeInTheDocument() }) await user.type(screen.getByRole('textbox'), 'fallback-note') await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: /^create$/i })) expect(mockVault.create).toHaveBeenCalledWith('notes/fallback-note.md', '') expect(templatePlugin.createFileFromTemplate).not.toHaveBeenCalled() }) it('does not show a New Folder action in the folder context menu', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /actions for notes/i })) expect(screen.queryByRole('menuitem', { name: /new folder/i })).not.toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /new file/i })).toBeInTheDocument() }) it('opens an existing file when folder create hits a duplicate path', async () => { const user = userEvent.setup() const existingFile = createTFile('existing.md', notesFolder, stat) ;(mockVault.create as ReturnType).mockRejectedValueOnce(new Error('File already exists')) ;(mockVault.getFileByPath as ReturnType).mockReturnValue(existingFile) render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) await user.type(screen.getByRole('textbox'), 'existing') await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: /^create$/i })) expect(mockVault.create).toHaveBeenCalledWith('notes/existing.md', '') expect(mockVault.getFileByPath).toHaveBeenCalledWith('notes/existing.md') expect(getOpenedTabTitles()).toContain('existing.md') }) it('keeps the shared modal open when folder-context creation fails', async () => { const user = userEvent.setup() ;(mockVault.create as ReturnType).mockRejectedValueOnce(new Error('Disk full')) render() await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) await user.type(screen.getByRole('textbox'), 'broken') await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: /^create$/i })) expect(await screen.findByText(/disk full/i)).toBeInTheDocument() expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('broken') }) describe('Adopt folder by URL', () => { // The former "header + modal" adopt test was removed when the root // CreateItemModal moved from FileTree to AppShell. The chrome-row "+" // button dispatches `reams-create-file:create-file`, which AppShell // handles; its adoption path (handleAdoptFolderFromRibbon on // vault.rootUrl) is covered by AppShell-level tests. it('ctx-menu + modal calls vault.adoptFolderByUrl with the right-clicked folder URL as anchor', async () => { const user = userEvent.setup() render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) const dialog = screen.getByRole('dialog') await user.type(within(dialog).getByRole('textbox'), 'automerge:abc12345') await user.click(within(dialog).getByRole('button', { name: /^create$/i })) expect(mockVault.adoptFolderByUrl).toHaveBeenCalledWith( NOTES_URL, [], 'automerge:abc12345', ) }) it('ctx-menu + modal with intermediate segments forwards segments under the folder anchor (deep path)', async () => { const user = userEvent.setup() render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /new file/i })) const dialog = screen.getByRole('dialog') await user.type(within(dialog).getByRole('textbox'), 'sub/deep/automerge:abc12345') await user.click(within(dialog).getByRole('button', { name: /^create$/i })) expect(mockVault.adoptFolderByUrl).toHaveBeenCalledWith( NOTES_URL, ['sub', 'deep'], 'automerge:abc12345', ) }) }) describe('delete', () => { // Regression: clicking Delete in the ctx-menu, then Delete in the // destructive confirm dialog used to silently no-op for non-empty // folders because Vault.delete defaults to force=false and refuses // to delete a folder with descendants. The user already confirmed // deletion via the destructive dialog, so we MUST call delete with // force=true. This regression specifically affected adopted folders // (they always have descendants). it('passes force=true to vault.delete so non-empty folders are actually deleted', async () => { const user = userEvent.setup() ;(mockVault.delete as ReturnType).mockResolvedValue(undefined) ;(mockVault.getAbstractFileByUrl as ReturnType).mockReturnValue(notesFolder) render( ) // Open the notes-folder context menu, click Delete, confirm. await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /delete/i })) // ConfirmDialog uses role="alertdialog" (not "dialog") because it // requires immediate user attention before further interaction. const confirmDialog = screen.getByRole('alertdialog') await user.click(within(confirmDialog).getByRole('button', { name: /^delete$/i })) await waitFor(() => { expect(mockVault.delete).toHaveBeenCalledWith(notesFolder, true) }) }) it('logs a console error and does not crash when vault.delete rejects', async () => { const user = userEvent.setup() const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) ;(mockVault.delete as ReturnType).mockRejectedValueOnce(new Error('Storage write failed')) ;(mockVault.getAbstractFileByUrl as ReturnType).mockReturnValue(notesFolder) render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /delete/i })) const confirmDialog = screen.getByRole('alertdialog') await user.click(within(confirmDialog).getByRole('button', { name: /^delete$/i })) await waitFor(() => { expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('failed to delete'), expect.any(Error), ) }) // Dialog still closes — error doesn't keep it open. The user can // retry via the ctx-menu if needed. expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) it('warns and does nothing when the URL is not indexed in the vault', async () => { const user = userEvent.setup() const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) ;(mockVault.getAbstractFileByUrl as ReturnType).mockReturnValue(null) render( ) await user.click(screen.getByRole('button', { name: /actions for notes/i })) await user.click(screen.getByRole('menuitem', { name: /delete/i })) const confirmDialog = screen.getByRole('alertdialog') await user.click(within(confirmDialog).getByRole('button', { name: /^delete$/i })) await waitFor(() => { expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('not found in vault index'), ) }) expect(mockVault.delete).not.toHaveBeenCalled() }) }) describe('mobile auto-close', () => { it('sets sidebar mode to hidden on mobile file click', async () => { const user = userEvent.setup() const orig = window.innerWidth Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) let mode = 'full' render( { mode = m }} /> ) await user.click(screen.getByRole('treeitem', { name: /test-file\.md/i })) expect(mode).toBe('hidden') Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: orig }) }) it('does not change sidebar mode on desktop file click', async () => { const user = userEvent.setup() const orig = window.innerWidth Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 }) let mode = 'full' render( { mode = m }} /> ) await user.click(screen.getByRole('treeitem', { name: /test-file\.md/i })) expect(mode).toBe('full') Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: orig }) }) }) })