Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316// src/layout/file-tree/__tests__/FileTree.sort.test.tsx//// Integration: mount <FileTree app view openDocument /> 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<string, string>(), 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<string, TFolder>() 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( <LayoutProvider> <FileTree app={harness.app} view={view} openDocument={vi.fn()} /> </LayoutProvider>, )
// 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( <LayoutProvider> <FileTree app={harness.app} view={view} openDocument={vi.fn()} /> </LayoutProvider>, )
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( <LayoutProvider> <FileTree app={harness.app} view={view} openDocument={vi.fn()} /> </LayoutProvider>, )
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( <LayoutProvider> <FileTree app={harness.app} view={view} openDocument={vi.fn()} /> </LayoutProvider>, )
// 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') })})