Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995import * as A from '@automerge/automerge'import { Repo } from '@automerge/automerge-repo'import { EditorState, StateEffect, Transaction } from '@codemirror/state'import { defaultKeymap, history, isolateHistory, redo, undo } from '@codemirror/commands'import { EditorView, keymap, runScopeHandlers } from '@codemirror/view'import { forceParsing } from '@codemirror/language'import { describe, expect, it, vi } from 'vitest'import { DocumentManager } from '../../services/document-manager'import { FileIndex } from '../../services/file-index'import * as fileStatModule from '../../services/file-stat-from-history'import { Vault } from '../../plugin/vault'import { TFile } from '../../plugin/files'import { TextFirstSyncController } from '../text-first-sync-controller'import { createEditorBindingOwnership } from '../../editor/editor-binding-ownership'import { lineAttachmentAstExtents, lineAttachmentBindingToken, lineAttachmentExtension, lineAttachmentOwners, markerDeltaEffect, prepareLineAttachmentDelta, unsupportedAttachmentEffect } from '../../editor/line-attachment-extension'import { lineAttachmentMoveLineDown, lineAttachmentMoveLineUp } from '../../editor/line-attachment-move'import { decodeBlockFactKey, encodeBlockFactKey, type BlockScalar } from '../../types-records/block-record-facts'import { buildRecordNodeIndex, collectMarkdownStructures, inspectRichText, visibleTextFromDoc } from '../../automerge-markdown'import { attachmentClipboardHandler, ATTACHMENT_CLIPBOARD_MIME } from '../../editor/attachment-clipboard'import { textFirstHistoryCompartment } from '../../editor/CM6Editor'import { editingKeybindings, handleEnterKey } from '../../editor/extensions/editing-keybindings'import { markdown, markdownLanguage } from '@codemirror/lang-markdown'import { GFM } from '@lezer/markdown'import { checkboxWidget } from '../../editor/extensions/checkbox-widget'import { CommittedObserverError } from '../../spikes/text-first-writer'import { setRecordField } from '../../types-records/record-write-command'import { RootFrontmatterRecords } from '../../types-records/root-frontmatter-records'
vi.mock('@automerge/automerge', async importOriginal => { const actual = await importOriginal<typeof import('@automerge/automerge')>() return { ...actual, spans: vi.fn(actual.spans) }})
type Harness = { repo: Repo manager: DocumentManager url: ReturnType<DocumentManager['createFile']> controller: TextFirstSyncController view: EditorView actualFileIndex?: FileIndex file?: TFile}
async function changeHashes(manager: DocumentManager, url: Harness['url']): Promise<Set<string>> { const handle = await manager.findFileHandle(url) if (!handle) throw new Error(`Missing handle for ${url}`) return new Set(A.getAllChanges(handle.doc()).map(change => A.decodeChange(change).hash))}
async function createHarness(content: string, recognizedTypeNames: readonly string[] = [], withEditingKeybindings = false, withFileIndex = false, withMarkdownLanguage = false, withDefaultKeymap = false): Promise<Harness> { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('note.md', undefined, content) let actualFileIndex: FileIndex | undefined let file: TFile | undefined if (withFileIndex) { const rootUrl = manager.createFolder() await manager.addFileToFolder(rootUrl, url, 'note.md') actualFileIndex = new FileIndex(manager, rootUrl, { findFileHandle: fileUrl => manager.findFileHandle(fileUrl), }) const vault = new Vault('test-sync', actualFileIndex, manager) actualFileIndex.setVault(vault) await actualFileIndex.init() await vi.waitFor(() => expect(vault.getFileByPath('note.md')).toBeInstanceOf(TFile)) file = vault.getFileByPath('note.md') as TFile const fileHandle = await manager.findFileHandle(url) if (!fileHandle) throw new Error('missing file handle') await vi.waitFor(() => expect(file!.stat.size).toBe(visibleTextFromDoc(fileHandle.doc()).length)) } const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready'))
const view = new EditorView({ state: EditorState.create({ doc: content, extensions: [textFirstHistoryCompartment.of(history()), ...(withEditingKeybindings ? [editingKeybindings(), ...(withDefaultKeymap ? [keymap.of(defaultKeymap)] : [])] : []), ...(withMarkdownLanguage ? [markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false })] : []), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))], }), }) controller.bindView(view, createEditorBindingOwnership()) return { repo, manager, url, controller, view, actualFileIndex, file }}
async function cleanup(harness: Harness): Promise<void> { harness.view.destroy() await harness.controller.dispose() harness.manager.dispose() await harness.repo.shutdown()}
async function tick(): Promise<void> { await Promise.resolve() await new Promise<void>(resolve => setTimeout(resolve, 0))}
type RealChangeCapture = { patches: readonly { action: string; path: readonly (string | number)[]; value?: unknown; values?: readonly unknown[]; length?: number }[] before: { raw: string; rawLength: number; visibleLength: number } after: { raw: string; rawLength: number; visibleLength: number } beforeHeads: readonly string[] afterHeads: readonly string[] currentHeads: readonly string[]}
function captureRealChange(event: { doc: A.Doc<any> patches: readonly { action: string; path: readonly (string | number)[]; value?: unknown; values?: readonly unknown[]; length?: number }[] patchInfo?: { before: A.Doc<any>; after: A.Doc<any> }}): RealChangeCapture { if (!event.patchInfo) throw new Error('real Automerge change did not include patchInfo') const jsonValue = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(jsonValue) if (value !== null && typeof value === 'object') return Object.fromEntries(Object.keys(value).map(key => [key, jsonValue((value as Record<string, unknown>)[key])])) return value } const patches = event.patches.map(patch => ({ action: patch.action, path: [...patch.path], ...(patch.length === undefined ? {} : { length: patch.length }), ...(patch.value === undefined ? {} : { value: jsonValue(patch.value) }), ...(patch.values === undefined ? {} : { values: patch.values.map(jsonValue) }), })) const measure = (doc: A.Doc<any>) => { const raw = typeof doc.content === 'string' ? doc.content : '' return { raw, rawLength: raw.length, visibleLength: visibleTextFromDoc(doc).length } } return { patches, before: measure(event.patchInfo.before), after: measure(event.patchInfo.after), beforeHeads: A.getHeads(event.patchInfo.before), afterHeads: A.getHeads(event.patchInfo.after), currentHeads: A.getHeads(event.doc), }}
async function writeFactThroughController(h: Harness, key: string, value: BlockScalar, nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId): Promise<void> { const release = await h.controller.prepareExternalWrite() expect(release).toBeTypeOf('function') const result = await h.manager.writeBlockRecordFactCurrent(h.url, key, value, { nodeId, typeName: 'task' }, () => true) expect(result.factChange?.origin).toBeDefined() await h.controller.applyExternalContribution({ documentId: h.url, beforeHeads: result.beforeHeads, heads: result.heads, beforeContent: result.beforeContent, afterContent: result.afterContent, metadataChanges: [result.factChange!], }) release?.()}
type ParityEntryMode = 'cm' | 'vault'
async function applyParityEntry(h: Harness, mode: ParityEntryMode, content: string, cmChange?: { from: number; to: number; insert: string }): Promise<void> { if (mode === 'cm') { h.view.dispatch({ changes: cmChange ?? { from: 0, to: h.view.state.doc.length, insert: content } }) await h.controller.flushPendingPlain() return } if (!h.file) throw new Error('expected a FileIndex-backed file') await h.file.vault.modify(h.file, content) await tick()}
async function applyRemoteFact(h: Harness, key: string, value: string, actor: string): Promise<void> { const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing document handle') const base = A.clone(handle.doc()) const remote = A.change(base, { actor } as never, draft => { (draft as Record<string, unknown>)[key] = value }) handle.update(current => A.applyChanges(current, A.getChanges(base, remote))[0] as typeof current) await tick()}
async function applyRemoteNestedFact(h: Harness, key: string, actor: string): Promise<void> { const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing document handle') const base = A.clone(handle.doc()) const remote = A.change(base, { actor } as never, draft => { const fact = (draft as Record<string, unknown>)[key] as { nested: { state: string } } fact.nested.state = 'remote' }) handle.update(current => A.applyChanges(current, A.getChanges(base, remote))[0] as typeof current) await tick()}
async function applyConcurrentRemoteFact(h: Harness, baseHeads: readonly string[], key: string, value: string, actor: string): Promise<void> { const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing document handle') const base = A.clone(A.view(handle.doc(), [...baseHeads])) const remote = A.change(base, { actor } as never, draft => { (draft as Record<string, unknown>)[key] = value }) handle.update(current => A.applyChanges(current, A.getChanges(base, remote))[0] as typeof current) await tick()}
describe('TextFirstSyncController', () => { it('retires a quoted Task protected by a multiline code span and restores its exact facts on undo', async () => { const source = '> quoted #task\n\nplain paragraph' const h = await createHarness(source, ['task']) try { const original = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(original, 'task', 'priority') await writeFactThroughController(h, key, 4) const separator = source.indexOf('\n\n') h.view.dispatch({ changes: { from: separator, to: separator + 1, insert: '' }, annotations: isolateHistory.of('full') }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('> quoted #task\nplain paragraph') expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 4) h.view.dispatch({ changes: [{ from: 2, insert: '`' }, { from: h.view.state.doc.length, insert: '`' }], annotations: isolateHistory.of('full') }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('> `quoted #task\nplain paragraph`') expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toEqual([]) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([original]) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 4) expect(redo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toEqual([]) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) } finally { await cleanup(h) } })
it.each<ParityEntryMode>(['cm', 'vault'])('keeps duplicate Type evidence on one Record, then retires only that Record (%s)', async mode => { const source = 'same #task #task\n\nother #task' const h = await createHarness(source, ['task'], false, true) try { const initial = h.manager.getTextFirstDocumentState(h.url) const [same, other] = initial.lineOwners if (!same || !other) throw new Error('expected duplicate and unrelated owners') const sameKey = encodeBlockFactKey(same.nodeId, 'task', 'due') const otherKey = encodeBlockFactKey(other.nodeId, 'task', 'due') await writeFactThroughController(h, sameKey, 'Friday') await writeFactThroughController(h, otherKey, 'Monday', other.nodeId)
const secondTag = source.indexOf('#task', source.indexOf('#task') + 1) await applyParityEntry(h, mode, 'same #task\n\nother #task', { from: secondTag, to: secondTag + '#task'.length, insert: '' }) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([same.nodeId, other.nodeId]) expect(await h.manager.readBlockFacts(h.url)).toMatchObject({ [sameKey]: 'Friday', [otherKey]: 'Monday' })
const firstTag = 'same #task'.indexOf('#task') await applyParityEntry(h, mode, 'same\n\nother #task', { from: firstTag, to: firstTag + '#task'.length, insert: '' }) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([other.nodeId]) const facts = await h.manager.readBlockFacts(h.url) expect(Object.fromEntries(Object.entries(facts ?? {}).filter(([key]) => key.startsWith('reams.record.')))).toEqual({ [otherKey]: 'Monday' }) } finally { await cleanup(h) } })
it.each<ParityEntryMode>(['cm', 'vault'])('keeps the destination same-Type values through a join (%s)', async mode => { const source = 'left #task\n\nright #task' const h = await createHarness(source, ['task'], false, true) try { const initial = h.manager.getTextFirstDocumentState(h.url) const [destination, sourceOwner] = initial.lineOwners if (!destination || !sourceOwner) throw new Error('expected two task owners') const destinationKey = encodeBlockFactKey(destination.nodeId, 'task', 'due') const sourceKey = encodeBlockFactKey(sourceOwner.nodeId, 'task', 'due') await writeFactThroughController(h, destinationKey, 'Friday') await writeFactThroughController(h, sourceKey, 'Monday', sourceOwner.nodeId)
const joinAt = source.indexOf('right') - 1 await applyParityEntry(h, mode, 'left #task\nright #task', { from: joinAt, to: joinAt + 1, insert: '' }) const joined = h.manager.getTextFirstDocumentState(h.url) expect(joined.content).toBe('left #task\nright #task') expect(joined.lineOwners.map(owner => owner.nodeId)).toEqual([destination.nodeId]) const facts = await h.manager.readBlockFacts(h.url) expect(Object.fromEntries(Object.entries(facts ?? {}).filter(([key]) => key.startsWith('reams.record.')))).toEqual({ [destinationKey]: 'Friday' }) } finally { await cleanup(h) } })
it.each([ ['plain text', 'alpha', 5, 5, '!'], ['Record retirement', 'alpha #task', 6, 11, ''], ] as const)('rebuilds the real stopped writer after a committed observer failure for %s', async (_name, text, from, to, insert) => { const h = await createHarness(text, ['task']) const handle = (await h.manager.findFileHandle(h.url))! const originalRecover = h.manager.recoverTextFirstWriter.bind(h.manager) let observedStoppedWriter = false const recover = vi.spyOn(h.manager, 'recoverTextFirstWriter').mockImplementation(url => { observedStoppedWriter = h.manager.isTextFirstWriterStopped(url) originalRecover(url) }) const observer = () => { handle.off('change', observer) throw new Error('real synchronous notification failed') } try { const owner = h.manager.getTextFirstDocumentState(h.url).lineOwners[0] if (owner) handle.change(draft => { (draft as Record<string, unknown>)[encodeBlockFactKey(owner.nodeId, 'task', 'priority')] = 3 }) await tick() await h.controller.flushPendingPlain() const before = A.toJS(handle.doc()) const hashes = await changeHashes(h.manager, h.url) handle.on('change', observer) h.view.dispatch({ changes: { from, to, insert } }) const expected = text.slice(0, from) + insert + text.slice(to) expect(observedStoppedWriter).toBe(true) expect(recover).toHaveBeenCalledOnce() expect(h.manager.isTextFirstWriterStopped(h.url)).toBe(false) expect(h.view.state.doc.toString()).toBe(expected) expect(visibleTextFromDoc(handle.doc())).toBe(expected) expect((await changeHashes(h.manager, h.url)).size).toBe(hashes.size + 1) expect(h.controller.getAttachmentDiagnostic()).toContain('committed') expect(await h.controller.flushPendingPlain()).toBe(true) expect(undo(h.view)).toBe(true) expect(await h.controller.flushPendingPlain()).toBe(true) expect(A.toJS(handle.doc())).toEqual(before) expect(redo(h.view)).toBe(true) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.view.state.doc.toString()).toBe(expected) h.view.dispatch({ changes: { from: 0, insert: 'next ' } }) expect(await h.controller.flushPendingPlain()).toBe(true) expect(visibleTextFromDoc(handle.doc())).toBe(`next ${expected}`) expect(h.controller.getSnapshot().saveStatus.state).toBe('saved') } finally { handle.off('change', observer); recover.mockRestore(); await cleanup(h) } })
it('recovers a mismatched editor projection and immediately accepts further input', async () => { const h = await createHarness('canonical') try { const ownership = createEditorBindingOwnership() h.controller.bindView(h.view, ownership) ownership.runIgnoringTransactions(() => h.view.dispatch({ changes: { from: 0, to: h.view.state.doc.length, insert: 'stale projection' }, annotations: Transaction.addToHistory.of(false), })) h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: '!' } }) expect(h.view.state.doc.toString()).toBe('canonical') expect(h.controller.getAttachmentDiagnostic()).toContain('materialized checkpoint') expect(undo(h.view)).toBe(false) h.view.dispatch({ changes: { from: 9, insert: ' next' } }) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('canonical next') expect(undo(h.view)).toBe(true) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.view.state.doc.toString()).toBe('canonical') } finally { await cleanup(h) } })
it('continues accepting edits while storage flush fails, then saves the latest state without replay', async () => { const h = await createHarness('alpha #task', ['task']) const flush = vi.spyOn(h.manager, 'flushTextFirstDocument').mockRejectedValueOnce(new Error('storage unavailable')) try { h.view.dispatch({ changes: { from: 0, insert: 'first ' } }) expect(await h.controller.flushPendingPlain()).toBe(false) h.view.dispatch({ changes: { from: 0, insert: 'second ' } }) const hashes = await changeHashes(h.manager, h.url) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('second first alpha #task') expect(await changeHashes(h.manager, h.url)).toEqual(hashes) expect(h.controller.getSnapshot().saveStatus.state).toBe('saved') } finally { flush.mockRestore(); await cleanup(h) } })
it.each([ ['ordinary deletion', 'alpha bravo', 0, 6, ''], ['last Type deletion', 'alpha #task', 6, 11, ''], ['record split', 'alpha bravo #task', 6, 6, '\n\n'], ['whole record deletion', 'alpha #task\n\nbeta #task', 0, 13, ''], ] as const)('rolls back a rejected %s and accepts the next edit with working undo', async (_name, text, from, to, insert) => { const h = await createHarness(text, ['task']) const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction') try { const handle = (await h.manager.findFileHandle(h.url))! const originalOwners = h.manager.getTextFirstDocumentState(h.url).lineOwners handle.change(draft => { for (const owner of originalOwners) (draft as Record<string, unknown>)[encodeBlockFactKey(owner.nodeId, 'task', 'priority')] = 3 }) await tick() await h.controller.flushPendingPlain() const before = A.toJS(handle.doc()) const heads = A.getHeads(handle.doc()) apply.mockImplementationOnce(() => { throw new Error('injected precommit rejection') }) h.view.dispatch({ changes: { from, to, insert } }) expect(h.view.state.doc.toString()).toBe(text) expect(A.toJS(handle.doc())).toEqual(before) expect(A.getHeads(handle.doc())).toEqual(heads) expect(undo(h.view)).toBe(false) expect(lineAttachmentOwners(h.view.state)).toEqual(originalOwners) expect(h.controller.getAttachmentDiagnostic()).toContain('injected precommit rejection')
// Repeat the same legal action, not merely an unrelated text-only edit. const next = h.view.state.update({ changes: { from, to, insert } }) const planned = next.effects.find(effect => effect.is(markerDeltaEffect))?.value for (const owner of planned?.before ?? []) expect(owner).toMatchObject(originalOwners.find(candidate => candidate.nodeId === owner.nodeId)!) h.view.dispatch(next) expect(await h.controller.flushPendingPlain()).toBe(true) expect(visibleTextFromDoc(handle.doc())).toBe(text.slice(0, from) + insert + text.slice(to)) expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status, JSON.stringify({ delta: apply.mock.calls[apply.mock.calls.length - 1]?.[2].markerDelta, diagnostic: h.controller.getAttachmentDiagnostic(), spans: A.spans(handle.doc(), ['content']) })).toBe('available') expect(h.controller.getSnapshot().saveStatus.state).toBe('saved') expect(undo(h.view)).toBe(true) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.view.state.doc.toString(), h.controller.getAttachmentDiagnostic()).toBe(text) expect(A.toJS(handle.doc())).toEqual(before) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toEqual(originalOwners) } finally { apply.mockRestore(); await cleanup(h) } })
it('keeps plain editor output marker-free while materializing and undoing a tagged record', async () => { const harness = await createHarness('plain', ['task'], true) try { expect(lineAttachmentOwners(harness.view.state)).toEqual([]) harness.view.dispatch({ changes: { from: 5, to: 5, insert: ' #task' } }) await tick() expect(harness.view.state.doc.toString()).toBe('plain #task') expect(lineAttachmentOwners(harness.view.state)).toHaveLength(1) const materialized = harness.manager.getTextFirstDocumentState(harness.url) expect(materialized.lineOwners).toHaveLength(1) expect(A.spans((await harness.manager.findFileHandle(harness.url))!.doc(), ['content']).filter(span => span.type === 'block')).toHaveLength(1)
expect(undo(harness.view)).toBe(true) await tick() expect(harness.view.state.doc.toString()).toBe('plain') expect(lineAttachmentOwners(harness.view.state)).toEqual([]) expect(harness.manager.getTextFirstDocumentState(harness.url).lineOwners).toEqual([]) } finally { await cleanup(harness) } })
it.each([ ['with trailing paragraph', '\n\nalpha μ\nalpha μ'], ['without trailing paragraph', ''], ])('creates a fresh root fence and preserves ownership through the real Enter keymap: %s', async (_name, suffix) => { const text = `- parent #task\n- ~~~~${suffix}` const h = await createHarness(text, ['task'], true, false, true, true) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') const emptyList = before.lineOwners.find(owner => owner.lineStart === 0) expect(emptyList).toBeDefined() if (!emptyList) throw new Error('expected empty list owner') const emptyFact = encodeBlockFactKey(emptyList.nodeId, 'task', 'empty') const handle = await h.manager.findFileHandle(h.url) handle!.change(draft => { const record = draft as Record<string, unknown> record[emptyFact] = 'kept' }) await tick()
h.view.dispatch({ selection: { anchor: 17 } }) forceParsing(h.view, h.view.state.doc.length) expect(runScopeHandlers(h.view, new KeyboardEvent('keydown', { key: 'Enter' }), 'editor')).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(`- parent #task\n- \n~~~~${suffix}`)
const after = h.manager.getTextFirstDocumentState(h.url) if (after.nodeIndex.status === 'invalid') throw new Error(JSON.stringify({ reason: after.nodeIndex.reason, blocks: A.spans((await h.manager.findFileHandle(h.url))!.doc() as A.Doc<any>, ['content']) })) if (after.nodeIndex.status !== 'available') throw new Error('expected an indexed document after Enter') expect(after.lineOwners.find(owner => owner.lineStart === 0)?.nodeId).toBe(emptyList.nodeId) const ordinaryStructures = collectMarkdownStructures(visibleTextFromDoc((await h.manager.findFileHandle(h.url))!.doc())) expect(ordinaryStructures.some(structure => structure.kind === 'fenced-code')).toBe(true) expect(after.lineOwners.map(owner => owner.nodeId)).toEqual([emptyList.nodeId]) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(emptyFact, 'kept')
expect(undo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(text) expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status).toBe('available')
expect(redo(h.view)).toBe(true) await tick() const redone = h.manager.getTextFirstDocumentState(h.url) expect(redone.nodeIndex.status).toBe('available') if (redone.nodeIndex.status !== 'available') throw new Error('expected an indexed document after redo') expect(redone.lineOwners.map(owner => owner.nodeId)).toEqual([emptyList.nodeId]) } finally { await cleanup(h) } })
it.each([3, 13])('preserves every nested same-line owner through Enter at %s', async caret => { const text = '- + * child #task' const h = await createHarness(text, ['task'], true) try { const before = h.manager.getTextFirstDocumentState(h.url).nodeIndex if (before.status !== 'available') throw new Error('expected native index') expect(before.index.nodes).toHaveLength(1) const ids = before.index.nodes.map(node => node.id) const handle = (await h.manager.findFileHandle(h.url))! const keys = ids.map(id => encodeBlockFactKey(id, 'task', 'priority')) handle.change(draft => { keys.forEach((key, index) => { (draft as Record<string, unknown>)[key] = index + 1 }) }) await tick() h.view.dispatch({ selection: { anchor: caret } }) expect(handleEnterKey(h.view)).toBe(true) await h.controller.flushPendingPlain() const expected = caret === 3 ? '- \n- + * child #task' : '- + * child\n- #task' expect(h.view.state.doc.toString()).toBe(expected) const after = h.manager.getTextFirstDocumentState(h.url).nodeIndex expect(after.status).toBe('available') if (after.status !== 'available') throw new Error('expected native index after Enter') const nodes = after.index.nodes expect(nodes).toHaveLength(1) const fresh = nodes.filter(node => !ids.includes(node.id)) expect(fresh).toHaveLength(0) expect(await h.manager.readBlockFacts(h.url)).toMatchObject(Object.fromEntries(keys.map((key, index) => [key, index + 1]))) expect(undo(h.view)).toBe(true) await h.controller.flushPendingPlain() const undone = h.manager.getTextFirstDocumentState(h.url).nodeIndex expect(undone.status === 'available' && undone.index.nodes).toEqual(before.index.nodes) expect(redo(h.view)).toBe(true) await h.controller.flushPendingPlain() const redone = h.manager.getTextFirstDocumentState(h.url).nodeIndex expect(redone.status === 'available' && redone.index.nodes).toEqual(nodes) expect(await h.manager.readBlockFacts(h.url)).toMatchObject(Object.fromEntries(keys.map((key, index) => [key, index + 1]))) } finally { await cleanup(h) } })
it.each([ ['before the inner marker', 3, '- \n- + child #task'], ['after the nested title', 10, '- + child\n- #task'], ])('keeps same-line ownership valid through the production Enter keymap %s', async (_name, caret, expected) => { const text = '- + child #task' const h = await createHarness(text, ['task'], true) try { const initial = h.manager.getTextFirstDocumentState(h.url) expect(initial.nodeIndex.status).toBe('available') if (initial.nodeIndex.status !== 'available') throw new Error('expected an indexed document') const nested = initial.nodeIndex.index.nodes.find(node => node.markerPlainIndex === 3) expect(nested).toBeDefined() if (!nested) throw new Error('expected nested indexed node') const handle = await h.manager.findFileHandle(h.url) expect(handle).toBeDefined() const nestedFactKey = encodeBlockFactKey(nested.id, 'task', 'done') handle!.change(draft => { (draft as Record<string, unknown>)[nestedFactKey] = true }) await tick() await tick() const beforeFacts = Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.'))) expect(beforeFacts).toHaveProperty(nestedFactKey, true) h.view.dispatch({ selection: { anchor: caret } }) expect(handleEnterKey(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(expected) const entered = h.manager.getTextFirstDocumentState(h.url) expect(entered.nodeIndex, entered.nodeIndex.status === 'invalid' ? entered.nodeIndex.reason : undefined).toMatchObject({ status: 'available' }) if (entered.nodeIndex.status !== 'available') throw new Error('expected an indexed document after Enter') expect(entered.nodeIndex.index.getNode(nested.id)).toBeDefined() if (caret === 10) { const continuation = entered.nodeIndex.index.nodes.find(node => node.from === 11) expect(continuation).toBeDefined() expect(continuation?.parentId).toBeUndefined() expect(continuation?.id).toBe(nested.id) } expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(nestedFactKey, true) expect(undo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(text) const undone = h.manager.getTextFirstDocumentState(h.url) expect(undone.nodeIndex, undone.nodeIndex.status === 'invalid' ? undone.nodeIndex.reason : undefined).toMatchObject({ status: 'available' }) if (undone.nodeIndex.status !== 'available') throw new Error('expected an indexed document after undo') expect(undone.nodeIndex.index.getNode(nested.id)).toBeDefined() if (caret === 10) { } expect(Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))).toEqual(beforeFacts) expect(redo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(expected) const redone = h.manager.getTextFirstDocumentState(h.url) expect(redone.nodeIndex, redone.nodeIndex.status === 'invalid' ? redone.nodeIndex.reason : undefined).toMatchObject({ status: 'available' }) if (redone.nodeIndex.status !== 'available') throw new Error('expected an indexed document after redo') expect(redone.nodeIndex.index.getNode(nested.id)).toBeDefined() if (caret === 10) { expect(redone.nodeIndex.index.nodes.find(node => node.from === 11)?.parentId).toBeUndefined() } expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(nestedFactKey, true) } finally { await cleanup(h) } })
it('retires a same-line nested owner and its facts through deletion undo and redo', async () => { const h = await createHarness('- + child #task\n- parent #task', ['task'], true) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') const nested = before.nodeIndex.index.nodes.find(node => node.markerPlainIndex === 3) expect(nested).toBeDefined() const handle = await h.manager.findFileHandle(h.url) const factKey = encodeBlockFactKey(nested!.id, 'task', 'done') handle!.change(draft => { (draft as Record<string, unknown>)[factKey] = true }) await tick() const beforeFacts = Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.'))) const nestedFacts = Object.keys(beforeFacts).filter(key => key.includes(nested!.id)) expect(nestedFacts.length).toBeGreaterThan(0)
h.view.dispatch({ changes: { from: 3, to: 5, insert: '' }, annotations: Transaction.userEvent.of('input.delete'), }) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- child #task\n- parent #task') const deleted = h.manager.getTextFirstDocumentState(h.url) expect(deleted.nodeIndex, deleted.nodeIndex.status === 'invalid' ? `${deleted.nodeIndex.reason}: ${JSON.stringify((await h.manager.findFileHandle(h.url))!.doc().content)}` : undefined).toMatchObject({ status: 'available' }) if (deleted.nodeIndex.status === 'available') expect(deleted.nodeIndex.index.getNode(nested!.id)).toBeDefined() const deletedFacts = Object.keys(Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))) expect(deletedFacts.some(key => key.includes(nested!.id))).toBe(true)
expect(undo(h.view)).toBe(true) await tick() const undone = h.manager.getTextFirstDocumentState(h.url) expect(undone.content).toBe('- + child #task\n- parent #task') expect(undone.nodeIndex.status).toBe('available') if (undone.nodeIndex.status === 'available') expect(undone.nodeIndex.index.getNode(nested!.id)).toBeDefined() expect(Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))).toEqual(beforeFacts)
expect(redo(h.view)).toBe(true) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- child #task\n- parent #task') expect(Object.keys(Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))).some(key => key.includes(nested!.id))).toBe(true) } finally { await cleanup(h) } })
it('creates a distinct nested owner when typing a marker into an empty list item', async () => { const h = await createHarness('- \n- parent #task', ['task'], true) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') const beforeIds = before.nodeIndex.index.nodes.map(node => node.id) const beforeFacts = Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))
h.view.dispatch({ changes: { from: 2, to: 2, insert: ' + ' }, annotations: Transaction.userEvent.of('input.type'), }) await tick()
const after = h.manager.getTextFirstDocumentState(h.url) expect(after.content).toBe('- + \n- parent #task') expect(after.nodeIndex.status, after.nodeIndex.status === 'invalid' ? after.nodeIndex.reason : undefined).toBe('available') if (after.nodeIndex.status !== 'available') throw new Error('expected an indexed document after typing') expect(after.nodeIndex.index.nodes).toHaveLength(1) expect(after.nodeIndex.index.nodes.filter(node => beforeIds.includes(node.id)).map(node => node.id)).toEqual(beforeIds) expect(Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))).toEqual(beforeFacts)
expect(undo(h.view)).toBe(true) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- \n- parent #task') const undone = h.manager.getTextFirstDocumentState(h.url).nodeIndex expect(undone, undone.status === 'invalid' ? undone.reason : undefined).toMatchObject({ status: 'available' }) expect(redo(h.view)).toBe(true) await tick() const redone = h.manager.getTextFirstDocumentState(h.url) expect(redone.content).toBe('- + \n- parent #task') expect(redone.nodeIndex.status).toBe('available') if (redone.nodeIndex.status === 'available') { expect(redone.nodeIndex.index.nodes).toHaveLength(1) } expect(Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.')))).toEqual(beforeFacts) } finally { await cleanup(h) } })
it('keeps same-line nested list identities through the integrated editor path', async () => { const h = await createHarness('- + child #task\n', ['task'], true) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') expect(before.lineOwners.map(owner => owner.markerPlainIndex ?? owner.lineStart)).toEqual([3]) const handle = await h.manager.findFileHandle(h.url) expect(handle).toBeDefined() expect(inspectRichText(handle!.doc(), { path: ['content'] }).blocks().map(block => block.plainIndex)).toEqual([3])
h.view.dispatch({ changes: { from: h.view.state.doc.length - 1, insert: '!' } }) await tick()
const after = h.manager.getTextFirstDocumentState(h.url) expect(after.content).toBe('- + child #task!\n') expect(after.nodeIndex.status).toBe('available') expect(after.lineOwners.map(owner => owner.markerPlainIndex ?? owner.lineStart)).toEqual([3]) expect(lineAttachmentOwners(h.view.state).map(owner => owner.markerPlainIndex ?? owner.lineStart)).toEqual([3]) } finally { await cleanup(h) } })
it.each([ ['first sibling', '- one\n- two\n- three', 5], ['middle sibling', '- one\n- two\n- three', 11], ['nested leaf sibling', '- parent\n - child\n- tail', 18], ])('continues an item locally with independent geometry: %s', async (_name, text, caret) => { const h = await createHarness(text) try { const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction') h.view.dispatch({ selection: { anchor: caret } }) expect(handleEnterKey(h.view)).toBe(true) expect(apply).toHaveBeenCalledOnce() expect(apply.mock.results[0]!.value.localGeometry).toBeDefined() const splitText = h.view.state.doc.toString() expect(lineAttachmentOwners(h.view.state)).toEqual([]) expect(undo(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe(text) expect(redo(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe(splitText) } finally { await cleanup(h) } }) it.each([ ['second item body start', '- one\n- two', 8], ['middle body', '- first item\n- next', 7], ['nested leaf end', '- parent\n - child\n- tail', 18], ])('publishes independent geometry through the real local split: %s', async (_name, text, caret) => { const h = await createHarness(text) try { const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction') h.view.dispatch({ selection: { anchor: caret } }) expect(handleEnterKey(h.view)).toBe(true) expect(apply).toHaveBeenCalledOnce() expect(apply.mock.results[0]!.value.localGeometry).toBeDefined() const splitText = h.view.state.doc.toString() expect(lineAttachmentOwners(h.view.state)).toEqual([]) expect(undo(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe(text) expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status).toBe('available') expect(redo(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe(splitText) } finally { await cleanup(h) } }) it.each([{ nodeCount: 10, newline: '\n' }, { nodeCount: 100, newline: '\r\n' }])('preserves attachment markers without a full rescan for $nodeCount nodes ($newline)', async ({ nodeCount, newline }) => { const content = Array.from({ length: nodeCount }, (_, index) => `- item ${index} #task`).join(newline) const h = await createHarness(content, ['task']) const spans = vi.mocked(A.spans) const markerSnapshot = vi.spyOn(h.manager, 'readTextFirstAttachmentMarkers') const legacyPayload = vi.spyOn(h.manager, 'readTextFirstAttachmentPayload') spans.mockClear() markerSnapshot.mockClear() legacyPayload.mockClear() try { const initialExtents = lineAttachmentAstExtents(h.view.state) expect(initialExtents).toHaveLength(nodeCount) expect(initialExtents.every(extent => extent.marker !== undefined)).toBe(true) expect(initialExtents.every(extent => (extent.marker?.attrs as Record<string, unknown>)['reams.nodeId'] === extent.nodeId)).toBe(true)
const firstTagEnd = content.indexOf('#task') + '#task'.length h.view.dispatch({ changes: { from: firstTagEnd - 1, to: firstTagEnd, insert: '' } }) await tick()
const remainingExtents = lineAttachmentAstExtents(h.view.state) expect(remainingExtents, h.controller.getAttachmentDiagnostic()).toHaveLength(nodeCount - 1) expect(remainingExtents.map(extent => extent.nodeId)).toEqual(initialExtents.slice(1).map(extent => extent.nodeId)) expect(remainingExtents.every(extent => (extent.marker?.attrs as Record<string, unknown>)['reams.nodeId'] === extent.nodeId)).toBe(true) expect(markerSnapshot).toHaveBeenCalledTimes(1) expect(legacyPayload).not.toHaveBeenCalled() expect(spans.mock.calls.length).toBeLessThanOrEqual(13) } finally { markerSnapshot.mockRestore() legacyPayload.mockRestore() await cleanup(h) } })
it('retires inactive Type evidence and gives retyped content fresh defaults', async () => { const h = await createHarness('item #task', ['task']) try { const initial = h.manager.getTextFirstDocumentState(h.url) const nodeId = initial.lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', 'due') await writeFactThroughController(h, key, 'Friday') const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing handle') const original = A.block(handle.doc(), ['content'], 0)! handle.change(draft => A.updateBlock(draft, ['content'], 0, { ...original, attrs: { ...(original.attrs as Record<string, unknown>), level: 'opaque-level', lang: 'opaque-lang', start: 42 }, })) await tick()
h.view.dispatch({ changes: { from: 0, to: h.view.state.doc.length, insert: 'item' } }) await h.controller.flushPendingPlain() expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toEqual([]) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) expect(A.spans(handle.doc(), ['content']).filter(span => span.type === 'block')).toHaveLength(0)
expect(undo(h.view)).toBe(true) await tick() expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([nodeId]) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Friday')
expect(redo(h.view)).toBe(true) await h.controller.flushPendingPlain() expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toEqual([]) h.view.dispatch({ changes: { from: 0, to: h.view.state.doc.length, insert: 'item #task' } }) const retyped = h.manager.getTextFirstDocumentState(h.url) expect(retyped.lineOwners).toHaveLength(1) expect(retyped.lineOwners[0]!.nodeId).not.toBe(nodeId) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) expect(A.block(handle.doc(), ['content'], 0)?.attrs).not.toMatchObject({ level: 'opaque-level', lang: 'opaque-lang', start: 42 })
h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: '!' } }) h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: '?' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('item #task!?') } finally { await cleanup(h) } })
it.each(['delete-tag', 'close-code'] as const)('retires a last Type on a quoted continuation via %s with exact history', async operation => { const source = operation === 'delete-tag' ? '> quoted\nparagraph #task\n\nother #task' : '> `quoted\nparagraph #task\n\nother #task' const retiredSource = operation === 'delete-tag' ? '> quoted\nparagraph #tas\n\nother #task' : '> `quoted\nparagraph #task`\n\nother #task' const h = await createHarness(source, ['task']) try { const initial = h.manager.getTextFirstDocumentState(h.url) const [quoted, other] = initial.lineOwners if (!quoted || !other) throw new Error('expected quoted and unrelated owners') const quotedKey = encodeBlockFactKey(quoted.nodeId, 'task', 'priority') const otherKey = encodeBlockFactKey(other.nodeId, 'task', 'priority') await writeFactThroughController(h, quotedKey, 5, quoted.nodeId) await writeFactThroughController(h, otherKey, 2, other.nodeId)
const tagEnd = h.view.state.doc.toString().indexOf('#task') + '#task'.length h.view.dispatch({ changes: { from: operation === 'delete-tag' ? tagEnd - 1 : tagEnd, to: tagEnd, insert: operation === 'delete-tag' ? '' : '`' } })
expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([other.nodeId]) const activeFacts = async () => Object.fromEntries(Object.entries((await h.manager.readBlockFacts(h.url)) ?? {}).filter(([key]) => key.startsWith('reams.record.'))) expect(await activeFacts()).toEqual({ [otherKey]: 2 })
expect(undo(h.view)).toBe(true) await h.controller.flushPendingPlain() expect(h.view.state.doc.toString()).toBe(source) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([quoted.nodeId, other.nodeId]) expect(await activeFacts()).toEqual({ [quotedKey]: 5, [otherKey]: 2 })
expect(redo(h.view)).toBe(true) await h.controller.flushPendingPlain() expect(h.view.state.doc.toString()).toBe(retiredSource) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([other.nodeId]) expect(await activeFacts()).toEqual({ [otherKey]: 2 }) } finally { await cleanup(h) } })
it('owns an untyped final list item before typing and preserves it through undo', async () => { const h = await createHarness('- existing item', [], true) try { h.view.dispatch({ selection: { anchor: h.view.state.doc.length } }) expect(lineAttachmentOwners(h.view.state)).toEqual([]) expect(handleEnterKey(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('- existing item\n- ') expect(lineAttachmentOwners(h.view.state)).toEqual([]) expect(lineAttachmentAstExtents(h.view.state)).toEqual([])
h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: 'new item text' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- existing item\n- new item text') expect(lineAttachmentAstExtents(h.view.state)).toEqual([])
expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- existing item') expect(lineAttachmentOwners(h.view.state)).toEqual([]) expect(lineAttachmentAstExtents(h.view.state)).toEqual([]) } finally { await cleanup(h) } })
it('falls back from a non-bullet native marker on list Enter without rejecting the edit', async () => { const h = await createHarness('- item #task', ['task'], true) try { const handle = (await h.manager.findFileHandle(h.url))! const originalId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const original = A.block(handle.doc() as A.Doc<any>, ['content'], 0) expect(original).not.toBeNull() if (!original) throw new Error('expected the recognized task record marker') handle.change(draft => A.updateBlock(draft as A.Doc<any>, ['content'], 0, { ...original, parents: [], type: 'paragraph', attrs: { ...(original.attrs as Record<string, unknown>), 'reams.nodeId': originalId }, payload: { preserved: true }, })) await tick() h.view.dispatch({ selection: { anchor: h.view.state.doc.length } }) expect(handleEnterKey(h.view)).toBe(true) await tick()
expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- item #task\n- ') const state = h.manager.getTextFirstDocumentState(h.url) expect(state.lineOwners).toHaveLength(1) expect(state.lineOwners[0]!.nodeId).toBe(originalId) expect(A.block(handle.doc() as A.Doc<any>, ['content'], 0)).toMatchObject({ type: 'bullet-list', payload: { preserved: true } }) const independent = buildRecordNodeIndex(handle.doc() as any, { path: ['content'], recognizedTypeNames: ['task'] }) expect(independent.status).toBe('available') expect(state.nodeIndex.status).toBe('available') if (independent.status === 'available' && state.nodeIndex.status === 'available') { expect(state.nodeIndex.index.nodes).toEqual(independent.index.nodes) } } finally { await cleanup(h) } })
it('uses the cache-only native append for a final root item', async () => { const h = await createHarness('- existing item', [], true, true) const spans = vi.mocked(A.spans) const stateRead = vi.spyOn(h.manager, 'getTextFirstDocumentState') const applyTransaction = vi.spyOn(h.manager, 'applyTextFirstTransaction') const beforeDiagnostics = h.manager.getTextFirstWriterDiagnostics(h.url) spans.mockClear() stateRead.mockClear() applyTransaction.mockClear() try { expect(lineAttachmentOwners(h.view.state)).toEqual([]) const sourceTo = h.view.state.doc.length const rawBefore = (await h.manager.findFileHandle(h.url))!.doc().content as string const oldCursor = A.getCursor((await h.manager.findFileHandle(h.url))!.doc() as A.Doc<any>, ['content'], sourceTo - 1) h.view.dispatch({ selection: { anchor: h.view.state.doc.length } }) expect(handleEnterKey(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe('- existing item\n- ') const transaction = applyTransaction.mock.calls[0]![2] expect(transaction.changes).toEqual([{ from: sourceTo, to: sourceTo, insert: '\n- ' }]) expect(lineAttachmentOwners(h.view.state)).toEqual([]) expect(A.spans((await h.manager.findFileHandle(h.url))!.doc(), ['content']).filter(span => span.type === 'block')).toHaveLength(0) const handle = (await h.manager.findFileHandle(h.url))! expect(A.getCursor(handle.doc() as A.Doc<any>, ['content'], sourceTo - 1)).toBe(oldCursor) expect(handle.doc().content as string).toBe(`${rawBefore}\n- `) const independent = buildRecordNodeIndex(handle.doc() as any, { path: ['content'], recognizedTypeNames: [] }) const state = h.manager.getTextFirstDocumentState(h.url) expect(independent).toMatchObject({ status: 'available' }) expect(state.nodeIndex.status).toBe('available') if (independent.status === 'available' && state.nodeIndex.status === 'available') { expect(state.nodeIndex.index.nodes).toEqual(independent.index.nodes) } stateRead.mockClear() spans.mockClear() expect(h.manager.getTextFirstWriterDiagnostics(h.url).cacheRebuilds).toBe(beforeDiagnostics.cacheRebuilds) expect(stateRead).not.toHaveBeenCalled() await tick() expect(stateRead).not.toHaveBeenCalled() expect(spans).not.toHaveBeenCalled()
h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: 'next' } }) expect(h.view.state.doc.toString()).toBe('- existing item\n- next') expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- existing item\n- next') expect(undo(h.view)).toBe(true) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- existing item') expect(redo(h.view)).toBe(true) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- existing item\n- next') } finally { stateRead.mockRestore() applyTransaction.mockRestore() await cleanup(h) } })
it('routes a combined append and fact delta through the generic mutation path', async () => { const h = await createHarness('- existing item #task', ['task']) const applyTransaction = vi.spyOn(h.manager, 'applyTextFirstTransaction') try { const source = lineAttachmentOwners(h.view.state)[0]! const key = encodeBlockFactKey(source.nodeId, 'task', 'due') const destination = { nodeId: 'combined-destination', lineStart: h.view.state.doc.length + 1 } const token = lineAttachmentBindingToken(h.view.state) h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: '\n- #task' }, effects: [ markerDeltaEffect.of({ bindingToken: token, before: [source], after: [source, destination], markers: [{ nodeId: destination.nodeId, marker: { type: 'bullet-list', parents: [], attrs: { 'reams.nodeId': destination.nodeId } } }], }), markerDeltaEffect.of({ bindingToken: token, before: [], after: [], facts: [{ key, after: 'Friday' }] }), ], }) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- existing item #task\n- #task') expect(await h.manager.readBlockFacts(h.url)).toMatchObject({ [key]: 'Friday' }) const handle = (await h.manager.findFileHandle(h.url))! const independent = buildRecordNodeIndex(handle.doc() as any, { path: ['content'], recognizedTypeNames: ['task'] }) const state = h.manager.getTextFirstDocumentState(h.url) expect(independent.status).toBe('available') expect(state.nodeIndex.status).toBe('available') if (independent.status === 'available' && state.nodeIndex.status === 'available') { expect(state.nodeIndex.index.nodes).toEqual(independent.index.nodes) } } finally { applyTransaction.mockRestore() await cleanup(h) } })
it('captures real Automerge patches for native-block Enter and undo', async () => { const cases = [ { name: 'plain-list-end', content: '- existing item', cursor: '- existing item'.length }, { name: 'mid-list-split', content: '- first item\n- second item', cursor: '- first'.length }, { name: 'task-enter', content: '- [ ] task #task', cursor: '- [ ] task #task'.length }, { name: 'crlf-unicode-sentinel', content: '- caf\u00e9 \ud83d\ude00\r\n- literal \ufffc', cursor: '- caf\u00e9 \ud83d\ude00\r\n- literal \ufffc'.length }, ] as const
for (const testCase of cases) { const h = await createHarness(testCase.content, [], true) const captures: RealChangeCapture[] = [] const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing document handle') const onChange = (event: unknown) => { captures.push(captureRealChange(event as Parameters<typeof captureRealChange>[0])) } handle.on('change', onChange) try { h.view.dispatch({ selection: { anchor: testCase.name === 'crlf-unicode-sentinel' ? h.view.state.doc.length : testCase.cursor } }) expect(handleEnterKey(h.view), testCase.name).toBe(true) await tick() expect(captures.length, `${testCase.name}: ${JSON.stringify(captures)}`).toBeGreaterThan(0)
const enter = captures[captures.length - 1]! expect(enter.patches.some(patch => patch.action === 'splice' && patch.path[0] === 'content'), `${testCase.name}: ${JSON.stringify(enter)}`).toBe(true) expect(enter.before.rawLength).toBeGreaterThanOrEqual(enter.before.visibleLength) expect(enter.after.rawLength).toBeGreaterThanOrEqual(enter.after.visibleLength) if (testCase.name === 'crlf-unicode-sentinel') { expect(enter.before.raw).toContain('\ufffc') expect(enter.before.raw.length).toBe(enter.before.rawLength) expect(enter.after.raw).toBe(`${enter.before.raw}\r\n- `) expect(enter.patches.some(patch => patch.action === 'splice' && patch.value === '\r\n- '), JSON.stringify(enter)).toBe(true) }
const capturesBeforeUndo = captures.length expect(undo(h.view), testCase.name).toBe(true) await tick() expect(captures.length, `${testCase.name} undo: ${JSON.stringify(captures)}`).toBeGreaterThan(capturesBeforeUndo) const undoCapture = captures[captures.length - 1]! expect(undoCapture.patches.some(patch => (patch.action === 'splice' || patch.action === 'del') && patch.path[0] === 'content'), `${testCase.name} undo: ${JSON.stringify(undoCapture)}`).toBe(true) } finally { handle.off('change', onChange) await cleanup(h) } } })
it.each([ ['plain-list-end', Array.from({ length: 128 }, (_, index) => `- item ${index}`).join('\n')], ['task-list-end', '- [ ] task #task'], ] as const)('updates the real FileIndex size for %s without content projection', async (_name, content) => { const h = await createHarness(content, [], true, true) const contentSize = vi.spyOn(fileStatModule, 'contentSizeFromDoc') const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing document handle') const initialIds = lineAttachmentOwners(h.view.state).map(owner => owner.nodeId) const initialMarker = A.spans(handle.doc() as A.Doc<any>, ['content']).find(span => span.type === 'block')?.value const observed: Array<{ size: number; projected: number }> = [] const modify = () => observed.push({ size: h.file!.stat.size, projected: visibleTextFromDoc(handle.doc()).length }) h.actualFileIndex!.on('modify', modify) contentSize.mockClear() try { h.view.dispatch({ selection: { anchor: h.view.state.doc.length } }) expect(handleEnterKey(h.view)).toBe(true) await tick()
expect(observed).toHaveLength(1) expect(observed[0]!.size).toBe(observed[0]!.projected) expect(h.file!.stat.size).toBe(visibleTextFromDoc(handle.doc()).length) expect(contentSize).not.toHaveBeenCalled() const afterIds = lineAttachmentOwners(h.view.state).map(owner => owner.nodeId) expect(afterIds).toEqual(initialIds)
expect(undo(h.view)).toBe(true) await tick() expect(h.file!.stat.size).toBe(content.length) expect(lineAttachmentOwners(h.view.state)).toEqual(initialIds.map(nodeId => ({ nodeId, lineStart: expect.any(Number) }))) expect(A.spans(handle.doc() as A.Doc<any>, ['content']).find(span => span.type === 'block')?.value).toEqual(initialMarker) expect(redo(h.view)).toBe(true) await tick() expect(h.file!.stat.size).toBe(visibleTextFromDoc(handle.doc()).length) expect(lineAttachmentOwners(h.view.state).map(owner => owner.nodeId)).toEqual(initialIds) } finally { h.actualFileIndex!.off('modify', modify) contentSize.mockRestore() await cleanup(h) } })
it('falls back safely when deleting a literal marker from CRLF and emoji text', async () => { const content = '- cafe\u0301 \ud83d\ude00\r\n- literal \ufffc' const h = await createHarness(content, [], false, true) const contentSize = vi.spyOn(fileStatModule, 'contentSizeFromDoc') const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing file handle') const rawBefore = handle.doc().content as string const marker = rawBefore.lastIndexOf('\ufffc') contentSize.mockClear() try { handle.change(draft => { A.splice(draft as A.Doc<any>, ['content'], marker, 1) }) await tick() expect(handle.doc().content as string).toBe(`${rawBefore.slice(0, marker)}${rawBefore.slice(marker + 1)}`) expect(h.file!.stat.size).toBe(visibleTextFromDoc(handle.doc()).length) expect(contentSize).toHaveBeenCalled() } finally { contentSize.mockRestore() await cleanup(h) } })
it('keeps the original marker and facts with content after an empty-first split', async () => { const h = await createHarness('- old #task', ['task']) try { const handle = await h.manager.findFileHandle(h.url) const original = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(original, 'task', 'payload') const marker = A.block(handle!.doc() as A.Doc<any>, ['content'], 0)! const payload = { ...marker, attrs: { ...(marker.attrs as Record<string, unknown>), date: new Date(0), count: new A.Counter(7), values: [true, 3, 'opaque'], } } handle!.change(draft => { A.updateBlock(draft as A.Doc<any>, ['content'], 0, payload) ;(draft as Record<string, unknown>)[key] = { nested: [true, 3, 'opaque'] } }) await tick()
h.view.dispatch({ selection: { anchor: 2 } }) expect(handleEnterKey(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('- \n- old #task') const after = h.manager.getTextFirstDocumentState(h.url) const moved = after.lineOwners.find(owner => owner.nodeId === original)! expect(moved.lineStart).toBe(3) expect(after.lineOwners.filter(owner => owner.lineStart === 3)).toHaveLength(1) expect(A.spans((await h.manager.findFileHandle(h.url))!.doc(), ['content']).filter(span => span.type === 'block')).toHaveLength(1) expect((await h.manager.readBlockFacts(h.url))?.[key]).toEqual({ nested: [true, 3, 'opaque'] }) const blocks = (await h.manager.findFileHandle(h.url))!.doc() const movedMarker = A.spans(blocks as A.Doc<any>, ['content']).find(span => span.type === 'block' && (span.value as any).attrs['reams.nodeId'] === original)! expect(movedMarker.value).toMatchObject({ type: 'bullet-list', attrs: { date: new Date(0), count: new A.Counter(7), values: [true, 3, 'opaque'] } })
expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- old #task') expect(redo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- \n- old #task') expect((await h.manager.readBlockFacts(h.url))?.[key]).toEqual({ nested: [true, 3, 'opaque'] }) } finally { await cleanup(h) } })
it('transfers every typed field on the content fragment during a list split', async () => { const h = await createHarness('- old #task #person', ['task', 'person']) try { const handle = await h.manager.findFileHandle(h.url) const original = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const taskKey = encodeBlockFactKey(original, 'task', 'due') const personKey = encodeBlockFactKey(original, 'person', 'name') const priorityKey = encodeBlockFactKey(original, 'person', 'priority') handle!.change(draft => { ;(draft as Record<string, unknown>)[taskKey] = 'Friday' ;(draft as Record<string, unknown>)[personKey] = 'Alice' ;(draft as Record<string, unknown>)[priorityKey] = 'high' }) await tick() h.view.dispatch({ selection: { anchor: h.view.state.doc.toString().indexOf('#person') } }) expect(handleEnterKey(h.view)).toBe(true) await tick() const after = h.manager.getTextFirstDocumentState(h.url) const lineText = (lineStart: number) => { const lineEnd = after.content.indexOf('\n', lineStart) return after.content.slice(lineStart, lineEnd < 0 ? after.content.length : lineEnd) } const originalOwner = after.lineOwners.find(owner => lineText(owner.lineStart) === '- old #task ')! const destinationOwner = after.lineOwners.find(owner => lineText(owner.lineStart) === '- #person')! expect(originalOwner, JSON.stringify({ content: after.content, owners: after.lineOwners })).toBeDefined() expect(destinationOwner, JSON.stringify({ content: after.content, owners: after.lineOwners })).toBeDefined() expect(originalOwner!.nodeId).toBe(original) expect(destinationOwner!.nodeId).not.toBe(original) const destination = destinationOwner.nodeId expect((await h.manager.readBlockFacts(h.url))).toMatchObject({ [encodeBlockFactKey(destination, 'person', 'name')]: 'Alice', [encodeBlockFactKey(destination, 'person', 'priority')]: 'high', [taskKey]: 'Friday', }) expect((await h.manager.readBlockFacts(h.url))).not.toHaveProperty(personKey) expect((await h.manager.readBlockFacts(h.url))).not.toHaveProperty(priorityKey) expect(undo(h.view)).toBe(true) await tick() const afterUndo = h.manager.getTextFirstDocumentState(h.url) expect(afterUndo.lineOwners).toEqual([{ nodeId: original, lineStart: 0 }]) const factsAfterUndo = await h.manager.readBlockFacts(h.url) expect(factsAfterUndo).toMatchObject({ [taskKey]: 'Friday', [personKey]: 'Alice', [priorityKey]: 'high' }) expect(factsAfterUndo).not.toHaveProperty(encodeBlockFactKey(destination, 'person', 'name')) expect(factsAfterUndo).not.toHaveProperty(encodeBlockFactKey(destination, 'person', 'priority')) expect(redo(h.view)).toBe(true) await tick() const afterRedo = h.manager.getTextFirstDocumentState(h.url) const redoLineText = (lineStart: number) => { const lineEnd = afterRedo.content.indexOf('\n', lineStart) return afterRedo.content.slice(lineStart, lineEnd < 0 ? afterRedo.content.length : lineEnd) } expect(afterRedo.lineOwners.find(owner => redoLineText(owner.lineStart) === '- old #task ')?.nodeId).toBe(original) expect(afterRedo.lineOwners.find(owner => redoLineText(owner.lineStart) === '- #person')?.nodeId).toBe(destination) expect((await h.manager.readBlockFacts(h.url))?.[encodeBlockFactKey(destination, 'person', 'name')]).toBe('Alice') expect((await h.manager.readBlockFacts(h.url))?.[encodeBlockFactKey(destination, 'person', 'priority')]).toBe('high') } finally { await cleanup(h) } })
it('keeps blockquote ordered-list Enter independently indexed through history', async () => { const h = await createHarness('> 1. old', [], true) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') h.view.dispatch({ selection: { anchor: h.view.state.doc.length } }) expect(handleEnterKey(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('> 1. old\n> 2. ') const state = h.manager.getTextFirstDocumentState(h.url) expect(state.nodeIndex.status).toBe('available') if (state.nodeIndex.status !== 'available') throw new Error('expected an indexed split') const independent = buildRecordNodeIndex((await h.manager.findFileHandle(h.url))!.doc() as A.Doc<any>, { path: ['content'], recognizedTypeNames: [] }) expect(independent.status).toBe('available') if (independent.status !== 'available') throw new Error('expected an independent indexed split') expect(state.nodeIndex.index.nodes).toEqual(independent.index.nodes) expect(state.nodeIndex.index.nodes).toHaveLength(0) expect(state.content).toBe('> 1. old\n> 2. ') expect(state.lineOwners).toEqual([]) expect(undo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('> 1. old') expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status).toBe('available') expect(redo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('> 1. old\n> 2. ') expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status).toBe('available') } finally { await cleanup(h) } })
it.each([ ['LF', '\n'], ['CRLF', '\r\n'], ] as const)('keeps native end-at-line-start geometry through fence Enter (%s)', async (_name, newline) => { const source = `- ~~~~${newline}${newline}alpha` const h = await createHarness(source, [], true) try { const cmPosition = (raw: string, position: number) => raw.slice(0, position).replace(/\r\n|\r/g, '\n').length const nativeBefore = h.manager.getTextFirstDocumentState(h.url).nodeIndex expect(nativeBefore.status).toBe('available') if (nativeBefore.status !== 'available') throw new Error('expected an indexed document') const beforeExtents = lineAttachmentAstExtents(h.view.state) expect(beforeExtents.map(extent => [extent.nodeId, extent.from, extent.to])).toEqual( nativeBefore.index.nodes.map(node => [node.id, cmPosition(source, node.from), cmPosition(source, node.to)]), )
h.view.dispatch({ selection: { anchor: h.view.state.doc.line(1).to } }) expect(handleEnterKey(h.view)).toBe(true) await tick()
const nativeAfter = h.manager.getTextFirstDocumentState(h.url).nodeIndex expect(nativeAfter.status).toBe('available') if (nativeAfter.status !== 'available') throw new Error('expected an indexed document after Enter') const afterExtents = lineAttachmentAstExtents(h.view.state) const afterRaw = h.manager.getTextFirstDocumentState(h.url).content expect(afterExtents.map(extent => [extent.nodeId, extent.from, extent.to])).toEqual( nativeAfter.index.nodes.map(node => [node.id, cmPosition(afterRaw, node.from), cmPosition(afterRaw, node.to)]), ) expect(h.view.state.doc.toString()).toBe('- ~~~~\n- \n\nalpha') } finally { await cleanup(h) } })
it('persists a read-mode checkbox through the bound CM, attachment filter, and writer', async () => { const h = await createHarness('- [ ] persisted task', []) h.view.destroy() const parent = document.createElement('div') document.body.appendChild(parent) const view = new EditorView({ state: EditorState.create({ doc: '- [ ] persisted task', extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), checkboxWidget, lineAttachmentExtension, EditorState.readOnly.of(true), EditorView.updateListener.of(update => h.controller.onDocumentUpdate(update)), ], selection: { anchor: '- [ ] persisted task'.length }, }), parent, }) h.controller.bindView(view, createEditorBindingOwnership()) try { const checkbox = view.dom.querySelector<HTMLInputElement>('.cm-task-checkbox') expect(checkbox).not.toBeNull() checkbox!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) expect(view.state.doc.toString()).toBe('- [x] persisted task') expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- [x] persisted task') expect(view.dom.querySelector<HTMLInputElement>('.cm-task-checkbox')?.checked).toBe(true) } finally { view.destroy() parent.remove() await h.controller.dispose() h.manager.dispose() await h.repo.shutdown() } }) it.each([ ['task first', 'left #task #person', 'task', 'person'], ['person first', 'left #person #task', 'person', 'task'], ])('redistributes multi-Type paragraph facts through CM undo and redo (%s)', async (_label, content, firstType, secondType) => { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('multi-type.md', undefined, content) await manager.prepareTextFirstDocument(url) await expect(manager.initializeTextFirstLineOwners(url, [firstType, secondType])).resolves.toBe('applied') const handle = await manager.findFileHandle(url) if (!handle) throw new Error('missing document handle') const original = manager.getTextFirstDocumentState(url).lineOwners[0]!.nodeId handle.change(draft => { ;(draft as Record<string, unknown>)[encodeBlockFactKey(original, firstType, 'value')] = `${firstType}-value` ;(draft as Record<string, unknown>)[encodeBlockFactKey(original, secondType, 'value')] = `${secondType}-value` }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: [firstType, secondType] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const view = new EditorView({ state: EditorState.create({ doc: content, extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))] }) }) controller.bindView(view, createEditorBindingOwnership()) try { // A soft newline is still one paragraph and one subject with both facets. view.dispatch({ changes: { from: `left #${firstType}`.length, insert: '\n' } }) expect(manager.getTextFirstDocumentState(url).lineOwners.map(owner => owner.nodeId)).toEqual([original]) expect(await manager.readBlockFacts(url)).toMatchObject({ [encodeBlockFactKey(original, firstType, 'value')]: `${firstType}-value`, [encodeBlockFactKey(original, secondType, 'value')]: `${secondType}-value`, }) expect(undo(view)).toBe(true) expect(view.state.doc.toString()).toBe(content) view.dispatch({ changes: { from: `left #${firstType}`.length, insert: '\n\n' } }) const owners = manager.getTextFirstDocumentState(url).lineOwners const fresh = owners.find(owner => owner.nodeId !== original)!.nodeId const facts = await manager.readBlockFacts(url) as Record<string, unknown> expect(facts[encodeBlockFactKey(original, firstType, 'value')]).toBe(`${firstType}-value`) expect(facts[encodeBlockFactKey(fresh, secondType, 'value')]).toBe(`${secondType}-value`) expect(facts[encodeBlockFactKey(original, secondType, 'value')]).toBeUndefined() expect(undo(view)).toBe(true) const undone = manager.getTextFirstDocumentState(url) expect(undone.lineOwners.map(owner => owner.nodeId)).toEqual([original]) const undoneFacts = await manager.readBlockFacts(url) as Record<string, unknown> expect(undoneFacts[encodeBlockFactKey(original, secondType, 'value')]).toBe(`${secondType}-value`) expect(redo(view)).toBe(true) expect(manager.getTextFirstDocumentState(url).lineOwners.map(owner => owner.nodeId)).toContain(fresh) const redoneFacts = await manager.readBlockFacts(url) as Record<string, unknown> expect(redoneFacts[encodeBlockFactKey(fresh, secondType, 'value')]).toBe(`${secondType}-value`) } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('does not republish or repeat initialization for equivalent Type-name arrays on rerender', async () => { const h = await createHarness('ordinary text') const initialize = vi.spyOn(h.manager, 'initializeTextFirstLineOwners') const listener = vi.fn() const unsubscribe = h.controller.subscribe(listener) try { for (let render = 0; render < 3; render += 1) { await h.controller.updateOptions({ documentManager: h.manager, docUrl: h.url, recognizedTypeNames: [] }) await tick() } expect(initialize).not.toHaveBeenCalled() expect(listener).not.toHaveBeenCalled() } finally { unsubscribe() await cleanup(h) } })
it('writes a multi-range CM transaction as one Automerge change', async () => { const h = await createHarness('abcdef') try { const before = await changeHashes(h.manager, h.url) h.view.dispatch({ changes: [{ from: 1, to: 2, insert: 'B' }, { from: 4, to: 5, insert: 'E' }] }) const after = await changeHashes(h.manager, h.url) expect(after.size - before.size).toBe(1) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('aBcdEf') } finally { await cleanup(h) } })
it('transfers each person facet to its own destination in one multi-range split', async () => { const source = 'left #task #person\n\nright #task #person' const h = await createHarness(source, ['task', 'person']) try { const initial = h.manager.getTextFirstDocumentState(h.url) const [left, right] = initial.lineOwners if (!left || !right) throw new Error('expected two source owners') const leftTask = encodeBlockFactKey(left.nodeId, 'task', 'due') const leftPerson = encodeBlockFactKey(left.nodeId, 'person', 'name') const rightTask = encodeBlockFactKey(right.nodeId, 'task', 'due') const rightPerson = encodeBlockFactKey(right.nodeId, 'person', 'name') const handle = (await h.manager.findFileHandle(h.url))! handle.change(draft => { const facts = draft as Record<string, unknown> facts[leftTask] = 'Friday' facts[leftPerson] = 'Alice' facts[rightTask] = 'Monday' facts[rightPerson] = 'Bob' }) await tick()
const firstPerson = source.indexOf('#person') const secondPerson = source.indexOf('#person', firstPerson + 1) h.view.dispatch({ changes: [ { from: firstPerson, insert: '\n\n' }, { from: secondPerson, insert: '\n\n' }, ] }) await tick()
const after = h.manager.getTextFirstDocumentState(h.url) const lines = after.lineOwners.map(owner => ({ text: after.content.slice(owner.lineStart, after.content.indexOf('\n', owner.lineStart) < 0 ? after.content.length : after.content.indexOf('\n', owner.lineStart)), id: owner.nodeId, })) expect(after.content).toBe('left #task \n\n#person\n\nright #task \n\n#person') expect(lines.map(line => line.text)).toEqual(['left #task ', '#person', 'right #task ', '#person']) const [leftTaskOwner, leftPersonOwner, rightTaskOwner, rightPersonOwner] = lines expect(leftTaskOwner!.id).toBe(left.nodeId) expect(rightTaskOwner!.id).toBe(right.nodeId) expect(leftPersonOwner!.id).not.toBe(leftTaskOwner!.id) expect(rightPersonOwner!.id).not.toBe(rightTaskOwner!.id) expect(leftPersonOwner!.id).not.toBe(rightPersonOwner!.id) const facts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> const recordFacts = Object.fromEntries(Object.entries(facts).filter(([key]) => key.startsWith('reams.record.'))) expect(recordFacts).toEqual({ [encodeBlockFactKey(left.nodeId, 'task', 'due')]: 'Friday', [encodeBlockFactKey(leftPersonOwner!.id, 'person', 'name')]: 'Alice', [encodeBlockFactKey(right.nodeId, 'task', 'due')]: 'Monday', [encodeBlockFactKey(rightPersonOwner!.id, 'person', 'name')]: 'Bob', })
expect(undo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(source) const undoneFacts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> expect(Object.fromEntries(Object.entries(undoneFacts).filter(([key]) => key.startsWith('reams.record.')))).toEqual({ [leftTask]: 'Friday', [leftPerson]: 'Alice', [rightTask]: 'Monday', [rightPerson]: 'Bob' }) expect(redo(h.view)).toBe(true) const redoneFacts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> expect(Object.fromEntries(Object.entries(redoneFacts).filter(([key]) => key.startsWith('reams.record.')))).toEqual(recordFacts) } finally { await cleanup(h) } })
it('keeps the original task facts, assigns person facts once, and defaults the second person on a three-way split', async () => { const source = 'one #task #person #person' const h = await createHarness(source, ['task', 'person']) try { const original = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const taskKey = encodeBlockFactKey(original, 'task', 'due') const personKey = encodeBlockFactKey(original, 'person', 'name') const handle = (await h.manager.findFileHandle(h.url))! handle.change(draft => { const facts = draft as Record<string, unknown> facts[taskKey] = 'Friday' facts[personKey] = 'Alice' }) await tick() const firstPerson = source.indexOf('#person') const secondPerson = source.indexOf('#person', firstPerson + 1) h.view.dispatch({ changes: [ { from: firstPerson, insert: '\n\n' }, { from: secondPerson, insert: '\n\n' }, ] }) await tick()
const after = h.manager.getTextFirstDocumentState(h.url) const personOwners = after.lineOwners.filter(owner => after.content.slice(owner.lineStart).startsWith('#person')) expect(personOwners, JSON.stringify({ content: after.content, owners: after.lineOwners })).toHaveLength(2) expect(after.lineOwners.some(owner => owner.nodeId === original)).toBe(true) const facts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> const recordFacts = Object.fromEntries(Object.entries(facts).filter(([key]) => key.startsWith('reams.record.'))) expect(recordFacts).toEqual({ [taskKey]: 'Friday', [encodeBlockFactKey(personOwners[0]!.nodeId, 'person', 'name')]: 'Alice', }) expect(personOwners[0]!.nodeId).not.toBe(personOwners[1]!.nodeId)
expect(undo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe(source) const undoneFacts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> expect(Object.fromEntries(Object.entries(undoneFacts).filter(([key]) => key.startsWith('reams.record.')))).toEqual({ [taskKey]: 'Friday', [personKey]: 'Alice' }) expect(redo(h.view)).toBe(true) const redoneFacts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> expect(Object.fromEntries(Object.entries(redoneFacts).filter(([key]) => key.startsWith('reams.record.')))).toEqual(recordFacts) } finally { await cleanup(h) } })
it('processes chained CM transactions with exact same-facet joins', async () => { const source = 'first #task\n\nsecond #task\n\nthird #task' const h = await createHarness(source, ['task']) try { const initial = h.manager.getTextFirstDocumentState(h.url) const [first, second, third] = initial.lineOwners if (!first || !second || !third) throw new Error('expected three task owners') const firstDue = encodeBlockFactKey(first.nodeId, 'task', 'due') const secondStatus = encodeBlockFactKey(second.nodeId, 'task', 'status') const thirdPriority = encodeBlockFactKey(third.nodeId, 'task', 'priority') const handle = (await h.manager.findFileHandle(h.url))! handle.change(draft => { const facts = draft as Record<string, unknown> facts[firstDue] = 'Friday' facts[secondStatus] = 'active' facts[thirdPriority] = 'high' }) await tick() const firstSeparator = source.indexOf('\n\n') const t1 = h.view.state.update({ changes: { from: firstSeparator + 1, to: firstSeparator + 2, insert: '' } }) const secondSeparator = t1.newDoc.toString().indexOf('\n\n') const t2 = t1.state.update({ changes: { from: secondSeparator + 1, to: secondSeparator + 2, insert: '' } }) h.view.update([t1, t2]) expect(h.view.state.doc.toString(), h.controller.getAttachmentDiagnostic()).toBe('first #task\nsecond #task\nthird #task') const joined = h.manager.getTextFirstDocumentState(h.url) expect(joined.lineOwners).toHaveLength(1) const facts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> expect(Object.fromEntries(Object.entries(facts).filter(([key]) => key.startsWith('reams.record.')))).toEqual({ [encodeBlockFactKey(joined.lineOwners[0]!.nodeId, 'task', 'due')]: 'Friday', }) } finally { await cleanup(h) } })
it('composes a fresh owner marker into a chained paragraph-to-list edit', async () => { const source = 'ordinary paragraph' const h = await createHarness(source, ['task']) try { const t1 = h.view.state.update({ changes: { from: source.length, insert: ' #task' } }) const t2 = t1.state.update({ changes: { from: 0, insert: '- ' } }) h.view.update([t1, t2])
expect(h.view.state.doc.toString()).toBe('- ordinary paragraph #task') const joined = h.manager.getTextFirstDocumentState(h.url) expect(joined.content).toBe('- ordinary paragraph #task') expect(joined.lineOwners).toHaveLength(1)
expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary paragraph #task') expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe(source) expect(redo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary paragraph #task') expect(redo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- ordinary paragraph #task') expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toHaveLength(1) } finally { await cleanup(h) } })
it('atomically rolls back a two-transaction CM update when the second transaction is unsupported', async () => { const source = 'first #task\n\nsecond #task' const h = await createHarness(source, ['task']) try { const handle = (await h.manager.findFileHandle(h.url))! const before = A.toJS(handle.doc()) const t1 = h.view.state.update({ changes: { from: 0, to: 0, insert: 'x' } }) const t2 = t1.state.update({ changes: { from: 0, to: 1, insert: '' }, effects: unsupportedAttachmentEffect.of('injected unsupported chained edit'), }) h.view.update([t1, t2]) expect(h.view.state.doc.toString()).toBe(source) expect(A.toJS(handle.doc())).toEqual(before) expect(h.controller.getAttachmentDiagnostic()).toContain('injected unsupported chained edit') } finally { await cleanup(h) } })
it('uses the prior CM attachment projection across a split then retirement batch', async () => { const source = 'x #task #person' const h = await createHarness(source, ['task', 'person']) try { const handle = (await h.manager.findFileHandle(h.url))! const original = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]! const taskKey = encodeBlockFactKey(original.nodeId, 'task', 'name') const personKey = encodeBlockFactKey(original.nodeId, 'person', 'name') handle.change(draft => { const facts = draft as Record<string, unknown> facts[taskKey] = 'Task' facts[personKey] = 'Alice' }) await tick()
const splitAt = source.indexOf('#person') const t1 = h.view.state.update({ changes: { from: splitAt, to: splitAt, insert: '\n\n' }, annotations: isolateHistory.of('full') }) const destination = lineAttachmentOwners(t1.state).find(owner => owner.nodeId !== original.nodeId)!.nodeId const destinationPersonKey = encodeBlockFactKey(destination, 'person', 'name') expect(lineAttachmentAstExtents(t1.state).find(extent => extent.nodeId === destination)?.facts).toEqual(expect.arrayContaining([expect.objectContaining({ key: destinationPersonKey, value: 'Alice' })])) const personStart = t1.newDoc.toString().indexOf('#person') const t2 = t1.state.update({ changes: { from: personStart - 2, to: personStart + '#person'.length, insert: '' }, annotations: isolateHistory.of('full') }) h.view.update([t1, t2])
const after = h.manager.getTextFirstDocumentState(h.url) expect(after.content, h.controller.getAttachmentDiagnostic()).toBe('x #task ') expect(after.lineOwners.map(owner => owner.nodeId)).toEqual([original.nodeId]) const taskOwner = after.lineOwners.find(owner => after.content.slice(owner.lineStart).startsWith('x #task'))! expect(taskOwner.nodeId).toBe(original.nodeId) expect(await h.manager.readBlockFacts(h.url)).toMatchObject({ [taskKey]: 'Task' }) const afterFacts = await h.manager.readBlockFacts(h.url) as Record<string, unknown> expect(Object.keys(afterFacts).filter(key => decodeBlockFactKey(key)?.[1] === 'person')).toHaveLength(0) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(personKey)
expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe(t1.newDoc.toString()) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(destinationPersonKey, 'Alice') expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe(source) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(personKey, 'Alice') expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(destinationPersonKey) expect(redo(h.view)).toBe(true) expect(redo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('x #task ') expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(destinationPersonKey) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(personKey) await h.controller.flushPendingPlain() } finally { await cleanup(h) } })
it('rolls back a failed chained edit, retries the same edit, then saves its undo', async () => { const source = 'first #task\n\nsecond #task\n\nthird #task' const h = await createHarness(source, ['task']) const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction').mockImplementationOnce(() => { throw new Error('injected chained rejection') }) try { const makeJoin = () => { const firstSeparator = h.view.state.doc.toString().indexOf('\n\n') const t1 = h.view.state.update({ changes: { from: firstSeparator + 1, to: firstSeparator + 2, insert: '' } }) const secondSeparator = t1.newDoc.toString().indexOf('\n\n') const t2 = t1.state.update({ changes: { from: secondSeparator + 1, to: secondSeparator + 2, insert: '' } }) return [t1, t2] } h.view.update(makeJoin()) expect(h.view.state.doc.toString()).toBe(source) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe(source) expect(h.controller.getAttachmentDiagnostic()).toContain('injected chained rejection')
h.view.update(makeJoin()) expect(h.view.state.doc.toString()).toBe('first #task\nsecond #task\nthird #task') expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.controller.getSnapshot().saveStatus.state).toBe('saved') expect(undo(h.view)).toBe(true) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.view.state.doc.toString()).toBe('first #task\nsecond #task\n\nthird #task') expect(undo(h.view)).toBe(true) expect(await h.controller.flushPendingPlain()).toBe(true) expect(h.view.state.doc.toString()).toBe(source) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe(source) expect(h.controller.getSnapshot().saveStatus.state).toBe('saved') } finally { apply.mockRestore(); await cleanup(h) } })
it('preserves a whole task identity and facts through text replacement', async () => { const h = await createHarness('- old #task', ['task']) try { const original = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(original, 'task', 'due') const handle = await h.manager.findFileHandle(h.url) handle!.change(draft => { (draft as Record<string, unknown>)[key] = 'Friday' }) const from = h.view.state.doc.toString().indexOf('old') h.view.dispatch({ changes: { from, to: from + 3, insert: 'new' } }) const after = h.manager.getTextFirstDocumentState(h.url) expect(after.content).toBe('- new #task') expect(after.lineOwners.map(owner => owner.nodeId)).toEqual([original]) expect(await h.manager.readBlockFacts(h.url)).toMatchObject({ [key]: 'Friday' }) } finally { await cleanup(h) } })
it('deletes two task ranges while retaining the correct surviving owner position', async () => { const content = '- first #task\n\n- remove #task\n\n- last #task' const h = await createHarness(content, ['task']) try { const initial = h.manager.getTextFirstDocumentState(h.url) const last = initial.nodeIndex.status === 'available' ? initial.nodeIndex.index.nodes[initial.nodeIndex.index.nodes.length - 1] : undefined expect(last).toBeDefined() const remove = content.indexOf('- remove') const removeEnd = remove + '- remove #task'.length const first = content.indexOf('- first') h.view.dispatch({ changes: [ { from: first, to: first + '- first #task\n\n'.length, insert: '' }, { from: remove, to: removeEnd + (content[removeEnd] === '\n' ? 1 : 0), insert: '' }, ] }) const after = h.manager.getTextFirstDocumentState(h.url) expect(after.content).toBe('\n- last #task') expect(after.lineOwners).toEqual([{ nodeId: last!.id, lineStart: 1 }]) } finally { await cleanup(h) } })
it('preserves one AST paragraph owner through native Enter and real CM history', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('note.md', undefined, 'Buy milk #task') const handle = await manager.findFileHandle(url) handle!.change(draft => { A.updateText(draft as A.Doc<any>, ['content'], 'Buy milk #task') A.splitBlock(draft as A.Doc<any>, ['content'], 0, { type: 'paragraph', parents: [], attrs: { 'reams.nodeId': 'task-node' } }) ;(draft as Record<string, unknown>)[encodeBlockFactKey('task-node', 'task', 'due')] = 'tomorrow' }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const view = new EditorView({ state: EditorState.create({ doc: 'Buy milk #task', extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))], }), }) controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ changes: { from: 3, to: 4, insert: '\n' } }) expect(manager.getTextFirstDocumentState(url).content).toBe('Buy\nmilk #task') const forwardIds = A.spans(handle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block').map(span => (span.value as any).attrs['reams.nodeId']) expect(forwardIds).toEqual(['task-node']) const forwardIndex = manager.getTextFirstDocumentState(url).nodeIndex expect(forwardIndex.status).toBe('available') if (forwardIndex.status === 'available') { expect(forwardIndex.index.nodes.map(node => [node.kind, node.id])).toEqual([['paragraph', 'task-node']]) } expect((await manager.readBlockFactsAt(url, manager.getTextFirstDocumentState(url).heads))?.[encodeBlockFactKey('task-node', 'task', 'due')]).toBe('tomorrow') expect(undo(view)).toBe(true) expect(manager.getTextFirstDocumentState(url).content).toBe('Buy milk #task') expect(A.spans(handle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block').map(span => (span.value as any).attrs['reams.nodeId'])).toEqual(['task-node']) expect(redo(view)).toBe(true) expect(manager.getTextFirstDocumentState(url).content).toBe('Buy\nmilk #task') expect(A.spans(handle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block').map(span => (span.value as any).attrs['reams.nodeId'])).toEqual(forwardIds) } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('keeps task identity on the tagged list fragment through real Enter, reload, and undo', async () => { const h = await createHarness('- [ ] Parent #task', ['task']) h.view.destroy() const view = new EditorView({ state: EditorState.create({ doc: '- [ ] Parent #task', extensions: [history(), lineAttachmentExtension, editingKeybindings(), EditorView.updateListener.of(update => h.controller.onDocumentUpdate(update))], }) }) h.controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ selection: { anchor: view.state.doc.length } }) view.focus() view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) expect(view.state.doc.toString()).toBe('- [ ] Parent #task\n- [ ] ') expect(h.controller.getAttachmentDiagnostic()).toBeUndefined() const first = h.manager.getTextFirstDocumentState(h.url) expect(first.lineOwners).toHaveLength(1) const original = first.lineOwners.find(owner => first.content.slice(owner.lineStart).includes('#task'))! expect(original.nodeId).toBe(first.lineOwners[0]!.nodeId)
expect(undo(view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toHaveLength(1) expect(redo(view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toHaveLength(1) } finally { view.destroy() await h.controller.dispose() h.manager.dispose() await h.repo.shutdown() } })
it('preserves the fresh paragraph marker payload through a two-Enter split', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('paragraph-split.md', undefined, 'Buy milk #task') const handle = await manager.findFileHandle(url) handle!.change(draft => { A.updateText(draft as A.Doc<any>, ['content'], 'Buy milk #task') A.splitBlock(draft as A.Doc<any>, ['content'], 0, { type: 'paragraph', parents: [], attrs: { 'reams.nodeId': 'task-node' } }) ;(draft as Record<string, unknown>)[encodeBlockFactKey('task-node', 'task', 'due')] = 'Friday' }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const view = new EditorView({ state: EditorState.create({ doc: 'Buy milk #task', extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))], }), }) controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ changes: { from: 9, to: 9, insert: '\n' } }) view.dispatch({ changes: { from: 10, to: 10, insert: '\n' } }) await controller.flushPendingPlain() const state = manager.getTextFirstDocumentState(url) expect(state.content).toBe('Buy milk \n\n#task') expect(state.nodeIndex.status).toBe('available') if (state.nodeIndex.status === 'available') { expect(state.nodeIndex.index.nodes).toHaveLength(1) const tagged = state.nodeIndex.index.nodes.find(node => state.content.slice(node.from, node.to).includes('#task'))! expect(tagged.id).toBe('task-node') } const blocks = A.spans(handle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block') expect(blocks).toHaveLength(1) expect(blocks.map(span => (span.value as any).type)).toEqual(['paragraph']) const splitIds = state.nodeIndex.status === 'available' ? state.nodeIndex.index.nodes.map(node => node.id) : [] expect((await manager.readBlockFactsAt(url, state.heads))?.[encodeBlockFactKey('task-node', 'task', 'due')]).toBe('Friday') expect(undo(view)).toBe(true) const undone = manager.getTextFirstDocumentState(url) expect(undone.nodeIndex.status).toBe('available') if (undone.nodeIndex.status === 'available') { expect(undone.nodeIndex.index.nodes.map(node => node.id)).toEqual(['task-node']) } expect(redo(view)).toBe(true) const redone = manager.getTextFirstDocumentState(url) expect(redone.nodeIndex.status).toBe('available') if (redone.nodeIndex.status === 'available') expect(redone.nodeIndex.index.nodes.map(node => node.id)).toEqual(splitIds) expect((await manager.readBlockFactsAt(url, redone.heads))?.[encodeBlockFactKey('task-node', 'task', 'due')]).toBe('Friday') } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('maintains typed identity when a split arrives as one double-newline transaction', async () => { const content = '---\ntypes: [task]\n---\n\nBuy milk alpha #task' const h = await createHarness(content, ['task']) try { const splitAt = content.indexOf('alpha') h.view.dispatch({ changes: { from: splitAt, to: splitAt, insert: '\n\n' } }) const state = h.manager.getTextFirstDocumentState(h.url) expect(state.content).toBe(`${content.slice(0, splitAt)}\n\n${content.slice(splitAt)}`) expect(state.nodeIndex.status).toBe('available') if (state.nodeIndex.status === 'available') { expect(state.nodeIndex.index.nodes).toHaveLength(1) expect(new Set(state.nodeIndex.index.nodes.map(node => node.id))) .toEqual(new Set(state.lineOwners.map(owner => owner.nodeId))) } const secondSplitAt = state.content.indexOf('milk') + 2 h.view.dispatch({ changes: { from: secondSplitAt, to: secondSplitAt, insert: '\n\n' } }) const afterSecondSplit = h.manager.getTextFirstDocumentState(h.url) expect(afterSecondSplit.nodeIndex.status).toBe('available') const growAt = afterSecondSplit.content.indexOf('alpha') + 'alpha'.length h.view.dispatch({ changes: { from: growAt, to: growAt, insert: '\n\nNew paragraph' } }) expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status).toBe('available') } finally { await cleanup(h) } })
it('preserves the same native identity across repeated edits', async () => { const content = '---\ntypes: [task]\n---\n\nalpha segment omega #task' const h = await createHarness(content, ['task']) try { const first = h.manager.getTextFirstDocumentState(h.url) expect(first.nodeIndex.status).toBe('available') if (first.nodeIndex.status !== 'available') return const id = first.nodeIndex.index.nodes[0]!.id const alpha = content.indexOf('alpha') h.view.dispatch({ changes: { from: alpha + 5, to: alpha + 5, insert: 'x' } }) h.view.dispatch({ changes: { from: alpha + 6, to: alpha + 6, insert: 'y' } }) const repeated = h.manager.getTextFirstDocumentState(h.url) expect(repeated.nodeIndex.status).toBe('available') if (repeated.nodeIndex.status === 'available') expect(repeated.nodeIndex.index.nodes[0]!.id).toBe(id) } finally { await cleanup(h) } })
it('pastes a captured native snapshot after source mutation with fresh identity and CM undo/redo', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const content = 'Due Friday #task\n\nOther' const url = manager.createFile('copy-snapshot.md', undefined, content) const handle = await manager.findFileHandle(url) if (!handle) throw new Error('missing handle') handle.change(draft => { A.updateText(draft as A.Doc<any>, ['content'], content) A.splitBlock(draft as A.Doc<any>, ['content'], 0, { type: 'paragraph', parents: [], attrs: { 'reams.nodeId': 'source-node', extra: { counter: new A.Counter(7), raw: new A.RawString('opaque') }, }, }) ;(draft as Record<string, unknown>)[encodeBlockFactKey('source-node', 'task', 'due')] = 'Friday' }) await manager.prepareTextFirstDocument(url) expect(await manager.initializeTextFirstLineOwners(url, ['task'])).toBe('applied') const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const clipboardValues = new Map<string, string>() const clipboard = { getData: (type: string) => clipboardValues.get(type) ?? '', setData: (type: string, value: string) => { clipboardValues.set(type, value) }, } const copyEvent = (type: 'copy' | 'paste') => { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: clipboard }) return event } const view = new EditorView({ state: EditorState.create({ doc: content, extensions: [ history(), lineAttachmentExtension, attachmentClipboardHandler({ documentId: url, getPayload: (nodeId, text) => controller.getAttachmentClipboardPayload(nodeId, text), prepareClipboardTransaction: (currentView, spec) => controller.prepareClipboardTransaction(currentView, spec), }), EditorView.updateListener.of(update => controller.onDocumentUpdate(update)), ], }), }) controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ selection: { anchor: 0, head: 16 } }) view.contentDOM.dispatchEvent(copyEvent('copy')) expect(clipboard.getData(ATTACHMENT_CLIPBOARD_MIME)).toMatch(/token/)
view.dispatch({ changes: { from: 4, to: 10, insert: 'Monday' } }) await controller.flushPendingPlain() const sourceOwner = lineAttachmentOwners(view.state).find(owner => owner.nodeId === 'source-node') if (!sourceOwner) throw new Error('missing source owner') view.dispatch({ changes: { from: 0, to: 18, insert: '' }, effects: markerDeltaEffect.of({ ...prepareLineAttachmentDelta(view.state, { atomic: true, before: [sourceOwner], after: [] }), bindingToken: lineAttachmentBindingToken(view.state) }), }) await tick() await controller.flushPendingPlain() expect(view.state.doc.toString(), controller.getAttachmentDiagnostic()).toBe('Other')
view.dispatch({ selection: { anchor: view.state.doc.length } }) view.contentDOM.dispatchEvent(copyEvent('paste')) const pasted = manager.getTextFirstDocumentState(url) expect(pasted.content).toBe('Other\n\nDue Friday #task') expect(pasted.lineOwners).toHaveLength(1) expect(pasted.lineOwners.some(owner => owner.nodeId === 'source-node')).toBe(false) const pastedId = pasted.lineOwners.find(owner => owner.lineStart > 0)!.nodeId expect(pastedId).not.toBe('source-node') expect(await manager.readBlockFacts(url)).toMatchObject({ [encodeBlockFactKey(pastedId, 'task', 'due')]: 'Friday' }) const pastedMarker = A.spans(handle.doc() as A.Doc<any>, ['content']).find(span => span.type === 'block' && (span.value.attrs as Record<string, unknown>)['reams.nodeId'] === pastedId) expect(pastedMarker?.type).toBe('block') if (pastedMarker?.type === 'block') { const attrs = pastedMarker.value.attrs as Record<string, any> expect(A.isCounter(attrs.extra.counter)).toBe(true) expect(attrs.extra.counter.value).toBe(7) expect(A.isRawString(attrs.extra.raw)).toBe(true) expect(attrs.extra.raw.toString()).toBe('opaque') }
expect(undo(view)).toBe(true) expect(manager.getTextFirstDocumentState(url).content).toBe('Other') expect(manager.getTextFirstDocumentState(url).lineOwners).toHaveLength(0) expect(manager.getTextFirstDocumentState(url).lineOwners.some(owner => owner.nodeId === pastedId)).toBe(false) expect(redo(view)).toBe(true) const redone = manager.getTextFirstDocumentState(url) expect(redone.content).toBe('Other\n\nDue Friday #task') expect(redone.lineOwners.find(owner => owner.lineStart > 0)?.nodeId).toBe(pastedId) await expect(manager.flushTextFirstDocument(url)).resolves.toBeUndefined()
const reloaded = new DocumentManager(repo) try { await reloaded.prepareTextFirstDocument(url) await reloaded.initializeTextFirstLineOwners(url, ['task']) expect(reloaded.getTextFirstDocumentState(url).lineOwners.find(owner => owner.lineStart > 0)?.nodeId).toBe(pastedId) const reloadedFacts = await reloaded.readBlockFacts(url) expect(reloadedFacts).toMatchObject({ [encodeBlockFactKey(pastedId, 'task', 'due')]: 'Friday' }) } finally { reloaded.dispose() } } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('cuts and pastes a whole paragraph through the real handler, retaining facts through undo', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const content = 'Due Friday #task\n\nOther' const url = manager.createFile('cut-paste.md', undefined, content) const handle = await manager.findFileHandle(url) if (!handle) throw new Error('missing handle') handle.change(draft => { A.updateText(draft as A.Doc<any>, ['content'], content) A.splitBlock(draft as A.Doc<any>, ['content'], 0, { type: 'paragraph', parents: [], attrs: { 'reams.nodeId': 'source-node' }, }) ;(draft as Record<string, unknown>)[encodeBlockFactKey('source-node', 'task', 'due')] = 'Friday' }) await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task']) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const clipboardValues = new Map<string, string>() const clipboard = { getData: (type: string) => clipboardValues.get(type) ?? '', setData: (type: string, value: string) => { clipboardValues.set(type, value) }, } const clipboardEvent = (type: 'cut' | 'paste') => { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: clipboard }) return event } const view = new EditorView({ state: EditorState.create({ doc: content, extensions: [ history(), lineAttachmentExtension, attachmentClipboardHandler({ documentId: url, getPayload: (nodeId, text) => controller.getAttachmentClipboardPayload(nodeId, text), prepareClipboardTransaction: (currentView, spec) => controller.prepareClipboardTransaction(currentView, spec), }), EditorView.updateListener.of(update => controller.onDocumentUpdate(update)), ], }), }) controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ selection: { anchor: 0, head: 16 } }) const cut = clipboardEvent('cut') view.contentDOM.dispatchEvent(cut) expect(cut.defaultPrevented).toBe(true) expect(view.state.doc.toString()).toBe('Other') expect(clipboard.getData(ATTACHMENT_CLIPBOARD_MIME)).toMatch(/token/) expect(await manager.readBlockFacts(url)).not.toHaveProperty(encodeBlockFactKey('source-node', 'task', 'due'))
view.dispatch({ selection: { anchor: view.state.doc.length } }) view.contentDOM.dispatchEvent(clipboardEvent('paste')) expect(view.state.doc.toString()).toBe('Other\n\nDue Friday #task') expect(view.state.selection.main.head).toBe(view.state.doc.length) expect(lineAttachmentOwners(view.state)).toContainEqual({ nodeId: 'source-node', lineStart: 7 }) expect(await manager.readBlockFacts(url)).toMatchObject({ [encodeBlockFactKey('source-node', 'task', 'due')]: 'Friday' })
expect(undo(view)).toBe(true) expect(view.state.doc.toString()).toBe('Other') expect(await manager.readBlockFacts(url)).not.toHaveProperty(encodeBlockFactKey('source-node', 'task', 'due')) expect(undo(view)).toBe(true) expect(view.state.doc.toString()).toBe(content) expect(await manager.readBlockFacts(url)).toMatchObject({ [encodeBlockFactKey('source-node', 'task', 'due')]: 'Friday' }) } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('puts an unbound field edit after a split in the same CM history sequence', async () => { const source = 'left #task\nright #task' const h = await createHarness(source, ['task']) try { h.view.dispatch({ changes: { from: 'left #task'.length, insert: '\n' } }) const stateAfterSplit = h.manager.getTextFirstDocumentState(h.url) expect(stateAfterSplit.nodeIndex, h.controller.getAttachmentDiagnostic()).toMatchObject({ status: 'available' }) expect(stateAfterSplit.lineOwners, JSON.stringify(h.controller.getSnapshot())).toHaveLength(2) const original = stateAfterSplit.lineOwners[0]!.nodeId const fresh = stateAfterSplit.lineOwners[1]!.nodeId const key = encodeBlockFactKey(fresh, 'task', 'due') const release = await h.controller.prepareExternalWrite() expect(release).toBeTypeOf('function') const result = await h.manager.writeBlockRecordFactCurrent(h.url, key, 'Friday', { nodeId: fresh, typeName: 'task' }, () => true) expect(result.changed).toBe(true) await h.controller.applyExternalContribution({ documentId: h.url, beforeHeads: result.beforeHeads, heads: result.heads, beforeContent: result.beforeContent, afterContent: result.afterContent, metadataChanges: [result.factChange!], } as never) release?.() expect(await h.manager.readBlockFacts(h.url)).toMatchObject({ [key]: 'Friday' })
expect(undo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners.map(owner => owner.nodeId)).toEqual([original, fresh])
expect(undo(h.view)).toBe(true) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe(source) expect(h.manager.getTextFirstDocumentState(h.url).lineOwners).toHaveLength(1) } finally { await cleanup(h) } })
it('joins through real CM deletion with destination facts winning and source-only facts transferring', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const content = 'left #task\n\nright #task #project' const url = manager.createFile('paragraph-join.md', undefined, content) await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task', 'project']) const handle = await manager.findFileHandle(url) handle!.change(draft => { const blocks = inspectRichText(draft as A.Doc<any>, { path: ['content'] }).blocks() const ids = ['left-node', 'right-node'] for (const [index, span] of blocks.entries()) { const value = span.markerValue as any A.updateBlock(draft as A.Doc<any>, ['content'], span.richIndex, { ...value, attrs: { ...value.attrs, 'reams.nodeId': ids[index] }, opaquePayload: { node: ids[index] } }) } ;(draft as Record<string, unknown>)[encodeBlockFactKey('left-node', 'task', 'due')] = 'Friday' ;(draft as Record<string, unknown>)[encodeBlockFactKey('right-node', 'task', 'due')] = 'Monday' ;(draft as Record<string, unknown>)[encodeBlockFactKey('right-node', 'project', 'title')] = 'Roadmap' }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task', 'project'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const view = new EditorView({ state: EditorState.create({ doc: content, extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))] }) }) controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ changes: { from: 11, to: 12, insert: '' } }) const joined = manager.getTextFirstDocumentState(url) expect(joined.content).toBe('left #task\nright #task #project') expect(joined.nodeIndex.status).toBe('available') if (joined.nodeIndex.status === 'available') expect(joined.nodeIndex.index.nodes.map(node => node.id)).toEqual(['left-node']) const joinedFacts = await manager.readBlockFactsAt(url, joined.heads) expect(joinedFacts?.[encodeBlockFactKey('left-node', 'task', 'due')]).toBe('Friday') expect(joinedFacts?.[encodeBlockFactKey('left-node', 'project', 'title')]).toBe('Roadmap') expect(joinedFacts?.[encodeBlockFactKey('right-node', 'task', 'due')]).toBeUndefined() expect(undo(view)).toBe(true) const undone = manager.getTextFirstDocumentState(url) expect(undone.content).toBe(content) expect(undone.nodeIndex.status).toBe('available') if (undone.nodeIndex.status === 'available') expect(undone.nodeIndex.index.nodes.map(node => node.id)).toEqual(['left-node', 'right-node']) const undoneBlocks = A.spans(handle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block') expect((undoneBlocks[1]!.value as any).opaquePayload).toEqual({ node: 'right-node' }) const undoneFacts = await manager.readBlockFactsAt(url, undone.heads) expect(undoneFacts?.[encodeBlockFactKey('left-node', 'task', 'due')]).toBe('Friday') expect(undoneFacts?.[encodeBlockFactKey('right-node', 'task', 'due')]).toBe('Monday') expect(undoneFacts?.[encodeBlockFactKey('right-node', 'project', 'title')]).toBe('Roadmap') expect(redo(view)).toBe(true) const redone = manager.getTextFirstDocumentState(url) expect(redone.nodeIndex.status).toBe('available') if (redone.nodeIndex.status === 'available') expect(redone.nodeIndex.index.nodes.map(node => node.id)).toEqual(['left-node']) } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('transfers a typed source-only fact to a fresh ordinary destination through real CM join undo and redo', async () => { const source = 'ordinary\n\ntyped #task' const h = await createHarness(source, ['task']) try { const initial = h.manager.getTextFirstDocumentState(h.url) const sourceOwner = initial.lineOwners[0] if (!sourceOwner) throw new Error('expected the typed source owner') const sourceKey = encodeBlockFactKey(sourceOwner.nodeId, 'task', 'priority') await writeFactThroughController(h, sourceKey, 'high', sourceOwner.nodeId) h.view.dispatch({ changes: { from: 'ordinary'.length, to: 'ordinary\n\n'.length, insert: '' } }) const joined = h.manager.getTextFirstDocumentState(h.url) expect(joined.content).toBe('ordinarytyped #task') const joinedOwner = joined.lineOwners[0]! const joinedKey = encodeBlockFactKey(joinedOwner.nodeId, 'task', 'priority') expect((await h.manager.readBlockFacts(h.url))?.[joinedKey]).toBe('high') expect(undo(h.view)).toBe(true) expect((await h.manager.readBlockFacts(h.url))?.[sourceKey]).toBe('high') expect(redo(h.view)).toBe(true) expect((await h.manager.readBlockFacts(h.url))?.[joinedKey]).toBe('high') } finally { await cleanup(h) } })
it('joins the middle pair of three paragraphs through persistence and restores it on undo', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const content = 'first #task\n\nsecond #task\n\nthird #project' const url = manager.createFile('middle-join.md', undefined, content) await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task', 'project']) const handle = await manager.findFileHandle(url) handle!.change(draft => { const blocks = inspectRichText(draft as A.Doc<any>, { path: ['content'] }).blocks() const ids = ['first-node', 'second-node', 'third-node'] for (const [index, block] of blocks.entries()) { const value = block.markerValue as any A.updateBlock(draft as A.Doc<any>, ['content'], block.richIndex, { ...value, attrs: { ...value.attrs, 'reams.nodeId': ids[index] }, opaquePayload: { node: ids[index], counter: new A.Counter(index + 1), raw: new A.RawString(`raw-${index}`) } }) } ;(draft as Record<string, unknown>)[encodeBlockFactKey('first-node', 'task', 'due')] = 'Friday' ;(draft as Record<string, unknown>)[encodeBlockFactKey('second-node', 'task', 'due')] = 'Monday' ;(draft as Record<string, unknown>)[encodeBlockFactKey('third-node', 'project', 'title')] = 'Roadmap' }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task', 'project'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const view = new EditorView({ state: EditorState.create({ doc: content, extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))] }) }) controller.bindView(view, createEditorBindingOwnership()) try { const thirdStart = content.indexOf('third') view.dispatch({ changes: { from: thirdStart - 1, to: thirdStart, insert: '' } }) const joined = manager.getTextFirstDocumentState(url) expect(joined.content).toBe('first #task\n\nsecond #task\nthird #project') expect(joined.nodeIndex.status).toBe('available') if (joined.nodeIndex.status === 'available') expect(joined.nodeIndex.index.nodes.map(node => node.id)).toEqual(['first-node', 'second-node']) const joinedFacts = await manager.readBlockFactsAt(url, joined.heads) expect(joinedFacts?.[encodeBlockFactKey('second-node', 'task', 'due')]).toBe('Monday') expect(joinedFacts?.[encodeBlockFactKey('second-node', 'project', 'title')]).toBe('Roadmap') expect(joinedFacts?.[encodeBlockFactKey('third-node', 'project', 'title')]).toBeUndefined() expect(undo(view)).toBe(true) const undone = manager.getTextFirstDocumentState(url) expect(undone.content).toBe(content) if (undone.nodeIndex.status === 'available') expect(undone.nodeIndex.index.nodes.map(node => node.id)).toEqual(['first-node', 'second-node', 'third-node']) const blocks = A.spans(handle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block') const payload = (blocks[2]!.value as any).opaquePayload expect(payload.raw.val).toBe('raw-2') expect(payload.counter.value).toBe(3) await manager.flushTextFirstDocument(url) const reloadedManager = new DocumentManager(repo) try { await reloadedManager.prepareTextFirstDocument(url) const reloadedHandle = await reloadedManager.findFileHandle(url) const reloadedBlocks = A.spans(reloadedHandle!.doc() as A.Doc<any>, ['content']).filter(span => span.type === 'block') const reloadedPayload = (reloadedBlocks[2]!.value as any).opaquePayload expect(reloadedPayload.raw.val).toBe('raw-2') expect(reloadedPayload.counter.value).toBe(3) } finally { reloadedManager.dispose() } } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('saves equal-text moves with distinct facts and follows the selected task after redo', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const source = '- same #task\n- same #task\n\nplain' const url = manager.createFile('equal-move.md', undefined, source) await manager.prepareTextFirstDocument(url) await expect(manager.initializeTextFirstLineOwners(url, ['task'])).resolves.toBe('applied') const initialized = manager.getTextFirstDocumentState(url) if (initialized.nodeIndex.status !== 'available') throw new Error('Expected initialized AST index') const taskNodes = initialized.nodeIndex.index.nodes.filter(node => node.kind === 'list-item') expect(taskNodes).toHaveLength(2) const firstTask = taskNodes[0]! const secondTask = taskNodes[1]! const handle = await manager.findFileHandle(url) handle!.change(draft => { ;(draft as Record<string, unknown>)[encodeBlockFactKey(firstTask.id, 'task', 'due')] = 'tomorrow' ;(draft as Record<string, unknown>)[encodeBlockFactKey(secondTask.id, 'task', 'due')] = 'next-week' }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const view = new EditorView({ state: EditorState.create({ doc: source, extensions: [history(), lineAttachmentExtension, keymap.of([ { key: 'Alt-ArrowUp', run: lineAttachmentMoveLineUp }, { key: 'Alt-ArrowDown', run: lineAttachmentMoveLineDown }, ]), EditorView.updateListener.of(update => controller.onDocumentUpdate(update))], }), }) controller.bindView(view, createEditorBindingOwnership()) try { view.focus() view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true })) expect(manager.getTextFirstDocumentState(url).content).toBe(source) const moved = manager.getTextFirstDocumentState(url) expect(moved.lineOwners.map(owner => owner.nodeId).filter(id => id === firstTask.id || id === secondTask.id)).toEqual([secondTask.id, firstTask.id]) expect((await manager.readBlockFactsAt(url, moved.heads))?.[encodeBlockFactKey(firstTask.id, 'task', 'due')]).toBe('tomorrow') expect((await manager.readBlockFactsAt(url, moved.heads))?.[encodeBlockFactKey(secondTask.id, 'task', 'due')]).toBe('next-week') expect(undo(view)).toBe(true) expect(redo(view)).toBe(true)
} finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it.each(['\r\n', '\r'])('keeps an initialized task attached across %j coordinates, punctuation, and history', async newline => { const repo = new Repo({}) const manager = new DocumentManager(repo) const source = ['😀lead', '', 'Buy milk #task'].join(newline) const url = manager.createFile('mixed.md', undefined, source) await manager.prepareTextFirstDocument(url) await expect(manager.initializeTextFirstLineOwners(url, ['task'])).resolves.toBe('applied') const initialized = manager.getTextFirstDocumentState(url) if (initialized.nodeIndex.status !== 'available') throw new Error('Expected initialized AST index') const taskNode = initialized.nodeIndex.index.nodes.find(node => node.from > 0) if (!taskNode) throw new Error('Expected task AST node') const handle = await manager.findFileHandle(url) handle!.change(draft => { ;(draft as Record<string, unknown>)[encodeBlockFactKey(taskNode.id, 'task', 'due')] = 'tomorrow' }) const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'] }) controller.start() await vi.waitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) const initial = manager.getTextFirstDocumentState(url) const task = initial.lineOwners.find(owner => owner.nodeId === taskNode.id) expect(task).toBeDefined() await tick() const view = new EditorView({ state: EditorState.create({ doc: '😀lead\n\nBuy milk #task', extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))], }), }) controller.bindView(view, createEditorBindingOwnership()) try { view.dispatch({ changes: { from: view.state.doc.toString().indexOf(' milk'), insert: '\n' } }) view.dispatch({ changes: { from: view.state.doc.toString().indexOf('#task'), insert: '! ' } }) await controller.flushPendingPlain() expect(manager.getTextFirstDocumentState(url).content).toBe(['😀lead', '', 'Buy', ' milk ! #task'].join(newline)) expect(manager.getTextFirstDocumentState(url).lineOwners.map(owner => owner.nodeId)).toContain(task!.nodeId) expect((await manager.readBlockFactsAt(url, manager.getTextFirstDocumentState(url).heads))?.[encodeBlockFactKey(task!.nodeId, 'task', 'due')]).toBe('tomorrow') expect(undo(view)).toBe(true) expect(undo(view)).toBe(true) expect(manager.getTextFirstDocumentState(url).content).toBe(source) expect(redo(view)).toBe(true) expect(redo(view)).toBe(true) expect(manager.getTextFirstDocumentState(url).content).toBe(['😀lead', '', 'Buy', ' milk ! #task'].join(newline)) } finally { view.destroy() await controller.dispose() manager.dispose() await repo.shutdown() } })
it('preserves mixed CRLF, lone CR, emoji, and newline edits in source coordinates', async () => { const source = 'a\r\nb\rc\n😀d' const h = await createHarness(source) try { expect(h.view.state.doc.toString()).toBe('a\nb\nc\n😀d') h.view.dispatch({ changes: { from: 2, to: 3, insert: 'B\n😀' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('a\r\nB\r\n😀\rc\n😀d') h.view.dispatch({ changes: { from: 3, to: 4, insert: '' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('a\r\nB😀\rc\n😀d') } finally { await cleanup(h) } })
it('does not create a change for a no-op and accepts the next edit', async () => { const h = await createHarness('same') try { const before = await changeHashes(h.manager, h.url) h.view.dispatch({ changes: { from: 0, to: 4, insert: 'same' } }) expect(await changeHashes(h.manager, h.url)).toEqual(before) h.view.dispatch({ changes: { from: 4, insert: '!' } }) expect((await changeHashes(h.manager, h.url)).size).toBe(before.size + 1) expect(h.view.state.doc.toString()).toBe('same!') } finally { await cleanup(h) } })
it('records metadata contributions as effect-only history without docChanged', async () => { const h = await createHarness('ordinary #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', 'done') try { const release = await h.controller.prepareExternalWrite() expect(release).toBeTypeOf('function') const result = await h.manager.writeBlockRecordFactCurrent(h.url, key, true, { nodeId, typeName: 'task' }, () => true) const dispatch = vi.spyOn(h.view, 'dispatch') await h.controller.applyExternalContribution({ documentId: h.url, beforeHeads: result.beforeHeads, heads: result.heads, beforeContent: result.beforeContent, afterContent: result.afterContent, metadataChanges: [result.factChange!], } as never) release?.() const metadataSpec = dispatch.mock.calls.map(call => call[0]).find(spec => typeof spec === 'object' && spec !== null && 'effects' in spec && !('changes' in spec)) expect(metadataSpec).toBeDefined() expect(h.view.state.doc.toString()).toBe('ordinary #task') expect(undo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) } finally { await cleanup(h) } })
it('restores an absent task fact through the real Record Done command and CM undo', async () => { const h = await createHarness('- [ ] Alpha #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const doneKey = encodeBlockFactKey(nodeId, 'task', 'done') const priorityKey = encodeBlockFactKey(nodeId, 'task', 'priority') const type = { path: '/types/task.json', schema: { version: 1 as const, name: 'Task', fields: { done: { key: 'done', name: 'Done', kind: 'boolean' as const }, priority: { key: 'priority', name: 'Priority', kind: 'text' as const }, }, fieldOrder: ['done', 'priority'], TaskListItem: 'done', }, revision: { key: 'task' }, } try { await writeFactThroughController(h, priorityKey, 'high', nodeId) const release = await h.controller.prepareExternalWrite() expect(release).toBeTypeOf('function') const outcome = await setRecordField({ documents: h.manager, roots: new RootFrontmatterRecords({ documents: h.manager }), catalog: { resolveType: async () => type } as never, documentId: h.url, documentPath: '/note.md', enabled: true, intent: { subject: { kind: 'block', documentId: h.url, nodeId }, typeName: 'task', fieldId: 'done', value: true }, }) expect(outcome.status).toBe('accepted') if (outcome.status !== 'accepted' || !outcome.textContribution) throw new Error('expected accepted Record Done contribution') await h.controller.applyExternalContribution(outcome.textContribution) release?.()
expect(h.view.state.doc.toString()).toBe('- [x] Alpha #task') expect(await h.manager.readBlockFacts(h.url)).toMatchObject({ [doneKey]: true, [priorityKey]: 'high' }) expect(undo(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe('- [ ] Alpha #task') expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- [ ] Alpha #task') const undoneFacts = await h.manager.readBlockFacts(h.url) expect(undoneFacts).not.toHaveProperty(doneKey) expect(undoneFacts).toHaveProperty(priorityKey, 'high') } finally { await cleanup(h) } })
it.each(['status', 'done'] as const)('records a real %s field clear in CM undo and redo', async fieldId => { const content = fieldId === 'done' ? '- [ ] Alpha #task' : 'Alpha #task' const h = await createHarness(content, ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', fieldId) const otherKey = encodeBlockFactKey(nodeId, 'task', 'priority') const type = { path: '/types/task.json', schema: { version: 1 as const, name: 'Task', fields: { status: { key: 'status', name: 'Status', kind: 'text' as const }, done: { key: 'done', name: 'Done', kind: 'boolean' as const } }, fieldOrder: ['status', 'done'], TaskListItem: 'done', }, revision: { key: 'task' }, } const write = async (value: string | boolean | undefined) => { const release = await h.controller.prepareExternalWrite() expect(release).toBeTypeOf('function') try { const outcome = await setRecordField({ documents: h.manager, roots: new RootFrontmatterRecords({ documents: h.manager }), catalog: { resolveType: async () => type } as never, documentId: h.url, documentPath: '/note.md', enabled: true, intent: { subject: { kind: 'block', documentId: h.url, nodeId }, typeName: 'task', fieldId, value }, }) expect(outcome.status).toBe('accepted') if (outcome.status !== 'accepted' || !outcome.textContribution) throw new Error('expected contribution') await h.controller.applyExternalContribution(outcome.textContribution) } finally { release?.() } await tick() } try { await writeFactThroughController(h, otherKey, 'high') const value = fieldId === 'done' ? true : 'Monday' await write(value) const populated = h.view.state.doc.toString() await write(undefined) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) expect(h.view.state.doc.toString()).toBe(content) expect(undo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, value) expect(h.view.state.doc.toString()).toBe(populated) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(otherKey, 'high') expect(redo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).not.toHaveProperty(key) expect(h.view.state.doc.toString()).toBe(content) if (fieldId === 'status') { await applyRemoteFact(h, key, 'Tuesday', 'remote-after-clear') expect(undo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Tuesday') expect(redo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Tuesday') } } finally { await cleanup(h) } })
it('reconciles canonical advancement when a reserved contribution becomes stale', async () => { const h = await createHarness('ordinary #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const firstKey = encodeBlockFactKey(nodeId, 'task', 'first') const secondKey = encodeBlockFactKey(nodeId, 'task', 'second') try { const release = await h.controller.prepareExternalWrite() expect(release).toBeTypeOf('function')
const first = await h.manager.writeBlockRecordFactCurrent(h.url, firstKey, 'one', { nodeId, typeName: 'task' }, () => true) const second = await h.manager.writeBlockRecordFactCurrent(h.url, secondKey, 'two', { nodeId, typeName: 'task' }, () => true) // The first callback is now stale because canonical state has already // advanced through the second native write. It must not strand CM on // the reservation frontier when the external command releases it. await h.controller.applyExternalContribution({ documentId: h.url, beforeHeads: first.beforeHeads, heads: first.heads, beforeContent: first.beforeContent, afterContent: first.afterContent, metadataChanges: [first.factChange!], } as never) release?.()
expect(h.controller.getSnapshot().checkpoint?.heads).toEqual(second.heads) expect(() => h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: '!' } })).not.toThrow() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary #task!') } finally { await cleanup(h) } })
it('preserves a remote field replacement across CM undo and redo', async () => { const h = await createHarness('ordinary #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', 'status') try { await writeFactThroughController(h, key, 'Monday') await writeFactThroughController(h, key, 'Friday') await applyRemoteFact(h, key, 'Tuesday', 'remote-replacement-actor') expect(undo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Tuesday') expect(redo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Tuesday') expect(h.controller.getAttachmentDiagnostic()).toContain('another actor') } finally { await cleanup(h) } })
it('invalidates a field origin through a remote equality cycle while ignoring another field', async () => { const h = await createHarness('ordinary #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', 'status') const otherKey = encodeBlockFactKey(nodeId, 'task', 'other') try { await writeFactThroughController(h, key, 'Monday') await writeFactThroughController(h, key, 'Friday') await applyRemoteFact(h, otherKey, 'unrelated', 'remote-unrelated-actor') await applyRemoteFact(h, key, 'Friday', 'remote-cycle-actor') await applyRemoteFact(h, key, 'Tuesday', 'remote-cycle-actor') await applyRemoteFact(h, key, 'Friday', 'remote-cycle-actor') expect(undo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Friday') expect(redo(h.view)).toBe(true) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Friday') } finally { await cleanup(h) } })
it('tracks nested JSON object IDs and preserves a remote descendant edit', async () => { const h = await createHarness('ordinary #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', 'json') try { await writeFactThroughController(h, key, { label: 'Monday', nested: { state: 'local' } }) await writeFactThroughController(h, key, { label: 'Friday', nested: { state: 'local' } }) await applyRemoteNestedFact(h, key, 'remote-nested-actor') expect(undo(h.view)).toBe(true) expect((await h.manager.readBlockFacts(h.url) as Record<string, any>)[key].nested.state).toBe('remote') expect(redo(h.view)).toBe(true) expect((await h.manager.readBlockFacts(h.url) as Record<string, any>)[key].nested.state).toBe('remote') } finally { await cleanup(h) } })
it('retires the empty root list item while preserving its child through Enter exit', async () => { const h = await createHarness('- \n - child #task', ['task'], true) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') const child = before.nodeIndex.index.nodes.find(node => node.from > 0) expect(child).toBeDefined() if (!child) throw new Error('expected child node') const factKey = encodeBlockFactKey(child.id, 'task', 'done') const handle = await h.manager.findFileHandle(h.url) handle!.change(draft => { ;(draft as Record<string, unknown>)[factKey] = true }) await tick()
h.view.dispatch({ selection: { anchor: 0 } }) expect(handleEnterKey(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('\n - child #task') const after = h.manager.getTextFirstDocumentState(h.url) expect(after.nodeIndex.status).toBe('available') if (after.nodeIndex.status !== 'available') throw new Error('expected an indexed document after Enter') expect(after.nodeIndex.index.getNode(child.id)).toBeDefined() expect(after.nodeIndex.index.getNode(child.id)?.from).toBe(1) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(factKey, true)
expect(undo(h.view)).toBe(true) await tick() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('- \n - child #task') expect(h.manager.getTextFirstDocumentState(h.url).nodeIndex.status).toBe('available') expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(factKey, true) expect(redo(h.view)).toBe(true) await tick() const redone = h.manager.getTextFirstDocumentState(h.url) expect(redone.content).toBe('\n - child #task') expect(redone.nodeIndex.status).toBe('available') if (redone.nodeIndex.status === 'available') expect(redone.nodeIndex.index.getNode(child.id)).toBeDefined() expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(factKey, true) } finally { await cleanup(h) } })
it('keeps an ordinary horizontal rule marker-free while editing adjacent text', async () => { const h = await createHarness('***\n\nitem #task', ['task']) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') expect(lineAttachmentAstExtents(h.view.state)).toHaveLength(1) h.view.dispatch({ changes: { from: 3, to: 3, insert: ' + ' } }) await tick() expect(h.view.state.doc.toString()).toBe('*** + \n\nitem #task') const after = h.manager.getTextFirstDocumentState(h.url) expect(after.nodeIndex.status).toBe('available') if (after.nodeIndex.status === 'available') expect(after.nodeIndex.index.nodes[0]?.kind).toBe('paragraph') expect(lineAttachmentAstExtents(h.view.state)).toHaveLength(1)
expect(undo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('***\n\nitem #task') expect(redo(h.view)).toBe(true) await tick() expect(h.view.state.doc.toString()).toBe('*** + \n\nitem #task') } finally { await cleanup(h) } })
it('keeps ordinary rule and paragraph syntax separate from Type ownership', async () => { const hr = await createHarness(' ***\n\nitem #task', ['task']) try { const before = hr.manager.getTextFirstDocumentState(hr.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') hr.view.dispatch({ changes: { from: 5, to: 5, insert: ' + ' } }) await tick() const paragraph = hr.manager.getTextFirstDocumentState(hr.url) expect(paragraph.nodeIndex.status).toBe('available') expect(lineAttachmentAstExtents(hr.view.state)).toHaveLength(1) } finally { await cleanup(hr) }
const paragraph = await createHarness(' --\n\nitem #task', ['task']) try { const before = paragraph.manager.getTextFirstDocumentState(paragraph.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') paragraph.view.dispatch({ changes: { from: 1, to: 1, insert: '-' } }) await tick() const horizontalRule = paragraph.manager.getTextFirstDocumentState(paragraph.url) expect(horizontalRule.nodeIndex.status).toBe('available') expect(lineAttachmentAstExtents(paragraph.view.state)).toHaveLength(1) } finally { await cleanup(paragraph) } })
it('does not retire a list owner when only part of its marker is deleted', async () => { const h = await createHarness('- item #task', ['task']) try { const before = h.manager.getTextFirstDocumentState(h.url) const owner = before.lineOwners[0]! const factKey = encodeBlockFactKey(owner.nodeId, 'task', 'payload') await writeFactThroughController(h, factKey, 'partial') h.view.dispatch({ changes: { from: 0, to: 1, insert: '' } }) await tick() const handle = await h.manager.findFileHandle(h.url) expect(A.block(handle!.doc() as A.Doc<any>, ['content'], 0)).toMatchObject({ attrs: { 'reams.nodeId': owner.nodeId } }) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(factKey, 'partial') } finally { await cleanup(h) } })
it.each(['', ' '])('allocates a fresh setext owner when populating blank line %j above an empty list', async blank => { const content = `${blank}\n- \n- parent #task\n\nalpha μ\n\nalpha μ` const h = await createHarness(content, ['task']) try { const before = h.manager.getTextFirstDocumentState(h.url) expect(before.nodeIndex.status).toBe('available') if (before.nodeIndex.status !== 'available') throw new Error('expected an indexed document') const parent = before.nodeIndex.index.nodes.find(node => node.from === blank.length + 4) expect(parent).toBeDefined() if (!parent) throw new Error('expected parent owner') const parentFact = encodeBlockFactKey(parent.id, 'task', 'payload') const handle = await h.manager.findFileHandle(h.url) handle!.change(draft => { const record = draft as Record<string, unknown> record[parentFact] = 'parent' }) await tick()
h.view.dispatch({ changes: { from: 0, to: blank.length, insert: 'replaced' }, annotations: Transaction.userEvent.of('input.replace'), }) await tick() const after = h.manager.getTextFirstDocumentState(h.url) expect(after.content).toBe('replaced\n- \n- parent #task\n\nalpha μ\n\nalpha μ') expect(after.nodeIndex.status).toBe('available') if (after.nodeIndex.status !== 'available') throw new Error('expected an indexed document after replacement') expect(after.nodeIndex.index.getNode(parent.id)).toBeDefined() expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(parentFact, 'parent')
expect(undo(h.view)).toBe(true) await tick() const undone = h.manager.getTextFirstDocumentState(h.url) expect(undone.content).toBe(content) expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(parentFact, 'parent')
expect(redo(h.view)).toBe(true) await tick() const redone = h.manager.getTextFirstDocumentState(h.url) expect(redone.content).toBe(after.content) expect(redone.nodeIndex.status).toBe('available') expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(parentFact, 'parent') } finally { await cleanup(h) } })
it('leaves raw cross-actor fact conflicts untouched when undo is consumed', async () => { const h = await createHarness('ordinary #task', ['task']) const nodeId = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]!.nodeId const key = encodeBlockFactKey(nodeId, 'task', 'status') try { await writeFactThroughController(h, key, 'Monday') const beforeFriday = h.manager.getTextFirstDocumentState(h.url).heads await writeFactThroughController(h, key, 'Friday') await applyConcurrentRemoteFact(h, beforeFriday, key, 'Tuesday', 'remote-conflict-actor') const handle = await h.manager.findFileHandle(h.url) if (!handle) throw new Error('missing document handle') const before = A.getConflicts(handle.doc() as A.Doc<Record<string, unknown>>, key as never) expect(undo(h.view)).toBe(true) const after = A.getConflicts(handle.doc() as A.Doc<Record<string, unknown>>, key as never) expect(after).toEqual(before) } finally { await cleanup(h) } })
it('flushes a committed clipboard mutation after an observer failure without replaying it', async () => { const h = await createHarness('ordinary text') const original = h.manager.applyTextFirstTransaction.bind(h.manager) const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction').mockImplementation((url, heads, transaction, origin) => { original(url, heads, transaction, origin) throw new CommittedObserverError('injected notification failure') }) const flush = vi.spyOn(h.manager, 'flushTextFirstDocument').mockResolvedValue(undefined) try { const commit = h.controller.prepareClipboardTransaction(h.view, { changes: { from: h.view.state.doc.length, insert: '!' } }) expect(commit).toBeTypeOf('function') commit?.() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary text!') expect(h.view.state.doc.toString()).toBe('ordinary text!') expect(h.controller.getAttachmentDiagnostic()).toContain('committed') await h.controller.flushPendingPlain() expect(flush).toHaveBeenCalled() expect(apply).toHaveBeenCalledOnce() } finally { await cleanup(h) } })
it('dispatches the exact transaction prepared for a clipboard commit', async () => { const h = await createHarness('ordinary text') let filterCalls = 0 h.view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.transactionFilter.of(transaction => { filterCalls += 1 return filterCalls === 1 ? transaction : { changes: { from: transaction.newDoc.length, insert: '?' } } })) }) try { const commit = h.controller.prepareClipboardTransaction(h.view, { changes: { from: h.view.state.doc.length, insert: '!' }, }) expect(commit).toBeTypeOf('function') commit?.() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary text!') expect(h.view.state.doc.toString()).toBe('ordinary text!') expect(filterCalls).toBe(1) expect(undo(h.view)).toBe(true) expect(h.view.state.doc.toString()).toBe('ordinary text') expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary text') } finally { await cleanup(h) } })
it('recovers canonical clipboard content when the captured start state becomes stale', async () => { const h = await createHarness('ordinary text') const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction') try { const commit = h.controller.prepareClipboardTransaction(h.view, { changes: { from: h.view.state.doc.length, insert: '!' }, }) expect(commit).toBeTypeOf('function') h.controller.bindView(null) commit?.() expect(apply).toHaveBeenCalledOnce() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary text!') h.controller.bindView(h.view, createEditorBindingOwnership()) expect(h.view.state.doc.toString()).toBe('ordinary text!') expect(h.controller.getAttachmentDiagnostic()).toContain('committed') } finally { await cleanup(h) } })
it('keeps a prepared fact-bearing clipboard contribution canonical when selection changes, and commits it once', async () => { const h = await createHarness('ordinary #task', ['task']) const owner = h.manager.getTextFirstDocumentState(h.url).lineOwners[0]! const key = encodeBlockFactKey(owner.nodeId, 'task', 'status') const apply = vi.spyOn(h.manager, 'applyTextFirstTransaction') try { const transaction = h.view.state.update({ changes: { from: h.view.state.doc.length, insert: ' copied' }, effects: markerDeltaEffect.of({ documentId: h.url, bindingToken: lineAttachmentBindingToken(h.view.state), before: [], after: [], facts: [{ key, before: undefined, after: 'Friday' }], }), }) const commit = h.controller.prepareClipboardTransaction(h.view, transaction) expect(commit).toBeTypeOf('function') h.view.dispatch({ selection: { anchor: 0 } }) commit?.() commit?.()
expect(apply).toHaveBeenCalledOnce() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('ordinary #task copied') expect(await h.manager.readBlockFacts(h.url)).toHaveProperty(key, 'Friday') expect(h.view.state.doc.toString()).toBe('ordinary #task copied') expect(lineAttachmentAstExtents(h.view.state).find(extent => extent.nodeId === owner.nodeId)?.facts).toContainEqual({ key, value: 'Friday' }) expect(await h.controller.flushPendingPlain()).toBe(true) } finally { apply.mockRestore(); await cleanup(h) } })
it('does not let an old clipboard callback mutate the switched document controller lifecycle', async () => { const h = await createHarness('old document') const oldHandle = await h.manager.findFileHandle(h.url) const oldFlush = vi.spyOn(h.manager, 'flushTextFirstDocument').mockResolvedValue(undefined) const secondUrl = h.manager.createFile('clipboard-second.md', undefined, 'new document') try { const commit = h.controller.prepareClipboardTransaction(h.view, { changes: { from: 12, insert: '!' } }) expect(commit).toBeTypeOf('function') await h.controller.updateOptions({ documentManager: h.manager, docUrl: secondUrl }) const flushCountAfterSwitch = oldFlush.mock.calls.length h.controller.start() await vi.waitFor(() => expect(h.controller.getSnapshot().loadPhase).toBe('ready')) const secondView = new EditorView({ state: EditorState.create({ doc: 'new document', extensions: [lineAttachmentExtension, EditorView.updateListener.of(update => h.controller.onDocumentUpdate(update))] }), }) h.controller.bindView(secondView, createEditorBindingOwnership()) const beforeStatus = h.controller.getSnapshot().saveStatus const beforeHeads = h.manager.getTextFirstDocumentState(secondUrl).heads commit?.() expect(visibleTextFromDoc(oldHandle!.doc())).toBe('old document!') expect(h.manager.getTextFirstDocumentState(secondUrl).content).toBe('new document') expect(h.manager.getTextFirstDocumentState(secondUrl).heads).toEqual(beforeHeads) expect(secondView.state.doc.toString()).toBe('new document') expect(h.controller.getSnapshot().saveStatus).toEqual(beforeStatus) expect(oldFlush).toHaveBeenCalledTimes(flushCountAfterSwitch) secondView.destroy() } finally { oldFlush.mockRestore(); await cleanup(h) } })
it('does not apply a pending metadata contribution after the binding becomes stale', async () => { const h = await createHarness('ordinary #task', ['task']) const release = await h.controller.prepareExternalWrite() const state = h.manager.getTextFirstDocumentState(h.url) const nodeId = state.lineOwners[0]?.nodeId ?? 'missing' const result = await h.manager.writeBlockRecordFactCurrent(h.url, encodeBlockFactKey(nodeId, 'task', 'done'), true, { nodeId, typeName: 'task' }, () => true) let unblock!: () => void const gate = new Promise<void>(resolve => { unblock = resolve }) const readFacts = vi.spyOn(h.manager, 'readBlockFacts').mockImplementation(async () => { await gate; return {} }) try { const pending = h.controller.applyExternalContribution({ documentId: h.url, beforeHeads: result.beforeHeads, heads: result.heads, beforeContent: result.beforeContent, afterContent: result.afterContent, metadataChanges: [result.factChange!], } as never) await Promise.resolve() h.controller.bindView(null) unblock() await pending expect(readFacts).toHaveBeenCalledOnce() } finally { release?.() readFacts.mockRestore() await cleanup(h) } })
it('clears production CM history when switching documents', async () => { const h = await createHarness('old') const secondUrl = h.manager.createFile('history-second.md', undefined, 'new') const productionView = new EditorView({ state: EditorState.create({ doc: 'old', extensions: [textFirstHistoryCompartment.of(history()), lineAttachmentExtension, EditorView.updateListener.of(update => h.controller.onDocumentUpdate(update))], }) }) h.view.destroy() h.controller.bindView(productionView, createEditorBindingOwnership()) try { productionView.dispatch({ changes: { from: 3, insert: '!' } }) await h.controller.updateOptions({ documentManager: h.manager, docUrl: secondUrl }) h.controller.start() await vi.waitFor(() => expect(h.controller.getSnapshot().loadPhase).toBe('ready')) h.controller.bindView(productionView, createEditorBindingOwnership()) expect(undo(productionView)).toBe(false) } finally { productionView.destroy() await h.controller.dispose() h.manager.dispose() await h.repo.shutdown() } })
it('projects an external manager write and maps the cursor without echoing it', async () => { const h = await createHarness('hello world') try { h.view.dispatch({ selection: { anchor: 8 } }) const before = await changeHashes(h.manager, h.url) await h.manager.updateContent(h.url, 'hello brave world') await tick() expect(h.view.state.doc.toString()).toBe('hello brave world') expect(h.view.state.selection.main.head).toBe(14) expect((await changeHashes(h.manager, h.url)).size).toBe(before.size + 1) h.view.dispatch({ changes: { from: h.view.state.doc.length, insert: '!' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('hello brave world!') } finally { await cleanup(h) } })
it('coalesces multiple external updates before the next local dispatch', async () => { const h = await createHarness('a') try { await h.manager.updateContent(h.url, 'ab') await h.manager.updateContent(h.url, 'abc') await tick() expect(h.view.state.doc.toString()).toBe('abc') h.view.dispatch({ changes: { from: 3, insert: '!' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('abc!') } finally { await cleanup(h) } })
it('retains editor input after a flush failure and retries without another change', async () => { const h = await createHarness('a') try { const flush = vi.spyOn(h.manager, 'flushTextFirstDocument').mockRejectedValueOnce(new Error('offline')) h.view.dispatch({ changes: { from: 1, insert: 'b' } }) expect(await h.controller.flushPendingPlain()).toBe(false) expect(h.view.state.doc.toString()).toBe('ab') const beforeRetry = await changeHashes(h.manager, h.url) flush.mockRestore() expect(await h.controller.flushPendingPlain()).toBe(true) expect(await changeHashes(h.manager, h.url)).toEqual(beforeRetry) } finally { await cleanup(h) } })
it('does not resolve overlapping flush requests before the repo gate', async () => { const h = await createHarness('a') try { let release!: () => void const gate = new Promise<void>(resolve => { release = resolve }) const flush = vi.spyOn(h.repo, 'flush').mockImplementation(async () => gate) h.view.dispatch({ changes: { from: 1, insert: 'b' } }) let firstDone = false let secondDone = false const first = h.controller.flushPendingPlain().then(() => { firstDone = true }) h.view.dispatch({ changes: { from: 2, insert: 'c' } }) expect(h.controller.getSnapshot().content).toBe('abc') const second = h.controller.flushPendingPlain().then(() => { secondDone = true }) await Promise.resolve() expect(firstDone).toBe(false) expect(secondDone).toBe(false) release() await Promise.all([first, second]) expect(flush).toHaveBeenCalled() expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('abc') expect(h.manager.getTextFirstProgress(h.url).materializedRevision).toBe(2) expect(h.manager.getTextFirstProgress(h.url).durableRevision).toBe(2) } finally { await cleanup(h) } })
it('ignores completion of an old document flush after switching documents', async () => { const h = await createHarness('old') const secondUrl = h.manager.createFile('second.md', undefined, 'new') try { let release!: () => void const gate = new Promise<void>(resolve => { release = resolve }) vi.spyOn(h.repo, 'flush').mockImplementation(async () => gate) h.view.dispatch({ changes: { from: 3, insert: '!' } }) const switching = h.controller.updateOptions({ documentManager: h.manager, docUrl: secondUrl }) release() await switching h.controller.start() await vi.waitFor(() => expect(h.controller.getSnapshot().loadPhase).toBe('ready')) h.controller.bindView(h.view, createEditorBindingOwnership()) await tick() expect(h.controller.getSnapshot().content).toBe('new') expect(h.view.state.doc.toString()).toBe('new') } finally { await cleanup(h) } })
it('supports dispose/start revival and keeps a new binding after stale unbind', async () => { const h = await createHarness('a') const secondView = new EditorView({ state: EditorState.create({ doc: 'a', extensions: [EditorView.updateListener.of(update => h.controller.onDocumentUpdate(update))], }), }) try { await h.controller.dispose() h.controller.start() await vi.waitFor(() => expect(h.controller.getSnapshot().loadPhase).toBe('ready')) h.controller.bindView(h.view, createEditorBindingOwnership()) h.controller.bindView(secondView, createEditorBindingOwnership()) h.controller.unbindView(h.view) secondView.dispatch({ changes: { from: 1, insert: '!' } }) expect(h.manager.getTextFirstDocumentState(h.url).content).toBe('a!') } finally { secondView.destroy() await cleanup(h) } })})