// 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')
})
})