Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964// src/plugin/__tests__/workspace.test.tsimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { Workspace, WorkspaceLeaf } from '../workspace'import { EditorState } from '@codemirror/state'import { EditorView } from '@codemirror/view'import { MarkdownView, attachMarkdownViewEditor } from '../views/markdown-view'import { View } from '../views/view'import { CM6EditorAdapter } from '../cm6-editor'import type { EventRefImpl } from '../events'
describe('Workspace', () => { describe('containerEl', () => { it('has a containerEl property', () => { const containerEl = document.createElement('div') const workspace = new Workspace(containerEl) expect(workspace.containerEl).toBe(containerEl) }) })
describe('layoutReady', () => { it('starts as false', () => { const workspace = new Workspace(document.createElement('div')) expect(workspace.layoutReady).toBe(false) }) })
describe('onLayoutReady', () => { it('calls callback immediately if layout is ready', () => { const workspace = new Workspace(document.createElement('div')) workspace.setLayoutReady() const callback = vi.fn() workspace.onLayoutReady(callback) expect(callback).toHaveBeenCalled() })
it('queues callback if layout is not ready', () => { const workspace = new Workspace(document.createElement('div')) const callback = vi.fn() workspace.onLayoutReady(callback) expect(callback).not.toHaveBeenCalled() })
it('calls queued callbacks when layout becomes ready', () => { const workspace = new Workspace(document.createElement('div')) const callback = vi.fn() workspace.onLayoutReady(callback) workspace.setLayoutReady() expect(callback).toHaveBeenCalled() })
it('one throwing callback does not prevent later callbacks from running', () => { const workspace = new Workspace(document.createElement('div')) const earlyCallback = vi.fn(() => { throw new Error('plugin crashed') }) const lateCallback = vi.fn() workspace.onLayoutReady(earlyCallback) workspace.onLayoutReady(lateCallback) workspace.setLayoutReady() expect(earlyCallback).toHaveBeenCalled() expect(lateCallback).toHaveBeenCalled() })
it('logs error when a layout callback throws', () => { const workspace = new Workspace(document.createElement('div')) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) workspace.onLayoutReady(() => { throw new Error('boom') }) workspace.setLayoutReady() expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('onLayoutReady callback'), expect.any(Error) ) consoleSpy.mockRestore() })
it('immediate callback (layout already ready) does not throw to caller', () => { const workspace = new Workspace(document.createElement('div')) workspace.setLayoutReady() const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // Should not throw — error should be caught and logged expect(() => { workspace.onLayoutReady(() => { throw new Error('immediate boom') }) }).not.toThrow() expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('onLayoutReady callback'), expect.any(Error) ) consoleSpy.mockRestore() }) })
describe('getLeaf', () => { it('returns a WorkspaceLeaf', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() expect(leaf).toBeInstanceOf(WorkspaceLeaf) })
it('returns new leaf when newLeaf is true', () => { const workspace = new Workspace(document.createElement('div')) const leaf1 = workspace.getLeaf() const leaf2 = workspace.getLeaf(true) expect(leaf2).not.toBe(leaf1) })
it('returns existing leaf when newLeaf is false', () => { const workspace = new Workspace(document.createElement('div')) const leaf1 = workspace.getLeaf() const leaf2 = workspace.getLeaf(false) expect(leaf2).toBe(leaf1) }) })
describe('moveRootLeaf', () => { it('moves the same leaf, preserves the active leaf, and notifies layout subscribers once', () => { const workspace = new Workspace(document.createElement('div')) const a = workspace.getLeaf(true) const b = workspace.getLeaf(true) const c = workspace.getLeaf(true) workspace.setActiveLeaf(b) const onLayout = vi.fn() const onActive = vi.fn() workspace.on('layout-change', onLayout) workspace.on('active-leaf-change', onActive)
workspace.moveRootLeaf(a, 2) const order: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => order.push(leaf)) expect(order).toEqual([b, c, a]) expect(workspace.activeLeaf).toBe(b) expect(onLayout).toHaveBeenCalledOnce() expect(onActive).not.toHaveBeenCalled()
workspace.moveRootLeaf(a, 2) workspace.moveRootLeaf(a, -1) workspace.moveRootLeaf(a, 3) workspace.moveRootLeaf(new WorkspaceLeaf(workspace), 0) expect(onLayout).toHaveBeenCalledOnce() }) })
describe('activeLeaf', () => { it('starts as null', () => { const workspace = new Workspace(document.createElement('div')) expect(workspace.activeLeaf).toBe(null) }) })
describe('activeEditor', () => { it('starts as null', () => { const workspace = new Workspace(document.createElement('div')) expect(workspace.activeEditor).toBe(null) })
it('returns the active markdown view', () => { const workspace = new Workspace(document.createElement('div')) const leafA = workspace.getLeaf() const leafB = workspace.getLeaf(true) const viewA = new MarkdownView(leafA) const viewB = new MarkdownView(leafB) leafA.view = viewA leafB.view = viewB
workspace.setActiveLeaf(leafA) expect(workspace.activeEditor).toBe(viewA)
workspace.setActiveLeaf(leafB) expect(workspace.activeEditor).toBe(viewB) })
it('tracks active leaf view changes in normal leaf open flow', async () => { class DummyView extends View { getViewType(): string { return 'dummy' }
getDisplayText(): string { return 'Dummy' } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf)
await leaf.open(new DummyView(leaf)) expect(workspace.activeEditor).toBe(null)
const markdownView = new MarkdownView(leaf) await leaf.open(markdownView) expect(workspace.activeEditor).toBe(markdownView) })
it('clears activeEditor while openView replaces the active markdown view asynchronously', async () => { let resolveClose!: () => void let resolveOpen!: () => void let replacementView: MarkdownView | null = null
class SlowCloseMarkdownView extends MarkdownView { override async onClose(): Promise<void> { await new Promise<void>((resolve) => { resolveClose = resolve }) } }
class DeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() const initialView = new SlowCloseMarkdownView(leaf) workspace.registerViewType('deferred-replacement-markdown', (targetLeaf) => { replacementView = new DeferredMarkdownView(targetLeaf) return replacementView })
await leaf.open(initialView) workspace.setActiveLeaf(leaf) expect(workspace.activeEditor).toBe(initialView)
const replacePromise = leaf.openView('deferred-replacement-markdown') expect(workspace.activeEditor).toBe(null)
resolveClose() await vi.waitFor(() => { expect(typeof resolveOpen).toBe('function') }) expect(workspace.activeEditor).toBe(null)
resolveOpen() await replacePromise expect(replacementView).toBeInstanceOf(MarkdownView) expect(workspace.activeEditor).toBe(replacementView) })
it('clears activeEditor when active leaf open rejects', async () => { class FailingMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { throw new Error('view open failed') } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() const initialView = new MarkdownView(leaf)
await leaf.open(initialView) workspace.setActiveLeaf(leaf) expect(workspace.activeEditor).toBe(initialView)
await expect(leaf.open(new FailingMarkdownView(leaf))).rejects.toThrow('view open failed') expect(leaf.view).toBe(null) expect(workspace.activeEditor).toBe(null) })
it('keeps activeEditor null until openView onOpen succeeds on the active leaf', async () => { let resolveOpen!: () => void let createdView: MarkdownView | null = null
class DeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) workspace.registerViewType('deferred-markdown', (targetLeaf) => { createdView = new DeferredMarkdownView(targetLeaf) return createdView })
const openPromise = leaf.openView('deferred-markdown') expect(workspace.activeEditor).toBe(null)
resolveOpen() await openPromise
expect(createdView).toBeInstanceOf(MarkdownView) expect(workspace.activeEditor).toBe(createdView) })
it('keeps activeEditor null until setViewState onOpen succeeds on the active leaf', async () => { let resolveOpen!: () => void let createdView: MarkdownView | null = null
class DeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) workspace.registerViewType('deferred-markdown-via-state', (targetLeaf) => { createdView = new DeferredMarkdownView(targetLeaf) return createdView })
const openPromise = leaf.setViewState({ type: 'deferred-markdown-via-state' }) expect(workspace.activeEditor).toBe(null)
resolveOpen() await openPromise
expect(createdView).toBeInstanceOf(MarkdownView) expect(workspace.activeEditor).toBe(createdView) })
it('does not apply stale setViewState state or eState after a newer replacement owns the leaf', async () => { let resolveAState!: () => void let sequence = 0 const views: StatefulView[] = [] class StatefulView extends View { readonly sequence = ++sequence readonly stateCalls: unknown[] = [] readonly ephemeralCalls: unknown[] = [] getViewType(): string { return 'overlapping-state-view' } getDisplayText(): string { return `State ${this.sequence}` } setState(state: unknown): Promise<void> { this.stateCalls.push(state) if (this.sequence === 1) return new Promise(resolve => { resolveAState = resolve }) return Promise.resolve() } setEphemeralState(state: unknown): void { this.ephemeralCalls.push(state) } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('overlapping-state-view', leaf => { const view = new StatefulView(leaf) views.push(view) return view }) const leaf = workspace.getLeaf(true) const first = leaf.setViewState({ type: 'overlapping-state-view', state: { label: 'A' } }, { marker: 'A' }) await vi.waitFor(() => expect(views[0]?.stateCalls).toEqual([{ label: 'A' }]))
const second = leaf.setViewState({ type: 'overlapping-state-view', state: { label: 'B' } }, { marker: 'B' }) await second resolveAState() await first
expect(views).toHaveLength(2) expect(views[0]!.ephemeralCalls).toEqual([]) expect(views[1]!.stateCalls).toEqual([{ label: 'B' }]) expect(views[1]!.ephemeralCalls).toEqual([{ marker: 'B' }]) expect(leaf.view).toBe(views[1]) })
it('ignores setViewState state when prior close, creator, or onOpen reenters', async () => { const run = async (phase: 'close' | 'creator' | 'open'): Promise<void> => { let newerPromise: Promise<void> | undefined let triggerNewer: (() => void) | undefined const applied: string[] = [] class StateView extends View { constructor(leaf: WorkspaceLeaf, readonly label: string) { super(leaf) } getViewType(): string { return this.label } getDisplayText(): string { return this.label } async onOpen(): Promise<void> { if (phase === 'open' && this.label === 'owned-a') triggerNewer?.() } onClose(): Promise<void> { if (phase === 'close' && this.label === 'previous') triggerNewer?.() return Promise.resolve() } setState(state: unknown): Promise<void> { applied.push(`${this.label}:${(state as { label: string }).label}`) return Promise.resolve() } } const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf(true) workspace.registerViewType('owned-a', target => { if (phase === 'creator') triggerNewer?.() return new StateView(target, 'owned-a') }) workspace.registerViewType('owned-b', target => new StateView(target, 'owned-b'))
if (phase === 'close') leaf.view = new StateView(leaf, 'previous') triggerNewer = () => { if (!newerPromise) newerPromise = leaf.setViewState({ type: 'owned-b', state: { label: 'B' } }) }
const stale = leaf.setViewState({ type: 'owned-a', state: { label: 'A' } }) await stale await newerPromise expect(applied).toEqual(['owned-b:B']) }
await run('close') await run('creator') await run('open') })
it('closes a supplied candidate when previous-view close fails', async () => { let candidateResource = false let candidateCleaned = false let candidateCloseCalls = 0 class PreviousFailureView extends View { getViewType(): string { return 'previous-failure-view' } getDisplayText(): string { return 'Previous failure' } onClose(): Promise<void> { return Promise.reject(new Error('previous close failed')) } } class CandidateView extends View { constructor(leaf: WorkspaceLeaf) { super(leaf) candidateResource = true } getViewType(): string { return 'candidate-after-close-failure' } getDisplayText(): string { return 'Candidate' } onClose(): Promise<void> { candidateCloseCalls += 1 candidateCleaned = true return Promise.resolve() } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf(true) leaf.view = new PreviousFailureView(leaf) const candidate = new CandidateView(leaf) await expect(leaf.open(candidate)).rejects.toThrow('previous close failed') expect(candidateResource).toBe(true) expect(candidateCleaned).toBe(true) expect(candidateCloseCalls).toBe(1) })
it('ignores stale same-leaf open completion while a newer openView is still pending', async () => { let resolveFirstOpen!: () => void let resolveSecondOpen!: () => void let replacementView: MarkdownView | null = null
class FirstDeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveFirstOpen = resolve }) } }
class SecondDeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveSecondOpen = resolve }) } }
const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) workspace.registerViewType('second-deferred-markdown', (targetLeaf) => { replacementView = new SecondDeferredMarkdownView(targetLeaf) return replacementView })
const firstView = new FirstDeferredMarkdownView(leaf) const firstOpenPromise = leaf.open(firstView) expect(workspace.activeEditor).toBe(null)
const secondOpenPromise = leaf.openView('second-deferred-markdown') await vi.waitFor(() => { expect(replacementView).toBeInstanceOf(MarkdownView) }) expect(workspace.activeEditor).toBe(null)
resolveFirstOpen() await firstOpenPromise expect(leaf.view).toBe(replacementView) expect(workspace.activeEditor).toBe(null)
resolveSecondOpen() await secondOpenPromise expect(workspace.activeEditor).toBe(replacementView) })
it('keeps activeEditor truthful when an inactive leaf becomes active during a pending open', async () => { let resolveOpen!: () => void
class DeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) } }
const workspace = new Workspace(document.createElement('div')) const activeLeaf = workspace.getLeaf() const pendingLeaf = workspace.getLeaf(true) const activeView = new MarkdownView(activeLeaf) const pendingView = new DeferredMarkdownView(pendingLeaf)
await activeLeaf.open(activeView) workspace.setActiveLeaf(activeLeaf) expect(workspace.activeEditor).toBe(activeView)
const openPromise = pendingLeaf.open(pendingView) expect(workspace.activeEditor).toBe(activeView)
workspace.setActiveLeaf(pendingLeaf) expect(workspace.activeEditor).toBe(null)
resolveOpen() await openPromise
expect(workspace.activeEditor).toBe(pendingView) })
it('keeps activeEditor truthful when an inactive leaf becomes active during pending openView', async () => { let resolveOpen!: () => void let createdView: MarkdownView | null = null
class DeferredMarkdownView extends MarkdownView { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) } }
const workspace = new Workspace(document.createElement('div')) const activeLeaf = workspace.getLeaf() const pendingLeaf = workspace.getLeaf(true) const activeView = new MarkdownView(activeLeaf)
await activeLeaf.open(activeView) workspace.setActiveLeaf(activeLeaf) workspace.registerViewType('deferred-inactive-markdown', (targetLeaf) => { createdView = new DeferredMarkdownView(targetLeaf) return createdView })
const openPromise = pendingLeaf.openView('deferred-inactive-markdown') expect(workspace.activeEditor).toBe(activeView)
workspace.setActiveLeaf(pendingLeaf) expect(workspace.activeEditor).toBe(null)
resolveOpen() await openPromise
expect(createdView).toBeInstanceOf(MarkdownView) expect(workspace.activeEditor).toBe(createdView) })
it('clears activeEditor before unregisterViewType runs synchronous onClose', async () => { let observedActiveEditor: MarkdownView | null = null
const workspace = new Workspace(document.createElement('div')) const closingLeaf = workspace.getLeaf() const fallbackLeaf = workspace.getLeaf(true)
class ClosingMarkdownView extends MarkdownView { override getViewType(): string { return 'closing-markdown' }
override onClose(): Promise<void> { observedActiveEditor = workspace.activeEditor as MarkdownView | null return Promise.resolve() } }
workspace.registerViewType('closing-markdown', (targetLeaf) => new ClosingMarkdownView(targetLeaf))
const closingView = new ClosingMarkdownView(closingLeaf) const fallbackView = new MarkdownView(fallbackLeaf)
await closingLeaf.open(closingView) await fallbackLeaf.open(fallbackView) workspace.setActiveLeaf(closingLeaf)
workspace.unregisterViewType('closing-markdown')
expect(observedActiveEditor).toBe(null) expect(workspace.activeEditor).toBe(fallbackView) })
it('clears activeEditor before detachLeavesOfType runs synchronous onClose', async () => { let observedActiveEditor: MarkdownView | null = null
const workspace = new Workspace(document.createElement('div')) const closingLeaf = workspace.getLeaf() const fallbackLeaf = workspace.getLeaf(true)
class ClosingMarkdownView extends MarkdownView { override getViewType(): string { return 'detach-closing-markdown' }
override onClose(): Promise<void> { observedActiveEditor = workspace.activeEditor as MarkdownView | null return Promise.resolve() } }
const closingView = new ClosingMarkdownView(closingLeaf) const fallbackView = new MarkdownView(fallbackLeaf)
await closingLeaf.open(closingView) await fallbackLeaf.open(fallbackView) workspace.setActiveLeaf(closingLeaf)
workspace.detachLeavesOfType('detach-closing-markdown')
expect(observedActiveEditor).toBe(null) expect(workspace.activeEditor).toBe(fallbackView) })
it('does not republish activeEditor to another doomed leaf during unregisterViewType bulk close', async () => { const workspace = new Workspace(document.createElement('div')) const doomedLeafA = workspace.getLeaf() const doomedActiveLeaf = workspace.getLeaf(true) const fallbackLeaf = workspace.getLeaf(true)
class ClosingMarkdownView extends MarkdownView { override getViewType(): string { return 'bulk-unregister-markdown' } }
workspace.registerViewType('bulk-unregister-markdown', (targetLeaf) => new ClosingMarkdownView(targetLeaf))
const doomedViewA = new ClosingMarkdownView(doomedLeafA) const doomedActiveView = new ClosingMarkdownView(doomedActiveLeaf) const fallbackView = new MarkdownView(fallbackLeaf) const activeEditorChanges: Array<MarkdownView | null> = []
await doomedLeafA.open(doomedViewA) await doomedActiveLeaf.open(doomedActiveView) await fallbackLeaf.open(fallbackView) workspace.setActiveLeaf(doomedActiveLeaf) workspace.on('active-editor-change', (editor) => { activeEditorChanges.push(editor as MarkdownView | null) })
workspace.unregisterViewType('bulk-unregister-markdown')
expect(activeEditorChanges).toEqual([null, fallbackView]) expect(activeEditorChanges).not.toContain(doomedViewA) expect(workspace.activeEditor).toBe(fallbackView) })
it('does not republish activeEditor to another doomed leaf during detachLeavesOfType bulk close', async () => { const workspace = new Workspace(document.createElement('div')) const doomedActiveLeaf = workspace.getLeaf() const doomedLeafB = workspace.getLeaf(true) const fallbackLeaf = workspace.getLeaf(true)
class ClosingMarkdownView extends MarkdownView { override getViewType(): string { return 'bulk-detach-markdown' } }
const doomedActiveView = new ClosingMarkdownView(doomedActiveLeaf) const doomedViewB = new ClosingMarkdownView(doomedLeafB) const fallbackView = new MarkdownView(fallbackLeaf) const activeEditorChanges: Array<MarkdownView | null> = []
await doomedActiveLeaf.open(doomedActiveView) await doomedLeafB.open(doomedViewB) await fallbackLeaf.open(fallbackView) workspace.setActiveLeaf(doomedActiveLeaf) workspace.on('active-editor-change', (editor) => { activeEditorChanges.push(editor as MarkdownView | null) })
workspace.detachLeavesOfType('bulk-detach-markdown')
expect(activeEditorChanges).toEqual([null, fallbackView]) expect(activeEditorChanges).not.toContain(doomedViewB) expect(workspace.activeEditor).toBe(fallbackView) }) })
describe('setActiveLeaf', () => { it('sets the active leaf', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) expect(workspace.activeLeaf).toBe(leaf) })
it('emits active-leaf-change event', () => { const workspace = new Workspace(document.createElement('div')) const callback = vi.fn() workspace.on('active-leaf-change', callback) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) expect(callback).toHaveBeenCalledWith(leaf) })
it('does not emit event if same leaf', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) const callback = vi.fn() workspace.on('active-leaf-change', callback) workspace.setActiveLeaf(leaf) expect(callback).not.toHaveBeenCalled() })
it('ignores activation requests for detached leaves', () => { const workspace = new Workspace(document.createElement('div')) const activeLeaf = workspace.getLeaf(true) const detachedLeaf = workspace.getLeaf(true) workspace.getLeaf(true) workspace.setActiveLeaf(activeLeaf) detachedLeaf.detach()
workspace.setActiveLeaf(detachedLeaf)
expect(workspace.activeLeaf).toBe(activeLeaf) }) })
describe('getActiveViewOfType', () => { it('returns null when no active leaf', () => { const workspace = new Workspace(document.createElement('div')) expect(workspace.getActiveViewOfType(Object as any)).toBe(null) }) })
describe('getMostRecentLeaf', () => { it('returns most recently active leaf', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) expect(workspace.getMostRecentLeaf()).toBe(leaf) }) })
describe('getLeafById', () => { it('returns leaf by id', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() expect(workspace.getLeafById(leaf.id)).toBe(leaf) })
it('returns null for unknown id', () => { const workspace = new Workspace(document.createElement('div')) expect(workspace.getLeafById('unknown')).toBe(null) }) })
describe('detachLeaf', () => { it('closes hosted views exactly once for main and panel leaves', async () => { class ClosableView extends View { closeCalls = 0 getViewType(): string { return 'closable' } getDisplayText(): string { return 'Closable' } onClose(): Promise<void> { this.closeCalls += 1 return Promise.resolve() } }
const workspace = new Workspace(document.createElement('div')) const leaves = [workspace.getLeaf(true), workspace.getLeftLeaf(true), workspace.getRightLeaf(true)] const views = leaves.map(leaf => new ClosableView(leaf)) leaves.forEach((leaf, index) => { leaf.view = views[index]! })
leaves.forEach(leaf => leaf.detach()) await Promise.resolve() expect(views.map(view => view.closeCalls)).toEqual([1, 1, 1]) leaves.forEach(leaf => leaf.detach()) expect(views.map(view => view.closeCalls)).toEqual([1, 1, 1]) })
it('invalidates a replacement awaiting the previous view close', async () => { let resolveClose!: () => void let closeCalls = 0 class PreviousView extends View { getViewType(): string { return 'previous-close-race' } getDisplayText(): string { return 'Previous' } onClose(): Promise<void> { closeCalls += 1 return new Promise(resolve => { resolveClose = resolve }) } } class NewView extends View { getViewType(): string { return 'new-close-race' } getDisplayText(): string { return 'New' } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('new-close-race', leaf => new NewView(leaf)) const leaf = workspace.getLeaf(true) leaf.view = new PreviousView(leaf) const opening = leaf.openView('new-close-race') await vi.waitFor(() => expect(closeCalls).toBe(1))
leaf.detach() resolveClose() await opening expect(closeCalls).toBe(1) expect(leaf.view).toBe(null) expect(workspace.getLeavesOfType('new-close-race')).toHaveLength(0) expect(workspace.activeLeaf).toBe(null) })
it('invalidates a new view awaiting onOpen and closes it once', async () => { let resolveOpen!: () => void let closeCalls = 0 let resourceAcquired = false let resourceCleaned = false class PendingView extends View { getViewType(): string { return 'pending-open-race' } getDisplayText(): string { return 'Pending' } async onOpen(): Promise<void> { await new Promise<void>(resolve => { resolveOpen = resolve }) resourceAcquired = true } onClose(): Promise<void> { closeCalls += 1 expect(resourceAcquired).toBe(true) resourceCleaned = true return Promise.resolve() } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('pending-open-race', leaf => new PendingView(leaf)) const leaf = workspace.getLeaf(true) const opening = leaf.openView('pending-open-race') await vi.waitFor(() => expect(leaf.view?.getViewType()).toBe('pending-open-race'))
leaf.detach() expect(closeCalls).toBe(0) resolveOpen() await opening expect(closeCalls).toBe(1) expect(resourceCleaned).toBe(true) expect(leaf.view).toBe(null) expect(workspace.getLeavesOfType('pending-open-race')).toHaveLength(0) expect(workspace.activeLeaf).toBe(null) })
it('closes current and stale views after onOpen rejection', async () => { let currentResource = false let currentCleaned = false let currentCloseCalls = 0 class RejectingView extends View { getViewType(): string { return 'rejecting-open-view' } getDisplayText(): string { return 'Rejecting' } async onOpen(): Promise<void> { currentResource = true throw new Error('open failed') } onClose(): Promise<void> { currentCloseCalls += 1 expect(currentResource).toBe(true) currentCleaned = true return Promise.resolve() } } const currentWorkspace = new Workspace(document.createElement('div')) currentWorkspace.registerViewType('rejecting-open-view', leaf => new RejectingView(leaf)) const currentLeaf = currentWorkspace.getLeaf(true) await expect(currentLeaf.openView('rejecting-open-view')).rejects.toThrow('open failed') expect(currentCloseCalls).toBe(1) expect(currentCleaned).toBe(true) expect(currentLeaf.view).toBe(null)
let resolveStaleOpen!: () => void let staleResource = false let staleCleaned = false let staleCloseCalls = 0 class StaleRejectingView extends View { getViewType(): string { return 'stale-rejecting-open-view' } getDisplayText(): string { return 'Stale Rejecting' } async onOpen(): Promise<void> { await new Promise<void>(resolve => { resolveStaleOpen = resolve }) staleResource = true throw new Error('stale open failed') } onClose(): Promise<void> { staleCloseCalls += 1 expect(staleResource).toBe(true) staleCleaned = true return Promise.resolve() } } const staleWorkspace = new Workspace(document.createElement('div')) staleWorkspace.registerViewType('stale-rejecting-open-view', leaf => new StaleRejectingView(leaf)) const staleLeaf = staleWorkspace.getLeaf(true) const staleOpening = staleLeaf.openView('stale-rejecting-open-view') await vi.waitFor(() => expect(staleLeaf.view?.getViewType()).toBe('stale-rejecting-open-view')) staleLeaf.detach() resolveStaleOpen() await staleOpening expect(staleCloseCalls).toBe(1) expect(staleCleaned).toBe(true) expect(staleLeaf.view).toBe(null) })
it('does not close a replacement view twice when detach races its onOpen', async () => { let resolveClose!: () => void let resolveOpen!: () => void let previousCloseCalls = 0 let replacementCloseCalls = 0 class PreviousView extends View { getViewType(): string { return 'replacement-previous-race' } getDisplayText(): string { return 'Previous' } onClose(): Promise<void> { previousCloseCalls += 1 return new Promise(resolve => { resolveClose = resolve }) } } class ReplacementView extends View { getViewType(): string { return 'replacement-new-race' } getDisplayText(): string { return 'Replacement' } async onOpen(): Promise<void> { await new Promise<void>(resolve => { resolveOpen = resolve }) } onClose(): Promise<void> { replacementCloseCalls += 1 return Promise.resolve() } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('replacement-new-race', leaf => new ReplacementView(leaf)) const leaf = workspace.getLeaf(true) leaf.view = new PreviousView(leaf) const opening = leaf.openView('replacement-new-race') await vi.waitFor(() => expect(previousCloseCalls).toBe(1)) resolveClose() await vi.waitFor(() => expect(leaf.view?.getViewType()).toBe('replacement-new-race'))
leaf.detach() expect(replacementCloseCalls).toBe(0) resolveOpen() await opening expect(previousCloseCalls).toBe(1) expect(replacementCloseCalls).toBe(1) expect(leaf.view).toBe(null) })
it('removes leaf from workspace', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.detachLeaf(leaf) expect(workspace.getLeafById(leaf.id)).toBe(null) })
it('clears activeLeaf if detached', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() workspace.setActiveLeaf(leaf) workspace.detachLeaf(leaf) expect(workspace.activeLeaf).toBe(null) })
it('emits layout-change event', () => { const workspace = new Workspace(document.createElement('div')) const callback = vi.fn() workspace.on('layout-change', callback) const leaf = workspace.getLeaf() workspace.detachLeaf(leaf) expect(callback).toHaveBeenCalled() }) })
describe('cursor-activity event', () => { it('can subscribe to cursor-activity event with typed callback', () => { const workspace = new Workspace(document.createElement('div')) const callback = vi.fn() const ref = workspace.on('cursor-activity', (editor) => { callback(editor) }) expect(ref).toBeDefined() // Cast to internal type to verify implementation details const impl = ref as EventRefImpl expect(impl.n).toBe('cursor-activity') })
it('can trigger cursor-activity event', () => { const workspace = new Workspace(document.createElement('div')) const callback = vi.fn() workspace.on('cursor-activity', callback) const mockEditor = { getCursor: () => ({ line: 0, ch: 5 }) } workspace.trigger('cursor-activity', mockEditor) expect(callback).toHaveBeenCalledWith(mockEditor) }) })})
describe('WorkspaceLeaf', () => { it('has a unique id', () => { const workspace = new Workspace(document.createElement('div')) const leaf1 = workspace.getLeaf() const leaf2 = workspace.getLeaf(true) expect(leaf1.id).not.toBe(leaf2.id) })
it('has a view property', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() expect(leaf.view).toBeDefined() })
it('detach removes from workspace', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() leaf.detach() expect(workspace.getLeafById(leaf.id)).toBe(null) })
it('getViewType returns empty string when no view', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() expect(leaf.getViewType()).toBe('') })
it('getDisplayText returns empty string when no view', () => { const workspace = new Workspace(document.createElement('div')) const leaf = workspace.getLeaf() expect(leaf.getDisplayText()).toBe('') })})
describe('Workspace.ensureSideLeaf', () => { function registerTestView(workspace: Workspace) { workspace.registerViewType('test-view', (leaf) => { return { getViewType: () => 'test-view', getDisplayText: () => 'Test View', containerEl: document.createElement('div'), onOpen: () => Promise.resolve(), onClose: () => Promise.resolve(), leaf, setState: () => {}, getState: () => ({}), } as unknown as import('../views/item-view').ItemView }) }
it('returns a promise that resolves to the leaf (Obsidian API contract)', async () => { const workspace = new Workspace(document.createElement('div')) registerTestView(workspace)
const leafPromise = workspace.ensureSideLeaf('test-view', 'left') expect(leafPromise).toBeInstanceOf(Promise)
const leaf = await leafPromise expect(leaf).toBeDefined() expect(leaf).toBeInstanceOf(WorkspaceLeaf) expect(workspace.getLeftLeaves()).toContain(leaf) })
it('defaults side to left when omitted', async () => { const workspace = new Workspace(document.createElement('div')) registerTestView(workspace)
const leaf = await workspace.ensureSideLeaf('test-view') expect(workspace.getLeftLeaves()).toContain(leaf) })
it('populates the view asynchronously via setViewState', async () => { const workspace = new Workspace(document.createElement('div')) registerTestView(workspace)
const leaf = await workspace.ensureSideLeaf('test-view', 'left') // View is not yet populated synchronously // After microtask, view should be set await new Promise(r => setTimeout(r, 0)) expect(leaf.view?.getViewType()).toBe('test-view') })
it('returns existing leaf if view type already exists on the side', async () => { const workspace = new Workspace(document.createElement('div')) registerTestView(workspace)
const leaf1 = await workspace.ensureSideLeaf('test-view', 'left') // Wait for the first view to open await new Promise(r => setTimeout(r, 0)) const leaf2 = await workspace.ensureSideLeaf('test-view', 'left') expect(leaf1).toBe(leaf2) })
it('passes options.state through to setViewState', async () => { const workspace = new Workspace(document.createElement('div')) const setStateSpy = vi.fn() workspace.registerViewType('stateful-side-view', (leaf) => { return { getViewType: () => 'stateful-side-view', getDisplayText: () => 'Stateful Side View', containerEl: document.createElement('div'), onOpen: () => Promise.resolve(), onClose: () => Promise.resolve(), leaf, setState: setStateSpy, getState: () => ({}), } as unknown as import('../views/item-view').ItemView })
const leaf = await workspace.ensureSideLeaf('stateful-side-view', 'left', { state: { expanded: true }, })
await new Promise(r => setTimeout(r, 0))
expect(setStateSpy).toHaveBeenCalledWith({ expanded: true }, { history: false }) expect(leaf.view?.getViewType()).toBe('stateful-side-view') })
it('forwards falsy options.state values when opening a new side leaf', async () => { const workspace = new Workspace(document.createElement('div')) const setStateSpy = vi.fn() workspace.registerViewType('falsy-stateful-side-view', (leaf) => { return { getViewType: () => 'falsy-stateful-side-view', getDisplayText: () => 'Falsy Stateful Side View', containerEl: document.createElement('div'), onOpen: () => Promise.resolve(), onClose: () => Promise.resolve(), leaf, setState: setStateSpy, getState: () => false, } as unknown as import('../views/item-view').ItemView })
const leaf = await workspace.ensureSideLeaf('falsy-stateful-side-view', 'left', { state: false, })
await new Promise(r => setTimeout(r, 0))
expect(setStateSpy).toHaveBeenCalledWith(false, { history: false }) expect(leaf.view?.getViewType()).toBe('falsy-stateful-side-view') })
it('applies options.state when reusing an existing side leaf', async () => { const workspace = new Workspace(document.createElement('div')) const setStateSpy = vi.fn() workspace.registerViewType('reused-stateful-side-view', (leaf) => { return { getViewType: () => 'reused-stateful-side-view', getDisplayText: () => 'Reused Stateful Side View', containerEl: document.createElement('div'), onOpen: () => Promise.resolve(), onClose: () => Promise.resolve(), leaf, setState: setStateSpy, getState: () => ({}), } as unknown as import('../views/item-view').ItemView })
const leaf1 = await workspace.ensureSideLeaf('reused-stateful-side-view', 'left') await new Promise(r => setTimeout(r, 0)) setStateSpy.mockClear()
const leaf2 = await workspace.ensureSideLeaf('reused-stateful-side-view', 'left', { state: { expanded: false }, })
expect(leaf2).toBe(leaf1) expect(setStateSpy).toHaveBeenCalledWith({ expanded: false }, { history: false }) })
it('handles async state application failures when reusing an existing side leaf', async () => { const workspace = new Workspace(document.createElement('div')) const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const setStateSpy = vi.fn().mockRejectedValue(new Error('async state failed'))
workspace.registerViewType('reused-async-stateful-side-view', (leaf) => { return { getViewType: () => 'reused-async-stateful-side-view', getDisplayText: () => 'Reused Async Stateful Side View', containerEl: document.createElement('div'), onOpen: () => Promise.resolve(), onClose: () => Promise.resolve(), leaf, setState: setStateSpy, getState: () => ({}), } as unknown as import('../views/item-view').ItemView })
const leaf1 = await workspace.ensureSideLeaf('reused-async-stateful-side-view', 'left') await new Promise(r => setTimeout(r, 0)) setStateSpy.mockClear()
const leaf2 = await workspace.ensureSideLeaf('reused-async-stateful-side-view', 'left', { state: { expanded: false }, })
expect(leaf2).toBe(leaf1) expect(setStateSpy).toHaveBeenCalledWith({ expanded: false }, { history: false }) await vi.waitFor(() => { expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining("failed to apply state for view 'reused-async-stateful-side-view'"), expect.any(Error) ) })
consoleWarn.mockRestore() })
it('does not reveal an existing side leaf detached during state application', async () => { const workspace = new Workspace(document.createElement('div')) const activeMainLeaf = workspace.getLeaf(true) workspace.setActiveLeaf(activeMainLeaf)
class ExistingDetachSideView extends View { override setState(state: unknown, result: { history: boolean }): Promise<void> { expect(state).toEqual({ expanded: true }) expect(result).toEqual({ history: false }) workspace.detachLeaf(this.leaf) return Promise.resolve() }
getViewType(): string { return 'existing-detach-side-view' }
getDisplayText(): string { return 'Existing Detach Side View' } }
workspace.registerViewType('existing-detach-side-view', (leaf) => new ExistingDetachSideView(leaf)) const leaf = await workspace.ensureSideLeaf('existing-detach-side-view', 'right', { reveal: false }) await new Promise(r => setTimeout(r, 0))
const revealSpy = vi.spyOn(workspace, 'revealLeaf') const sameLeaf = await workspace.ensureSideLeaf('existing-detach-side-view', 'right', { state: { expanded: true }, reveal: true, })
expect(sameLeaf).toBe(leaf) await new Promise(r => setTimeout(r, 0))
expect(revealSpy).not.toHaveBeenCalled() expect(workspace.activeLeaf).toBe(activeMainLeaf) expect(workspace.activeRightLeaf).toBe(null) expect(workspace.rightSidebarCollapsed).toBe(true) expect(workspace.getRightLeaves()).not.toContain(leaf)
revealSpy.mockRestore() })
it('handles async state application failures when opening a new side leaf', async () => { const workspace = new Workspace(document.createElement('div')) const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const setStateSpy = vi.fn().mockRejectedValue(new Error('async state failed'))
workspace.registerViewType('new-async-stateful-side-view', (leaf) => { return { getViewType: () => 'new-async-stateful-side-view', getDisplayText: () => 'New Async Stateful Side View', containerEl: document.createElement('div'), onOpen: () => Promise.resolve(), onClose: () => Promise.resolve(), leaf, setState: setStateSpy, getState: () => ({}), } as unknown as import('../views/item-view').ItemView })
const leaf = await workspace.ensureSideLeaf('new-async-stateful-side-view', 'left', { state: { expanded: false }, })
expect(workspace.getLeftLeaves()).toContain(leaf) await vi.waitFor(() => { expect(setStateSpy).toHaveBeenCalledWith({ expanded: false }, { history: false }) expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining("failed to apply state for view 'new-async-stateful-side-view'"), expect.any(Error) ) }) expect(workspace.getLeftLeaves()).toContain(leaf) expect(leaf.view?.getViewType()).toBe('new-async-stateful-side-view')
consoleWarn.mockRestore() })
it('keeps the Promise contract when revealLeaf throws for an existing side leaf', async () => { const workspace = new Workspace(document.createElement('div')) const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) registerTestView(workspace)
const leaf = await workspace.ensureSideLeaf('test-view', 'left', { reveal: false }) await new Promise(r => setTimeout(r, 0))
const revealError = new Error('reveal failed') const revealSpy = vi.spyOn(workspace, 'revealLeaf').mockImplementation(() => { throw revealError })
await expect(workspace.ensureSideLeaf('test-view', 'left')).resolves.toBe(leaf) expect(workspace.getLeftLeaves()).toContain(leaf) expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining("failed to reveal view 'test-view'"), revealError, )
revealSpy.mockRestore() consoleWarn.mockRestore() })
it('does not detach a new side leaf when revealLeaf rejects', async () => { const workspace = new Workspace(document.createElement('div')) const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) registerTestView(workspace)
const revealError = new Error('async reveal failed') const revealSpy = vi.spyOn(workspace, 'revealLeaf').mockRejectedValue(revealError)
const leaf = await workspace.ensureSideLeaf('test-view', 'left')
await vi.waitFor(() => { expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining("failed to reveal view 'test-view'"), revealError, ) })
expect(workspace.getLeftLeaves()).toContain(leaf) expect(leaf.view?.getViewType()).toBe('test-view')
revealSpy.mockRestore() consoleWarn.mockRestore() })
it('backs off when a stale background ensure completion is superseded on the same leaf', async () => { let resolveSlowOpen!: () => void
class SlowSideView extends View { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveSlowOpen = resolve }) }
getViewType(): string { return 'slow-side-view' }
getDisplayText(): string { return 'Slow Side View' } }
class ReplacementSideView extends View { getViewType(): string { return 'replacement-side-view' }
getDisplayText(): string { return 'Replacement Side View' } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('slow-side-view', (leaf) => new SlowSideView(leaf)) workspace.registerViewType('replacement-side-view', (leaf) => new ReplacementSideView(leaf))
const leaf = await workspace.ensureSideLeaf('slow-side-view', 'left', { reveal: false }) await vi.waitFor(() => { expect(leaf.view?.getViewType()).toBe('slow-side-view') })
await leaf.setViewState({ type: 'replacement-side-view' }) expect(leaf.view?.getViewType()).toBe('replacement-side-view') expect(workspace.getLeftLeaves()).toContain(leaf)
resolveSlowOpen() await new Promise(r => setTimeout(r, 0))
expect(leaf.view?.getViewType()).toBe('replacement-side-view') expect(workspace.getLeftLeaves()).toContain(leaf) })
it('defers same-type reentry follow-up work until the initial side leaf is ready', async () => { let resolveOpen!: () => void const setStateSpy = vi.fn()
class DeferredSideView extends View { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) }
override setState(state: unknown, result: { history: boolean }): Promise<void> { setStateSpy(state, result) return Promise.resolve() }
getViewType(): string { return 'deferred-side-view' }
getDisplayText(): string { return 'Deferred Side View' } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('deferred-side-view', (leaf) => new DeferredSideView(leaf)) const revealSpy = vi.spyOn(workspace, 'revealLeaf')
const leaf1 = await workspace.ensureSideLeaf('deferred-side-view', 'left', { reveal: false }) await vi.waitFor(() => { expect(leaf1.view?.getViewType()).toBe('deferred-side-view') })
const leaf2 = await workspace.ensureSideLeaf('deferred-side-view', 'left', { state: { expanded: true }, reveal: true, })
expect(leaf2).toBe(leaf1) expect(workspace.getLeftLeaves()).toHaveLength(1) expect(setStateSpy).not.toHaveBeenCalled() expect(revealSpy).not.toHaveBeenCalled()
resolveOpen() await vi.waitFor(() => { expect(setStateSpy).toHaveBeenCalledWith({ expanded: true }, { history: false }) expect(revealSpy).toHaveBeenCalledWith(leaf1) })
revealSpy.mockRestore() })
it('lets the latest pending reentry own reveal timing after later state settles', async () => { let resolveOpen!: () => void let resolveState!: () => void const events: string[] = []
class OrderedDeferredSideView extends View { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) }
override setState(state: unknown, result: { history: boolean }): Promise<void> { expect(state).toEqual({ expanded: true }) expect(result).toEqual({ history: false }) events.push('state-start') return new Promise<void>((resolve) => { resolveState = () => { events.push('state-end') resolve() } }) }
getViewType(): string { return 'ordered-deferred-side-view' }
getDisplayText(): string { return 'Ordered Deferred Side View' } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('ordered-deferred-side-view', (leaf) => new OrderedDeferredSideView(leaf)) const revealSpy = vi.spyOn(workspace, 'revealLeaf').mockImplementation(async (leaf) => { events.push(`reveal:${leaf.id}`) })
const leaf1 = await workspace.ensureSideLeaf('ordered-deferred-side-view', 'left') await vi.waitFor(() => { expect(leaf1.view?.getViewType()).toBe('ordered-deferred-side-view') })
const leaf2 = await workspace.ensureSideLeaf('ordered-deferred-side-view', 'left', { state: { expanded: true }, })
expect(leaf2).toBe(leaf1)
resolveOpen() await vi.waitFor(() => { expect(events).toContain('state-start') })
expect(revealSpy).not.toHaveBeenCalled()
resolveState() await vi.waitFor(() => { expect(revealSpy).toHaveBeenCalledWith(leaf1) }) expect(events).toEqual(['state-start', 'state-end', `reveal:${leaf1.id}`])
revealSpy.mockRestore() })
it('detaches a new side leaf when the creator returns the wrong view type', async () => { class WrongTypeSideView extends View { getViewType(): string { return 'wrong-type-side-view-actual' }
getDisplayText(): string { return 'Wrong Type Side View' } }
const workspace = new Workspace(document.createElement('div')) workspace.registerViewType('wrong-type-side-view', (leaf) => new WrongTypeSideView(leaf))
const leaf = await workspace.ensureSideLeaf('wrong-type-side-view', 'left', { reveal: false })
await vi.waitFor(() => { expect(leaf.view).toBe(null) expect(workspace.getLeftLeaves()).not.toContain(leaf) }) })
it('backs off completely when a detached pending side leaf later finishes opening', async () => { let resolveOpen!: () => void const setStateSpy = vi.fn()
class DeferredSideView extends View { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) }
override setState(state: unknown, result: { history: boolean }): Promise<void> { setStateSpy(state, result) return Promise.resolve() }
getViewType(): string { return 'detached-pending-side-view' }
getDisplayText(): string { return 'Detached Pending Side View' } }
const workspace = new Workspace(document.createElement('div')) const activeMainLeaf = workspace.getLeaf(true) workspace.setActiveLeaf(activeMainLeaf) workspace.registerViewType('detached-pending-side-view', (leaf) => new DeferredSideView(leaf)) const revealSpy = vi.spyOn(workspace, 'revealLeaf')
const leaf = await workspace.ensureSideLeaf('detached-pending-side-view', 'right', { reveal: false, }) await vi.waitFor(() => { expect(leaf.view?.getViewType()).toBe('detached-pending-side-view') })
const sameLeaf = await workspace.ensureSideLeaf('detached-pending-side-view', 'right', { state: { expanded: true }, reveal: true, })
expect(sameLeaf).toBe(leaf) workspace.detachLeaf(leaf) expect(workspace.getRightLeaves()).not.toContain(leaf)
resolveOpen() await new Promise(r => setTimeout(r, 0))
expect(setStateSpy).not.toHaveBeenCalled() expect(revealSpy).not.toHaveBeenCalled() expect(workspace.activeLeaf).toBe(activeMainLeaf) expect(workspace.activeRightLeaf).toBe(null) expect(workspace.rightSidebarCollapsed).toBe(true) expect(workspace.getRightLeaves()).not.toContain(leaf)
revealSpy.mockRestore() })
it('does not reveal a side leaf detached synchronously during follow-up state application', async () => { let resolveOpen!: () => void const setStateSpy = vi.fn() const workspace = new Workspace(document.createElement('div'))
class DeferredSideView extends View { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) }
override setState(state: unknown, result: { history: boolean }): Promise<void> { setStateSpy(state, result) workspace.detachLeaf(this.leaf) return Promise.resolve() }
getViewType(): string { return 'detach-during-follow-up-side-view' }
getDisplayText(): string { return 'Detach During Follow-up Side View' } }
const activeMainLeaf = workspace.getLeaf(true) workspace.setActiveLeaf(activeMainLeaf) workspace.registerViewType('detach-during-follow-up-side-view', (leaf) => new DeferredSideView(leaf)) const revealSpy = vi.spyOn(workspace, 'revealLeaf')
const leaf = await workspace.ensureSideLeaf('detach-during-follow-up-side-view', 'right', { reveal: false, }) await vi.waitFor(() => { expect(leaf.view?.getViewType()).toBe('detach-during-follow-up-side-view') })
const sameLeaf = await workspace.ensureSideLeaf('detach-during-follow-up-side-view', 'right', { state: { expanded: true }, reveal: true, })
expect(sameLeaf).toBe(leaf)
resolveOpen() await vi.waitFor(() => { expect(setStateSpy).toHaveBeenCalledWith({ expanded: true }, { history: false }) })
expect(revealSpy).not.toHaveBeenCalled() expect(workspace.activeLeaf).toBe(activeMainLeaf) expect(workspace.activeRightLeaf).toBe(null) expect(workspace.rightSidebarCollapsed).toBe(true) expect(workspace.getRightLeaves()).not.toContain(leaf)
revealSpy.mockRestore() })
it('does not reveal a side leaf detached asynchronously during follow-up state application', async () => { let resolveOpen!: () => void const setStateSpy = vi.fn() const workspace = new Workspace(document.createElement('div'))
class DeferredSideView extends View { override async onOpen(): Promise<void> { await new Promise<void>((resolve) => { resolveOpen = resolve }) }
override setState(state: unknown, result: { history: boolean }): Promise<void> { setStateSpy(state, result) return Promise.resolve().then(() => { workspace.detachLeaf(this.leaf) }) }
getViewType(): string { return 'async-detach-during-follow-up-side-view' }
getDisplayText(): string { return 'Async Detach During Follow-up Side View' } }
const activeMainLeaf = workspace.getLeaf(true) workspace.setActiveLeaf(activeMainLeaf) workspace.registerViewType('async-detach-during-follow-up-side-view', (leaf) => new DeferredSideView(leaf)) const revealSpy = vi.spyOn(workspace, 'revealLeaf')
const leaf = await workspace.ensureSideLeaf('async-detach-during-follow-up-side-view', 'right', { reveal: false, }) await vi.waitFor(() => { expect(leaf.view?.getViewType()).toBe('async-detach-during-follow-up-side-view') })
const sameLeaf = await workspace.ensureSideLeaf('async-detach-during-follow-up-side-view', 'right', { state: { expanded: true }, reveal: true, })
expect(sameLeaf).toBe(leaf)
resolveOpen() await vi.waitFor(() => { expect(setStateSpy).toHaveBeenCalledWith({ expanded: true }, { history: false }) }) await new Promise(r => setTimeout(r, 0))
expect(revealSpy).not.toHaveBeenCalled() expect(workspace.activeLeaf).toBe(activeMainLeaf) expect(workspace.activeRightLeaf).toBe(null) expect(workspace.rightSidebarCollapsed).toBe(true) expect(workspace.getRightLeaves()).not.toContain(leaf)
revealSpy.mockRestore() })})
describe('Workspace hover link source stubs', () => { it('registerHoverLinkSource is a no-op function', () => { const workspace = new Workspace(document.createElement('div')) expect(() => workspace.registerHoverLinkSource('test', { display: 'Test', defaultMod: true })).not.toThrow() })
it('unregisterHoverLinkSource is a no-op function', () => { const workspace = new Workspace(document.createElement('div')) expect(() => workspace.unregisterHoverLinkSource('test')).not.toThrow() })})
describe('Workspace sidebar leaf manifest', () => { it('getSidebarLeafManifest returns empty array when no leaves', () => { const workspace = new Workspace(document.createElement('div')) expect(workspace.getSidebarLeafManifest()).toEqual([]) })
it('getSidebarLeafManifest includes left and right leaves with views', async () => { const workspace = new Workspace(document.createElement('div')) const { ItemView } = await import('../views/item-view')
class LeftView extends ItemView { getViewType() { return 'left-view' } getDisplayText() { return 'Left' } } class RightView extends ItemView { getViewType() { return 'right-view' } getDisplayText() { return 'Right' } }
workspace.registerViewType('left-view', (leaf) => new LeftView(leaf)) workspace.registerViewType('right-view', (leaf) => new RightView(leaf))
const leftLeaf = workspace.getLeftLeaf(true) await leftLeaf.setViewState({ type: 'left-view' }) const rightLeaf = workspace.getRightLeaf(true) await rightLeaf.setViewState({ type: 'right-view' })
const manifest = workspace.getSidebarLeafManifest() expect(manifest).toEqual([ { viewType: 'left-view', side: 'left' }, { viewType: 'right-view', side: 'right' }, ]) })
it('getSidebarLeafManifest skips leaves without views', () => { const workspace = new Workspace(document.createElement('div')) workspace.getLeftLeaf(true) // empty leaf, no view expect(workspace.getSidebarLeafManifest()).toEqual([]) })})
describe('Workspace restoreSidebarLeaves', () => { it('creates leaves for registered view types', async () => { const workspace = new Workspace(document.createElement('div')) const { ItemView } = await import('../views/item-view')
class TestView extends ItemView { getViewType() { return 'test-view' } getDisplayText() { return 'Test' } } workspace.registerViewType('test-view', (leaf) => new TestView(leaf))
workspace.restoreSidebarLeaves([{ viewType: 'test-view', side: 'left' }]) // setViewState is async, wait for microtask await new Promise(r => setTimeout(r, 10))
const leftLeaves = workspace.getLeftLeaves() expect(leftLeaves.length).toBe(1) expect(leftLeaves[0].view?.getViewType()).toBe('test-view') })
it('skips unregistered view types', async () => { const workspace = new Workspace(document.createElement('div')) workspace.restoreSidebarLeaves([{ viewType: 'nonexistent', side: 'left' }]) await new Promise(r => setTimeout(r, 10)) expect(workspace.getLeftLeaves().length).toBe(0) })
it('skips if leaf of same type already exists on that side', async () => { const workspace = new Workspace(document.createElement('div')) const { ItemView } = await import('../views/item-view')
class TestView extends ItemView { getViewType() { return 'test-view' } getDisplayText() { return 'Test' } } workspace.registerViewType('test-view', (leaf) => new TestView(leaf))
// Pre-create a leaf (simulates built-in plugin onload) const existing = workspace.getLeftLeaf(true) await existing.setViewState({ type: 'test-view' })
workspace.restoreSidebarLeaves([{ viewType: 'test-view', side: 'left' }]) await new Promise(r => setTimeout(r, 10))
// Should still have only 1 leaf, not 2 const leftLeaves = workspace.getLeftLeaves() expect(leftLeaves.length).toBe(1) })
it('restores to correct side', async () => { const workspace = new Workspace(document.createElement('div')) const { ItemView } = await import('../views/item-view')
class RView extends ItemView { getViewType() { return 'right-test' } getDisplayText() { return 'R' } } workspace.registerViewType('right-test', (leaf) => new RView(leaf))
workspace.restoreSidebarLeaves([{ viewType: 'right-test', side: 'right' }]) await new Promise(r => setTimeout(r, 10))
expect(workspace.getLeftLeaves().length).toBe(0) expect(workspace.getRightLeaves().length).toBe(1) expect(workspace.getRightLeaves()[0].view?.getViewType()).toBe('right-test') })})
describe('Workspace.scrollActiveEditorToLine (#237)', () => { // Save and restore globals that we mutate via tests. let origMatchMedia: typeof window.matchMedia | undefined let origVisualViewport: VisualViewport | null | undefined
beforeEach(() => { origMatchMedia = window.matchMedia origVisualViewport = window.visualViewport // Default: no reduced motion. Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), }), }) })
afterEach(() => { if (origMatchMedia) { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: origMatchMedia, }) } Object.defineProperty(window, 'visualViewport', { configurable: true, writable: true, value: origVisualViewport ?? null, }) })
function setupBound(workspace: Workspace, doc: string): { leaf: WorkspaceLeaf view: MarkdownView cm6: EditorView scrollSpy: ReturnType<typeof vi.fn> } { const leaf = workspace.getLeaf(true) const view = new MarkdownView(leaf as never) leaf.view = view const cm6 = new EditorView({ state: EditorState.create({ doc }), }) const adapter = new CM6EditorAdapter() adapter.setView(cm6) attachMarkdownViewEditor(view, adapter) workspace.setActiveLeaf(leaf)
// Spy on the scrollDOM.scrollTo so tests can assert dispatch arguments. const scrollSpy = vi.fn() cm6.scrollDOM.scrollTo = scrollSpy as unknown as typeof cm6.scrollDOM.scrollTo return { leaf, view, cm6, scrollSpy } }
it('returns false and does not throw when there is no active leaf', () => { const ws = new Workspace(document.createElement('div')) expect(ws.scrollActiveEditorToLine(0)).toBe(false) })
it('returns false when active leaf has no MarkdownView', () => { const ws = new Workspace(document.createElement('div')) const leaf = ws.getLeaf(true) ws.setActiveLeaf(leaf) expect(ws.scrollActiveEditorToLine(5)).toBe(false) })
it('calls scrollDOM.scrollTo with computed top when bound', () => { const ws = new Workspace(document.createElement('div')) const { scrollSpy, cm6 } = setupBound(ws, '# A\n# B\n# C\n# D\n# E') const ok = ws.scrollActiveEditorToLine(2) expect(ok).toBe(true) expect(scrollSpy).toHaveBeenCalledTimes(1) const arg = scrollSpy.mock.calls[0][0] expect(arg.behavior).toBe('smooth') expect(typeof arg.top).toBe('number') expect(arg.top).toBeGreaterThanOrEqual(0) cm6.destroy() })
it('clamps line to doc bounds (line >= lineCount → last line)', () => { const ws = new Workspace(document.createElement('div')) const { scrollSpy, cm6 } = setupBound(ws, 'a\nb\nc') // 3 lines const ok = ws.scrollActiveEditorToLine(99) expect(ok).toBe(true) expect(scrollSpy).toHaveBeenCalled() cm6.destroy() })
it('clamps negative line to 0', () => { const ws = new Workspace(document.createElement('div')) const { scrollSpy, cm6 } = setupBound(ws, 'a\nb\nc') const ok = ws.scrollActiveEditorToLine(-5) expect(ok).toBe(true) expect(scrollSpy).toHaveBeenCalled() cm6.destroy() })
it('honors prefers-reduced-motion (smooth → instant)', () => { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn(), }), }) const ws = new Workspace(document.createElement('div')) const { scrollSpy, cm6 } = setupBound(ws, 'a\nb\nc') ws.scrollActiveEditorToLine(2, { behavior: 'smooth' }) expect(scrollSpy).toHaveBeenCalledTimes(1) expect(scrollSpy.mock.calls[0][0].behavior).toBe('instant') cm6.destroy() })
it('applies visualViewport-aware top margin on mobile', () => { Object.defineProperty(window, 'visualViewport', { configurable: true, writable: true, value: { offsetTop: 80, addEventListener: vi.fn(), removeEventListener: vi.fn(), } as unknown as VisualViewport, }) const ws = new Workspace(document.createElement('div')) // Use a doc with many lines so top has room to subtract const doc = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n') const { scrollSpy, cm6 } = setupBound(ws, doc) // Stub lineBlockAt to return a known top so we can compute the expected. const fakeTop = 1000 const origLineBlockAt = cm6.lineBlockAt.bind(cm6) cm6.lineBlockAt = ((pos: number) => ({ ...origLineBlockAt(pos), top: fakeTop })) as typeof cm6.lineBlockAt ws.scrollActiveEditorToLine(10) expect(scrollSpy).toHaveBeenCalled() const arg = scrollSpy.mock.calls[0][0] // top should be fakeTop - (24 baseline + 80 vv offset) = 1000 - 104 = 896 expect(arg.top).toBe(fakeTop - (24 + 80)) cm6.destroy() })
it('getActiveCM6View returns null when no MarkdownView, returns the bound EditorView when set', () => { const ws = new Workspace(document.createElement('div')) expect(ws.getActiveCM6View()).toBeNull()
const { cm6 } = setupBound(ws, 'hello') expect(ws.getActiveCM6View()).toBe(cm6) cm6.destroy() })})
describe('serializeLayout', () => { it('returns empty tabs array when no main-area leaves', () => { const workspace = new Workspace(document.createElement('div')) const result = workspace.serializeLayout() expect(result.tabs).toEqual([]) expect(result.activeIndex).toBe(0) })
it('serializes tabs with docUrls', async () => { // Import dependencies inline to avoid circular import issues at module load time const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files')
const mockDM = { listFolder: vi.fn().mockResolvedValue([]) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace)
// Register a file in vault (simulate existing file) const root = vault.getRoot() const file = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) // Access private fileIndex to register file ;(vault as any).fileIndex.registerFile('automerge:doc1', 'automerge:root', file)
// Create a leaf with file const leaf = workspace.getLeaf(true) leaf.file = file workspace.setActiveLeaf(leaf)
const result = workspace.serializeLayout() expect(result.tabs).toHaveLength(1) expect(result.tabs[0].docUrl).toBe('automerge:doc1') expect(result.activeIndex).toBe(0) app.destroy() })
it('persists moved tab order and the active tab position', async () => { const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files') const mockDM = { listFolder: vi.fn().mockResolvedValue([]) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const root = vault.getRoot() const first = createTFile('first.md', root, { ctime: 0, mtime: 0, size: 0 }) const second = createTFile('second.md', root, { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile('automerge:first', 'automerge:root', first) ;(vault as any).fileIndex.registerFile('automerge:second', 'automerge:root', second) const firstLeaf = workspace.getLeaf(true) firstLeaf.file = first const secondLeaf = workspace.getLeaf(true) secondLeaf.file = second workspace.setActiveLeaf(secondLeaf)
workspace.moveRootLeaf(secondLeaf, 0) expect(workspace.serializeLayout()).toEqual({ tabs: [{ docUrl: 'automerge:second' }, { docUrl: 'automerge:first' }], activeIndex: 0, }) app.destroy() })})
describe('restoreLayout', () => { it('restores many tab identities without opening inactive views, then loads a selected tab on demand', async () => { const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files') const mockDM = { listFolder: vi.fn().mockResolvedValue([]), getFileRoutingInfo: vi.fn(async () => ({ contentType: 'public.markdown' })) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) app.setDocumentManager(mockDM) const files = Array.from({ length: 13 }, (_, index) => { const file = createTFile(`note-${index}.md`, vault.getRoot(), { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile(`automerge:note-${index}`, 'automerge:root', file) return file }) await workspace.restoreLayout({ tabs: files.map((_, index) => ({ docUrl: `automerge:note-${index}` })), activeIndex: 12 }, vault) const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves.map(leaf => leaf.file)).toEqual(files) expect(leaves.slice(0, 12).every(leaf => leaf.isDeferred && leaf.view?.getViewType() === 'reams-deferred-file')).toBe(true) expect(leaves[12]!.isDeferred).toBe(false) expect(workspace.activeLeaf).toBe(leaves[12]) expect(workspace.serializeLayout().tabs).toHaveLength(13) expect(mockDM.getFileRoutingInfo).toHaveBeenCalledTimes(1)
workspace.setActiveLeaf(leaves[1]!) await leaves[1]!.loadIfDeferred() expect(leaves[1]!.file).toBe(files[1]) expect(leaves[1]!.view?.getViewType()).toBe('markdown') expect(workspace.activeLeaf).toBe(leaves[1]) expect(mockDM.getFileRoutingInfo).toHaveBeenCalledTimes(2) app.destroy() })
it('closing a deferred tab during its open cannot publish a zombie view', async () => { const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files') let resolveFirst!: (value: { contentType: string }) => void const pending = new Promise<{ contentType: string }>(resolve => { resolveFirst = resolve }) const mockDM = { listFolder: vi.fn().mockResolvedValue([]), getFileRoutingInfo: vi.fn(async (url: string) => url === 'automerge:deferred-0' ? pending : { contentType: 'public.markdown' }), } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) app.setDocumentManager(mockDM) for (let index = 0; index < 13; index++) { const file = createTFile(`deferred-${index}.md`, vault.getRoot(), { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile(`automerge:deferred-${index}`, 'automerge:root', file) } await workspace.restoreLayout({ tabs: Array.from({ length: 13 }, (_, index) => ({ docUrl: `automerge:deferred-${index}` })), activeIndex: 12 }, vault) const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) const closing = leaves[0]! workspace.setActiveLeaf(closing) await vi.waitFor(() => expect(mockDM.getFileRoutingInfo).toHaveBeenCalledWith('automerge:deferred-0')) workspace.detachLeaf(closing) resolveFirst({ contentType: 'public.markdown' }) await closing.loadIfDeferred() expect(closing.file).toBeNull() const surviving: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => surviving.push(leaf)) expect(surviving).not.toContain(closing) expect(workspace.serializeLayout().tabs).toHaveLength(12) app.destroy() })
it('creates leaves from persisted state', async () => { // Test 2.12: workspace.restoreLayout creates leaves from state const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files')
const mockDM = { listFolder: vi.fn().mockResolvedValue([]) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace)
// Register two files in vault const root = vault.getRoot() const file1 = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) const file2 = createTFile('doc2.md', root, { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile('automerge:doc1', 'automerge:root', file1) ;(vault as any).fileIndex.registerFile('automerge:doc2', 'automerge:root', file2)
// Restore layout with 2 tabs, second one active await workspace.restoreLayout( { tabs: [{ docUrl: 'automerge:doc1' }, { docUrl: 'automerge:doc2' }], activeIndex: 1, }, vault, )
// Verify correct number of leaves const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(2)
// Verify files are correct expect(leaves[0].file).toBe(file1) expect(leaves[1].file).toBe(file2)
// Verify active leaf is the second one (index 1) expect(workspace.activeLeaf).toBe(leaves[1]) expect(workspace.activeLeaf?.file).toBe(file2)
app.destroy() })
it('skips tabs whose files no longer exist', async () => { const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files')
const mockDM = { listFolder: vi.fn().mockResolvedValue([]) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace)
// Register only one file const root = vault.getRoot() const file1 = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile('automerge:doc1', 'automerge:root', file1) // automerge:doc2 is NOT registered
// Restore layout with 2 tabs, one missing await workspace.restoreLayout( { tabs: [{ docUrl: 'automerge:doc1' }, { docUrl: 'automerge:doc2' }], activeIndex: 0, }, vault, )
// Only 1 leaf should exist (the one with valid file) const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(1) expect(leaves[0].file).toBe(file1)
app.destroy() })
it('keeps activeEditor null until the restored active markdown view is ready', async () => { const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files')
let resolveOpen!: () => void const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file?.path === 'doc2.md') { return new Promise<void>((resolve) => { resolveOpen = resolve }) } return Promise.resolve() })
try { const mockDM = { listFolder: vi.fn().mockResolvedValue([]) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace)
const root = vault.getRoot() const file1 = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) const file2 = createTFile('doc2.md', root, { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile('automerge:doc1', 'automerge:root', file1) ;(vault as any).fileIndex.registerFile('automerge:doc2', 'automerge:root', file2)
const restorePromise = workspace.restoreLayout( { tabs: [{ docUrl: 'automerge:doc1' }, { docUrl: 'automerge:doc2' }], activeIndex: 1, }, vault, )
expect(workspace.activeEditor).toBe(null)
await vi.waitFor(() => { expect(typeof resolveOpen).toBe('function') }) resolveOpen() await restorePromise
expect(workspace.activeLeaf?.file).toBe(file2) expect(workspace.activeEditor).toBe(workspace.activeLeaf?.view)
app.destroy() } finally { onOpenSpy.mockRestore() } })
it('detaches a failed restored tab instead of leaving a blank leaf behind', async () => { const { Vault } = await import('../vault') const { FileIndex } = await import('../../services/file-index') const { App } = await import('../app') const { createTFile } = await import('../files')
const mockDM = { listFolder: vi.fn().mockResolvedValue([]) } as any const vault = new Vault('Test', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace)
const root = vault.getRoot() const okFile = createTFile('ok.md', root, { ctime: 0, mtime: 0, size: 0 }) const failingFile = createTFile('failing.md', root, { ctime: 0, mtime: 0, size: 0 }) ;(vault as any).fileIndex.registerFile('automerge:ok', 'automerge:root', okFile) ;(vault as any).fileIndex.registerFile('automerge:failing', 'automerge:root', failingFile)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === failingFile) { return Promise.reject(new Error('view open failed')) } return Promise.resolve() })
try { await expect(workspace.restoreLayout( { tabs: [{ docUrl: 'automerge:ok' }, { docUrl: 'automerge:failing' }], activeIndex: 0, }, vault, )).rejects.toThrow('view open failed')
const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(1) expect(leaves[0].file).toBe(okFile) expect(leaves[0].view).toBeInstanceOf(MarkdownView) } finally { onOpenSpy.mockRestore() consoleSpy.mockRestore() app.destroy() } })})
describe('file-open event deduplication', () => { it('does not fire file-open twice for same file reference', async () => { // Test 4.3: Workspace deduplicates file-open events const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file = createTFile('test.md', root, { ctime: 0, mtime: 0, size: 0 })
// Track file-open events const fileOpenCalls: Array<typeof file | null> = [] workspace.on('file-open', (f) => { fileOpenCalls.push(f) })
// Create leaf with file and set as active const leaf = workspace.getLeaf(true) leaf.file = file workspace.setActiveLeaf(leaf)
// First setActiveLeaf should fire file-open expect(fileOpenCalls).toHaveLength(1) expect(fileOpenCalls[0]).toBe(file)
// Call setActiveLeaf again with same leaf - should NOT fire again workspace.setActiveLeaf(leaf) expect(fileOpenCalls).toHaveLength(1) // Still 1, no duplicate
// Call setActiveLeaf a third time - still no duplicate workspace.setActiveLeaf(leaf) expect(fileOpenCalls).toHaveLength(1) // Still 1 })
it('fires file-open when switching to different file', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file1 = createTFile('test1.md', root, { ctime: 0, mtime: 0, size: 0 }) const file2 = createTFile('test2.md', root, { ctime: 0, mtime: 0, size: 0 })
const fileOpenCalls: Array<typeof file1 | null> = [] workspace.on('file-open', (f) => { fileOpenCalls.push(f) })
// Create first leaf and activate const leaf1 = workspace.getLeaf(true) leaf1.file = file1 workspace.setActiveLeaf(leaf1) expect(fileOpenCalls).toHaveLength(1) expect(fileOpenCalls[0]).toBe(file1)
// Create second leaf and activate - should fire for new file const leaf2 = workspace.getLeaf(true) leaf2.file = file2 workspace.setActiveLeaf(leaf2) expect(fileOpenCalls).toHaveLength(2) expect(fileOpenCalls[1]).toBe(file2)
// Switch back to first leaf - should fire for file1 again (different file) workspace.setActiveLeaf(leaf1) expect(fileOpenCalls).toHaveLength(3) expect(fileOpenCalls[2]).toBe(file1) })})
describe('Workspace.openFile', () => { it('creates a new main-area leaf with MarkdownView when no leaf shows the file', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 })
workspace.openFile(file)
const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(1) expect(workspace.activeLeaf).toBe(leaves[0]) await vi.waitFor(() => { expect(leaves[0].file).toBe(file) expect(leaves[0].view).toBeInstanceOf(MarkdownView) }) })
it('reuses an existing leaf when one already shows the file (dedup by path)', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) const otherFile = createTFile('other.md', root, { ctime: 0, mtime: 0, size: 0 })
// Open `file` then `otherFile` then `file` again — should not create a third leaf. workspace.openFile(file) workspace.openFile(otherFile) workspace.openFile(file)
await vi.waitFor(() => { const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(2) expect(workspace.activeLeaf?.file).toBe(file) }) })
it('retargets a dedup-reused leaf to the newest same-path TFile object', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const firstFile = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) const refreshedFile = createTFile('doc1.md', root, { ctime: 1, mtime: 1, size: 1 })
const fileOpenCalls: Array<typeof firstFile | null> = [] workspace.on('file-open', (file) => { fileOpenCalls.push(file) })
workspace.openFile(firstFile)
await vi.waitFor(() => { expect(workspace.activeLeaf?.file).toBe(firstFile) expect((workspace.activeLeaf?.view as MarkdownView | null)?.file).toBe(firstFile) })
const reusedLeaf = workspace.activeLeaf workspace.openFile(refreshedFile)
await vi.waitFor(() => { const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(1) expect(workspace.activeLeaf).toBe(reusedLeaf) expect(reusedLeaf?.file).toBe(refreshedFile) expect((reusedLeaf?.view as MarkdownView | null)?.file).toBe(refreshedFile) expect(fileOpenCalls).toEqual([firstFile, refreshedFile]) }) })
it('opens separate leaves for same-path files with different stable identities', async () => { const { createTFile, createTFolder } = await import('../files') const root = createTFolder('', null) const firstFile = createTFile('welcome.md', root, { ctime: 0, mtime: 0, size: 0 }) const secondFile = createTFile('welcome.md', root, { ctime: 0, mtime: 0, size: 0 }) const identities = new Map([[firstFile, 'local'], [secondFile, 'foreign']]) const workspace = new Workspace(document.createElement('div'), (file) => identities.get(file) ?? file.path)
workspace.openFile(firstFile) workspace.openFile(secondFile)
await vi.waitFor(() => { const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(2) expect(new Set(leaves.map(leaf => leaf.file))).toEqual(new Set([firstFile, secondFile])) expect(new Set(leaves.map(leaf => (leaf.view as MarkdownView).file))).toEqual(new Set([firstFile, secondFile])) expect(workspace.activeLeaf?.file).toBe(secondFile) })
const renamedForeignFile = createTFile('meeting.md', root, { ctime: 1, mtime: 1, size: 1 }) identities.set(renamedForeignFile, 'foreign') workspace.openFile(renamedForeignFile)
await vi.waitFor(() => { const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(2) expect(workspace.activeLeaf?.file).toBe(renamedForeignFile) expect((workspace.activeLeaf?.view as MarkdownView).file).toBe(renamedForeignFile) }) })
it('keeps activeEditor null until a newly opened markdown view is ready', async () => { const { createTFile, createTFolder } = await import('../files')
let resolveOpen!: () => void const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file?.path === 'doc1.md') { return new Promise<void>((resolve) => { resolveOpen = resolve }) } return Promise.resolve() })
try { const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 })
workspace.openFile(file)
const leaf = workspace.activeLeaf expect(leaf).not.toBeNull() expect(workspace.activeEditor).toBe(null)
resolveOpen() await vi.waitFor(() => { expect(workspace.activeEditor).toBe(leaf?.view) }) } finally { onOpenSpy.mockRestore() } })
it('only publishes successful host opens that remain active', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file1 = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) const file2 = createTFile('doc2.md', root, { ctime: 0, mtime: 0, size: 0 })
const fileOpenCalls: Array<typeof file1 | null> = [] workspace.on('file-open', (f) => { fileOpenCalls.push(f) })
workspace.openFile(file1) workspace.openFile(file2) workspace.openFile(file1) // reuses existing leaf, switches active
// Host-created leaves no longer publish `view.file` while still pending. // The middle open to file2 loses active status before it finishes, so the // only truthful successful publication here is file1. await vi.waitFor(() => { expect(fileOpenCalls).toEqual([file1]) }) })
it('emits active-leaf-change only once after a successful host open becomes truthful', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 })
let resolveOpen!: () => void const activeLeafChanges: Array<WorkspaceLeaf | null> = [] workspace.on('active-leaf-change', (leaf) => { activeLeafChanges.push(leaf) })
const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === file) { return new Promise<void>((resolve) => { resolveOpen = resolve }) } return Promise.resolve() })
try { workspace.openFile(file)
await vi.waitFor(() => { expect(resolveOpen).toBeTypeOf('function') })
expect(activeLeafChanges).toHaveLength(1) expect(activeLeafChanges[0]).toBe(workspace.activeLeaf) expect(workspace.activeLeaf?.file).toBeNull()
resolveOpen()
await vi.waitFor(() => { expect(workspace.activeLeaf?.file).toBe(file) expect(activeLeafChanges).toHaveLength(2) })
expect(activeLeafChanges[1]).toBe(workspace.activeLeaf) } finally { onOpenSpy.mockRestore() } })
it('detaches a failed host-created leaf instead of leaving stale file or view state', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const stableFile = createTFile('stable.md', root, { ctime: 0, mtime: 0, size: 0 }) const failingFile = createTFile('failing.md', root, { ctime: 0, mtime: 0, size: 0 })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === failingFile) { return Promise.reject(new Error('view open failed')) } return Promise.resolve() })
try { workspace.openFile(stableFile) await vi.waitFor(() => { expect(workspace.activeLeaf?.file).toBe(stableFile) }) const stableLeaf = workspace.activeLeaf
workspace.openFile(failingFile)
await vi.waitFor(() => { const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf))
expect(leaves).toHaveLength(1) expect(leaves[0]).toBe(stableLeaf) expect(leaves[0].file).toBe(stableFile) expect(leaves[0].view).toBeInstanceOf(MarkdownView) expect(workspace.activeLeaf).toBe(stableLeaf) }) } finally { onOpenSpy.mockRestore() consoleSpy.mockRestore() } })
it('does not emit a bogus file-open before a host-created open succeeds', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const stableFile = createTFile('stable.md', root, { ctime: 0, mtime: 0, size: 0 }) const failingFile = createTFile('failing.md', root, { ctime: 0, mtime: 0, size: 0 })
const fileOpenCalls: Array<typeof stableFile | null> = [] workspace.on('file-open', (file) => { fileOpenCalls.push(file) })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === failingFile) { return Promise.reject(new Error('view open failed')) } return Promise.resolve() })
try { workspace.openFile(stableFile) await vi.waitFor(() => { expect(fileOpenCalls).toEqual([stableFile]) })
workspace.openFile(failingFile)
await vi.waitFor(() => { const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf))
expect(leaves).toHaveLength(1) expect(leaves[0].file).toBe(stableFile) expect(workspace.activeLeaf).toBe(leaves[0]) })
expect(fileOpenCalls).not.toContain(failingFile) expect(fileOpenCalls[0]).toBe(stableFile) expect(fileOpenCalls[fileOpenCalls.length - 1]).toBe(stableFile) } finally { onOpenSpy.mockRestore() consoleSpy.mockRestore() } })
it('does not emit a premature file-open when host openFile reuses a pending non-host leaf', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const stableFile = createTFile('stable.md', root, { ctime: 0, mtime: 0, size: 0 }) const pendingFile = createTFile('pending.md', root, { ctime: 0, mtime: 0, size: 0 })
const fileOpenCalls: Array<typeof stableFile | null> = [] workspace.on('file-open', (file) => { fileOpenCalls.push(file) })
let resolveOpen!: () => void const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === pendingFile) { return new Promise<void>((resolve) => { resolveOpen = resolve }) } return Promise.resolve() })
try { workspace.openFile(stableFile) await vi.waitFor(() => { expect(fileOpenCalls).toEqual([stableFile]) })
const pendingLeaf = workspace.getLeaf(true) const pendingOpenPromise = pendingLeaf.openFile(pendingFile, { active: false })
await vi.waitFor(() => { expect(resolveOpen).toBeTypeOf('function') expect(pendingLeaf.file).toBeNull() expect((pendingLeaf.view as MarkdownView | null)?.file).toBe(pendingFile) })
workspace.openFile(pendingFile)
const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(2) expect(workspace.activeLeaf).toBe(pendingLeaf) expect(fileOpenCalls).toEqual([stableFile, null]) expect(fileOpenCalls).not.toContain(pendingFile)
resolveOpen() await pendingOpenPromise
await vi.waitFor(() => { expect(fileOpenCalls).toEqual([stableFile, null, pendingFile]) expect(workspace.activeLeaf).toBe(pendingLeaf) expect(pendingLeaf.file).toBe(pendingFile) }) } finally { onOpenSpy.mockRestore() } })
it('publishes the refreshed file object when an active pending host open is retargeted before success', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const staleFile = createTFile('pending.md', root, { ctime: 0, mtime: 0, size: 0 }) const refreshedFile = createTFile('pending.md', root, { ctime: 1, mtime: 1, size: 1 })
const fileOpenCalls: Array<typeof staleFile | null> = [] workspace.on('file-open', (file) => { fileOpenCalls.push(file) })
let resolveOpen!: () => void const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === staleFile) { return new Promise<void>((resolve) => { resolveOpen = resolve }) } return Promise.resolve() })
try { workspace.openFile(staleFile)
await vi.waitFor(() => { expect(resolveOpen).toBeTypeOf('function') expect(workspace.activeLeaf?.file).toBeNull() expect((workspace.activeLeaf?.view as MarkdownView | null)?.file).toBe(staleFile) })
const pendingLeaf = workspace.activeLeaf workspace.openFile(refreshedFile)
expect(workspace.activeLeaf).toBe(pendingLeaf) expect(pendingLeaf?.file).toBeNull() expect((pendingLeaf?.view as MarkdownView | null)?.file).toBe(refreshedFile) expect(fileOpenCalls).toEqual([])
resolveOpen()
await vi.waitFor(() => { expect(workspace.activeLeaf).toBe(pendingLeaf) expect(pendingLeaf?.file).toBe(refreshedFile) expect((pendingLeaf?.view as MarkdownView | null)?.file).toBe(refreshedFile) expect(fileOpenCalls).toEqual([refreshedFile]) }) } finally { onOpenSpy.mockRestore() } })
it('keeps a detached pending host-created leaf dead after its async open resolves', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file = createTFile('pending.md', root, { ctime: 0, mtime: 0, size: 0 })
let resolveOpen!: () => void const onOpenSpy = vi.spyOn(MarkdownView.prototype as unknown as { onOpen: () => Promise<void> }, 'onOpen') .mockImplementation(function (this: MarkdownView) { if (this.file === file) { return new Promise<void>((resolve) => { resolveOpen = resolve }) } return Promise.resolve() })
try { workspace.openFile(file)
await vi.waitFor(() => { expect(resolveOpen).toBeTypeOf('function') expect(workspace.activeLeaf?.file).toBeNull() expect((workspace.activeLeaf?.view as MarkdownView | null)?.file).toBe(file) })
const detachedLeaf = workspace.activeLeaf expect(detachedLeaf).not.toBeNull()
workspace.detachLeaf(detachedLeaf!)
expect(detachedLeaf?.file).toBeNull() expect(detachedLeaf?.view).toBeNull() expect(workspace.activeLeaf).toBeNull()
resolveOpen() await new Promise(resolve => setTimeout(resolve, 0))
expect(detachedLeaf?.file).toBeNull() expect(detachedLeaf?.view).toBeNull()
const leaves: WorkspaceLeaf[] = [] workspace.iterateRootLeaves(leaf => leaves.push(leaf)) expect(leaves).toHaveLength(0) expect(workspace.activeLeaf).toBeNull() } finally { onOpenSpy.mockRestore() } })})
describe('Workspace.getLayout', () => { it('returns a degenerate layout tree with main-area tabs as leaf children', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file1 = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 }) const file2 = createTFile('doc2.md', root, { ctime: 0, mtime: 0, size: 0 })
// Create two main-area tabs workspace.openFile(file1) workspace.openFile(file2)
const layout = workspace.getLayout()
// Structure should be { main: { type: 'split', children: [...] }, active: 'leaf-id' } expect(layout).toHaveProperty('main') const main = layout.main as { type: string; children: Array<{ type: string; id: string }> } expect(main).toHaveProperty('type', 'split') expect(main).toHaveProperty('children') expect(Array.isArray(main.children)).toBe(true) expect(main.children).toHaveLength(2)
// Each child should be a leaf representation for (const child of main.children) { expect(child).toHaveProperty('type', 'leaf') expect(child).toHaveProperty('id') }
// Active should be one of the leaf IDs expect(layout).toHaveProperty('active') expect(typeof layout.active).toBe('string') })
it('returns empty children array when no main-area leaves', () => { const workspace = new Workspace(document.createElement('div'))
const layout = workspace.getLayout()
const main = layout.main as { type: string; children: unknown[] } expect(main.children).toEqual([]) expect(layout.active).toBeNull() })
it('active corresponds to the activeLeaf id', async () => { const { createTFile, createTFolder } = await import('../files')
const workspace = new Workspace(document.createElement('div')) const root = createTFolder('', null) const file1 = createTFile('doc1.md', root, { ctime: 0, mtime: 0, size: 0 })
workspace.openFile(file1)
const layout = workspace.getLayout() expect(layout.active).toBe(workspace.activeLeaf?.id) })
it('includes left/right/floating stubs for Obsidian compatibility', () => { const workspace = new Workspace(document.createElement('div'))
const layout = workspace.getLayout()
// Obsidian's layout includes sidebar properties expect(layout).toHaveProperty('left') expect(layout).toHaveProperty('right') expect(layout).toHaveProperty('floating')
// All should be empty split stubs const left = layout.left as { type: string; children: unknown[] } const right = layout.right as { type: string; children: unknown[] } const floating = layout.floating as { type: string; children: unknown[] }
expect(left).toEqual({ type: 'split', children: [] }) expect(right).toEqual({ type: 'split', children: [] }) expect(floating).toEqual({ type: 'split', children: [] }) })})
describe('Workspace.changeLayout', () => { it('rejects with NotImplementedError with helpful message', async () => { const workspace = new Workspace(document.createElement('div'))
await expect( workspace.changeLayout({ main: { type: 'split', children: [] }, active: null }) ).rejects.toThrow(/does not support split layouts.*serializeLayout.*restoreLayout/i) })
it('includes NotImplementedError in the error name', async () => { const workspace = new Workspace(document.createElement('div'))
try { await workspace.changeLayout({ main: { type: 'split', children: [] }, active: null }) expect.fail('Should have thrown') } catch (err) { expect((err as Error).name).toBe('NotImplementedError') } })
it('returns a Promise (async API matches Obsidian)', () => { const workspace = new Workspace(document.createElement('div'))
const result = workspace.changeLayout({ main: { type: 'split', children: [] }, active: null })
// Should return a Promise, not throw synchronously expect(result).toBeInstanceOf(Promise)
// Suppress unhandled rejection warning result.catch(() => {}) })})