// src/layout/file-tree/__tests__/FileTree.sort.test.tsx // // Integration: mount with a REAL // FileTreeView and assert the rendered DOM row order updates when // view.setSortMode is called. This is the proof that the // FileTreeView ⟷ view-sort-store ⟷ useSortModeForView ⟷ FileTree.tsx // re-order pipeline works end-to-end (Test 12 from the test plan). import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, cleanup, act } from '@testing-library/react' import type { AutomergeUrl } from '@automerge/automerge-repo' import { FileTree } from '../FileTree' import { LayoutProvider } from '../../LayoutContext' import { FileTreeView } from '../../../plugins/file-tree/file-tree-view' import { App } from '../../../plugin/app' import { Vault } from '../../../plugin/vault' import { FileIndex } from '../../../services/file-index' import { Workspace } from '../../../plugin/workspace' import { MetadataCache } from '../../../services/metadata' import { createTFile, createTFolder, type TFolder } from '../../../plugin/files' const metadataState = vi.hoisted(() => ({ displayTitles: new Map(), displayTitlesEnabled: false, indexedDocuments: [] as string[], listeners: new Set<() => void>(), version: 0, })) // FileTree pulls metadata from SearchContext. Keep the stub controllable so // sort tests can project frontmatter titles independently from filenames. vi.mock('../../../services/SearchContext', () => ({ useSearch: () => ({ metadataCache: { subscribe: (listener: () => void) => { metadataState.listeners.add(listener) return () => metadataState.listeners.delete(listener) }, getVersion: () => metadataState.version, getDisplayTitle: (file: { name: string; basename: string }) => metadataState.displayTitlesEnabled ? (metadataState.displayTitles.get(file.name) ?? file.basename) : file.basename, getIndexedDocuments: () => metadataState.indexedDocuments, on: () => ({ e: {}, n: 'changed', fn: () => {} }), offref: () => {}, }, }), })) interface FileSpec { path: string ctime: number mtime: number parent?: TFolder } interface FolderSpec { name: string } interface Harness { app: App vault: Vault fileIndex: FileIndex } function makeHarness(folders: FolderSpec[], files: FileSpec[]): Harness { const mockDocumentManager = { createFile: vi.fn(), createFolder: vi.fn(), readText: vi.fn(async (url: AutomergeUrl) => ({ status: 'available' as const, documentId: url, heads: ['h0'], content: '' })), updateContent: vi.fn(), listFolder: vi.fn().mockResolvedValue([]), createFileInFolder: vi.fn(), getFile: vi.fn().mockResolvedValue(null), } as any const fileIndex = new FileIndex(mockDocumentManager, 'automerge:root' as AutomergeUrl) const vault = new Vault('Test', fileIndex, mockDocumentManager) const workspace = new Workspace(document.createElement('div')) const metadataCache = new MetadataCache() metadataCache.setVault(vault) const app = new App(vault, workspace) app.setMetadataCache(metadataCache) app.setDocumentManager(mockDocumentManager) const root = vault.getRoot() // Register folders first so their URLs exist when children are added. const folderByName = new Map() for (const f of folders) { const folder = createTFolder(f.name, root) ;(folder as any).vault = vault root.children.push(folder) const url = `automerge:folder-${f.name}` as AutomergeUrl fileIndex.registerFolder(url, 'automerge:root' as AutomergeUrl, folder) folderByName.set(f.name, folder) } for (const spec of files) { const parent = spec.parent ?? root const url = `automerge:${spec.path}` as AutomergeUrl const file = createTFile(spec.path, parent, { ctime: spec.ctime, mtime: spec.mtime, size: 0, }) file.vault = vault parent.children.push(file) const parentUrl = parent === root ? ('automerge:root' as AutomergeUrl) : (`automerge:folder-${parent.name}` as AutomergeUrl) fileIndex.registerFile(url, parentUrl, file) } return { app, vault, fileIndex } } function makeLeaf(): any { return { id: 'leaf-ft', app: { workspace: { getPanelSide: () => 'left' as const, _requestHeaderActionsUpdate: vi.fn(), }, commands: { executeCommandById: vi.fn() }, }, } } function getVisibleRowNames(): string[] { // FileTreeItem renders treeitem role with the file/folder name in the // accessible name. We grab them in DOM order. const items = screen.getAllByRole('treeitem') return items.map((el) => { // Strip optional dot-prefix / counts; the FileTreeItem label is the // basename text node inside. const text = el.textContent ?? '' return text.trim() }) } describe('FileTree sort-mode-driven re-order', () => { let harness: Harness | null = null beforeEach(() => { localStorage.clear() metadataState.displayTitles.clear() metadataState.displayTitlesEnabled = false metadataState.indexedDocuments = [] metadataState.listeners.clear() metadataState.version = 0 harness = null }) afterEach(() => { cleanup() harness?.app.destroy() harness = null document.querySelectorAll('.menu').forEach((m) => m.remove()) }) it('reorders root rows when view.setSortMode flips to modified-desc', async () => { harness = makeHarness( [], [ { path: 'apple.md', ctime: 100, mtime: 200 }, { path: 'banana.md', ctime: 50, mtime: 300 }, { path: 'cherry.md', ctime: 200, mtime: 100 }, ], ) const leaf = makeLeaf() const view = new FileTreeView(leaf, harness.app) render( , ) // Default mode is name-asc. The three files render alphabetically. let names = getVisibleRowNames() expect(names[0]).toContain('apple.md') expect(names[1]).toContain('banana.md') expect(names[2]).toContain('cherry.md') // Flip to modified-desc: banana (300) → apple (200) → cherry (100). act(() => view.setSortMode('modified-desc')) names = getVisibleRowNames() expect(names[0]).toContain('banana.md') expect(names[1]).toContain('apple.md') expect(names[2]).toContain('cherry.md') // Flip to created-desc: cherry (200) → apple (100) → banana (50). act(() => view.setSortMode('created-desc')) names = getVisibleRowNames() expect(names[0]).toContain('cherry.md') expect(names[1]).toContain('apple.md') expect(names[2]).toContain('banana.md') // Flip to name-desc. act(() => view.setSortMode('name-desc')) names = getVisibleRowNames() expect(names[0]).toContain('cherry.md') expect(names[1]).toContain('banana.md') expect(names[2]).toContain('apple.md') }) it('sorts name modes by projected frontmatter title instead of filename', () => { harness = makeHarness( [], [ { path: 'alpha.md', ctime: 100, mtime: 100 }, { path: 'zulu.md', ctime: 100, mtime: 100 }, ], ) metadataState.indexedDocuments = ['automerge:alpha.md', 'automerge:zulu.md'] metadataState.displayTitlesEnabled = true metadataState.displayTitles.set('alpha.md', 'Zulu Project') metadataState.displayTitles.set('zulu.md', 'Alpha Project') const view = new FileTreeView(makeLeaf(), harness.app) render( , ) expect(getVisibleRowNames()).toEqual(['Alpha Project', 'Zulu Project']) act(() => view.setSortMode('name-desc')) expect(getVisibleRowNames()).toEqual(['Zulu Project', 'Alpha Project']) act(() => { metadataState.displayTitlesEnabled = false metadataState.version += 1 for (const listener of metadataState.listeners) listener() }) expect(getVisibleRowNames()).toEqual(['zulu.md', 'alpha.md']) }) it('sorts and renders physical filenames when projected titles are disabled', () => { harness = makeHarness( [], [ { path: 'alpha.md', ctime: 100, mtime: 100 }, { path: 'zulu.md', ctime: 100, mtime: 100 }, ], ) metadataState.indexedDocuments = ['automerge:alpha.md', 'automerge:zulu.md'] metadataState.displayTitles.set('alpha.md', 'Zulu Project') metadataState.displayTitles.set('zulu.md', 'Alpha Project') const view = new FileTreeView(makeLeaf(), harness.app) render( , ) expect(getVisibleRowNames()).toEqual(['alpha.md', 'zulu.md']) }) it('keeps folders before files (folders-first invariant) across every mode flip', async () => { harness = makeHarness( [{ name: 'Alpha' }, { name: 'Zeta' }], [ { path: 'apple.md', ctime: 100, mtime: 200 }, { path: 'banana.md', ctime: 50, mtime: 300 }, ], ) const leaf = makeLeaf() const view = new FileTreeView(leaf, harness.app) render( , ) // Default mode: folders first (Alpha, Zeta), then files (apple, banana). let names = getVisibleRowNames() expect(names[0]).toContain('Alpha') expect(names[1]).toContain('Zeta') expect(names[2]).toContain('apple.md') expect(names[3]).toContain('banana.md') // Even under modified-desc, folders must remain alphabetically first. act(() => view.setSortMode('modified-desc')) names = getVisibleRowNames() expect(names[0]).toContain('Alpha') expect(names[1]).toContain('Zeta') // banana (300) ahead of apple (200). expect(names[2]).toContain('banana.md') expect(names[3]).toContain('apple.md') // And under modified-asc (the inverse). act(() => view.setSortMode('modified-asc')) names = getVisibleRowNames() expect(names[0]).toContain('Alpha') expect(names[1]).toContain('Zeta') // apple (200) ahead of banana (300) in ascending. expect(names[2]).toContain('apple.md') expect(names[3]).toContain('banana.md') }) })