Something went wrong. Try again.
Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657// src/plugin/workspace.tsimport { Events } from './events'import { isComponentLoaded } from './component'import type { EventRef } from './events'import type { View } from './views/view'import type { ViewCreator, OpenViewState, PaneType } from './types'import type { EditorSuggest } from './editor-suggest'import type { Extension } from '@codemirror/state'import { MarkdownView } from './views/markdown-view'import { ItemView } from './views/item-view'import { UnsupportedFileView, UNSUPPORTED_VIEW_TYPE } from './views/unsupported-view'import { CONFORMS_TO, inferContentType, KnownContentTypes } from '../types/content-types'import { invokeViewOnClose, invokeViewOnOpen } from './views/view'import { CM6EditorAdapter } from './cm6-editor'import { stripWikilinkFragment } from '../utils/wikilink-fragment'import type { Editor } from './editor'import type { MarkdownFileInfo } from './obsidian-cm6-stubs'import type { TFile, TAbstractFile } from './files'import type { AutomergeUrl } from '../storage/document-runtime-contract'import type { FileViewResolver, FileViewResolverEntry } from './views/file-view-resolver'import { type Side, type SplitDirection, type Constructor, type Debouncer, type HoverLinkSource, type HoverParent, type ViewState, type IconName, type WorkspaceWindowInitData, WorkspaceItem, WorkspaceSplit, WorkspaceTabs, WorkspaceSidedock, WorkspaceMobileDrawer, WorkspaceRoot, WorkspaceRibbon, WorkspaceWindow, WorkspaceParent, HoverPopover, Menu, Tasks, debounce,} from './obsidian-stubs'
/** * File extensions that Reams treats as non-markdown. Used by * `Workspace.openLinkText`: when a wikilink target ends in one of * these extensions and the file does NOT exist in the vault, the * call is a silent no-op (we don't auto-create binaries or special- * format files). * * Conservative allowlist intentionally — anything not in this set * (e.g., `[[My.Note]]`, `[[v1.2.3]]`, `[[draft.outline]]`) is * auto-created as `<base>.md`, matching Obsidian's behavior. * * Compared at module scope (case-insensitive via `.toLowerCase()` * at the call site) so the set isn't rebuilt on every navigation. */const NON_MARKDOWN_EXTENSIONS: ReadonlySet<string> = new Set([ // Images 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', // Documents 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', // Audio / video 'mp3', 'mp4', 'wav', 'ogg', 'm4a', 'webm', 'mov', 'avi', 'mkv', // Archives 'zip', 'tar', 'gz', '7z', 'rar', // Misc binary 'exe', 'dmg', 'iso', // Obsidian-native non-markdown formats (canvas / excalidraw plugin) 'canvas', 'excalidraw', 'base', // Plain-text formats Obsidian recognizes but doesn't treat as md 'csv', 'json', 'txt', 'yaml', 'yml', 'xml', 'html', // Code formats — wikilinks to source files should not auto-create // a markdown file by their name. 'js', 'ts', 'tsx', 'jsx', 'css', 'scss', 'sass', 'less',])
let leafIdCounter = 0
/** File identity is known, but content-type routing and onOpen are pending. */class DeferredFileView extends ItemView { constructor(leaf: WorkspaceLeaf) { super(leaf) this.contentEl.textContent = 'Opening note…' } getViewType(): string { return 'reams-deferred-file' } getDisplayText(): string { return this.leaf.file?.name ?? 'Loading file…' }}
/** * Single enforcement point for {@link TypedViewHostContract} invariant 1: * resolves the leaf's view to a `MarkdownFileInfo` only when the view is * a ready `MarkdownView`. Returns `null` for non-markdown views (the * typed-view host contract), unready views (lifecycle pending), and * `null` leaves. * * @see {@link TypedViewHostContract} in * `src/plugin/views/typed-view-host-contract.ts` for the full contract, * Patchwork-integration surface, and what is intentionally out of scope. * * @internal Exported for unit testing. Not part of the Obsidian-compatible API. */export function resolveActiveEditor(leaf: WorkspaceLeaf | null): MarkdownFileInfo | null { if (!leaf?.isViewReadyForActiveEditor()) { return null } const view = leaf?.view return view instanceof MarkdownView ? view : null}
/** * Workspace leaf - a single view container. * * Implements HoverParent to support hover popovers for page preview. * * Upstream contract (WorkspaceLeaf extends WorkspaceItem implements HoverParent): * - parent: WorkspaceTabs | WorkspaceMobileDrawer * - view: View * - hoverPopover: HoverPopover | null * - openFile(file: TFile, openState?: OpenViewState): Promise<void> * - open(view: View): Promise<View> * - getViewState(): ViewState * - setViewState(viewState: ViewState, eState?: any): Promise<void> * - get isDeferred: boolean * - loadIfDeferred(): Promise<void> * - getEphemeralState(): any * - setEphemeralState(state: any): void * - togglePinned(): void * - setPinned(pinned: boolean): void * - setGroupMember(other: WorkspaceLeaf): void * - setGroup(group: string): void * - detach(): void * - getIcon(): IconName * - getDisplayText(): string * - onResize(): void * - on('pinned-change', callback): EventRef * - on('group-change', callback): EventRef * * @public */export class WorkspaceLeaf extends WorkspaceItem implements HoverParent { /** * The direct parent of the leaf. * * On desktop, a leaf is always a child of a `WorkspaceTabs` component. * On mobile, a leaf might be a child of a `WorkspaceMobileDrawer`. * Perform an `instanceof` check before making an assumption about the * `parent`. * * NOTE: In Reams' React-based architecture, we stub this as WorkspaceTabs * since we don't use the native Obsidian layout system. * * @public */ parent: WorkspaceMobileDrawer | WorkspaceTabs
/** * The view associated with this leaf. Do not attempt to cast this to your * custom `View` without first checking `instanceof`. * * NOTE: This is typed as non-null to match upstream, but may be * effectively null during construction or after view close in Reams. * * @public */ view: View = null as unknown as View
/** * The hover popover instance attached to this leaf. * @public * @since 0.11.13 */ hoverPopover: HoverPopover | null = null
/** * Unique leaf ID. * @internal */ readonly id: string
/** * The file open in this leaf (set by openFile). * @internal */ file: import('./files').TFile | null = null private _deferredFile: TFile | null = null private _deferredLoad: Promise<void> | null = null
/** @internal Establish the tab identity without loading its document. */ installDeferredFile(file: TFile): void { this.file = file this._deferredFile = file this.view = new DeferredFileView(this) }
/** * Parent workspace. * @internal */ private _workspace: Workspace
/** * Whether this leaf is pinned (prevents auto-replacement). * @internal */ private _pinned: boolean = false
/** * The group this leaf belongs to (for linked pane scrolling). * @internal */ private _group: string = ''
/** * Tracks whether the currently assigned view has finished opening. * Active-editor publication must stay suppressed while onOpen() is pending. * @internal */ private _viewReadyForActiveEditor: boolean = true
/** * Monotonic token for same-leaf view replacement operations. * Later opens supersede earlier async completions. * @internal */ private _viewLifecycleEpoch: number = 0
/** * Monotonic token for same-leaf openFile requests. * Later openFile calls supersede earlier async completions. * @internal */ private _openFileEpoch: number = 0 private _pendingFileOpens = 0 private _fileReplacementLeases = new WeakMap<TAbstractFile, number>()
/** Views participating in an async open on this leaf. */ private readonly _openingViews = new Set<View>()
/** Views closed in a completed or in-flight open lifecycle. */ private readonly _closedViews = new WeakSet<View>()
/** In-flight close promises, so overlapping teardown shares one close. */ private readonly _closingViewPromises = new WeakMap<View, Promise<void>>()
/** * Host-created pending opens must not publish `view.file` as a truthful * active file before the open has actually succeeded. * @internal */ private _suppressPendingViewFilePublication: boolean = false
/** * Reference to the App instance. * Obsidian plugins access this as `leaf.app` to reach the App singleton. * Returns the App from the parent workspace. * @internal */ get app(): import('./app').App | null { return this._workspace.app }
/** * @internal */ constructor(workspace: Workspace) { super() this._workspace = workspace this.id = `leaf-${++leafIdCounter}` // Stub parent as WorkspaceTabs since Reams doesn't use Obsidian's layout system this.parent = new WorkspaceTabs() }
/** * Open a view in this leaf. * If the creator throws or onOpen() rejects, the leaf is left with no view. * * @internal Host-internal. Plugins should use `leaf.setViewState({ type: viewType })` * (Obsidian-shape) which delegates here. Note: behavior differs — `openView` * throws on unknown view types, while `setViewState` logs a warning and * returns silently (matches Obsidian's behavior). */ async openView(viewType: string): Promise<void> { this._deferredFile = null const creator = this._workspace.getViewCreator(viewType) if (!creator) { throw new Error(`View type '${viewType}' is not registered`) }
// Any explicit view transition supersedes a file open that is still // resolving content type before it reaches the shared lifecycle lane. this._openFileEpoch += 1 const operationEpoch = this._beginViewLifecycleTransition() const previousView = this.view
// Clear the active editor before awaiting close so an active leaf never // advertises a view that is already being replaced. if (previousView) { this.view = null as unknown as View this._workspace._syncActiveEditor() if (!this._openingViews.has(previousView)) { try { await this._closeViewOnce(previousView) } catch (err) { if (this._isCurrentViewLifecycleTransition(operationEpoch)) { this._viewReadyForActiveEditor = true this._workspace._syncActiveEditor() throw err } return } } if (!this._isCurrentViewLifecycleTransition(operationEpoch)) { return } } this.file = null
// Create and open new view. If either step fails, ensure the leaf // is left in a clean state (view = null) before re-throwing. let view try { view = creator(this) } catch (err) { if (this._isCurrentViewLifecycleTransition(operationEpoch)) { this._viewReadyForActiveEditor = true this.view = null as unknown as View this._workspace._syncActiveEditor() } throw err } this._openingViews.add(view) if (!this._isCurrentViewLifecycleTransition(operationEpoch)) { this._openingViews.delete(view) await this._closeOpeningView(view) return } this._closedViews.delete(view) this.view = view let openFailed = false let openError: unknown try { await invokeViewOnOpen(view) } catch (error) { openFailed = true openError = error } this._openingViews.delete(view) const stale = !this._isCurrentViewLifecycleTransition(operationEpoch) || this.view !== view if (openFailed || stale) { if (!stale && this.view === view) { this.view = null as unknown as View this._viewReadyForActiveEditor = true this._workspace._syncActiveEditor() } await this._closeOpeningView(view) if (openFailed && !stale) throw openError return } this._viewReadyForActiveEditor = true this._workspace._syncActiveEditor() // Notify listeners that a view changed on this leaf so React // components (e.g. SidebarViewContainer) re-render with the new view. this._workspace.trigger('layout-change') }
/** * Get the display text of the view. * @public */ getDisplayText(): string { return this.view?.getDisplayText() ?? '' }
/** * Get the view type. * * @internal Host-internal. Plugins should use `leaf.getViewState().type` * (Obsidian-shape) or `leaf.view?.getViewType()`. */ getViewType(): string { return this.view?.getViewType() ?? '' }
/** * Detach this leaf from the workspace. * @public */ detach(): void { this._workspace.detachLeaf(this) }
/** * Set the view state for this leaf. * Obsidian-compatible API. Plugins call this to open a specific view type: * leaf.setViewState({ type: 'my-view-type' }) * * Delegates to openView() with the type from the state object. * Returns silently if the view type is not registered (matches Obsidian behavior). * @public */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async setViewState(viewState: ViewState, eState?: any): Promise<void> { if (!viewState?.type) return // If the view type isn't registered, log a warning and return silently. // Obsidian doesn't throw here — it just ignores unknown view types. if (!this._workspace.getViewCreator(viewState.type)) { console.warn(`setViewState: view type '${viewState.type}' is not registered`) return } const baselineEpoch = this._viewLifecycleEpoch const expectedEpoch = baselineEpoch + 1 const openPromise = this.openView(viewState.type) await openPromise const openedView = this.view if (!openedView) return const ownsOpenedView = () => openedView !== null && this.view === openedView && this._isCurrentViewLifecycleTransition(expectedEpoch) && openedView.getViewType() === viewState.type if (!ownsOpenedView()) return
// Persistent state may itself yield to a newer same-leaf operation. if (viewState.state) { const stateResult = openedView.setState(viewState.state, { history: false }) if (stateResult && typeof (stateResult as PromiseLike<unknown>).then === 'function') { await stateResult } if (!ownsOpenedView()) return } // Handle eState only while the exact opened view still owns the leaf. if (eState !== undefined && ownsOpenedView()) { openedView.setEphemeralState(eState) } }
/** * Get the view state for this leaf. * Obsidian-compatible API. * @public */ getViewState(): ViewState { return { type: this.view?.getViewType() ?? '', state: this.view?.getState(), active: this._workspace.activeLeaf === this, pinned: this._pinned, } }
/** * Open a file in this leaf. * Obsidian-compatible API used by plugins to navigate to files. * * Opens a markdown view directly in THIS leaf so the leaf/view/file state is * truthful even when the host has other tab-routing or dedup behavior. * * @param file The TFile to open * @param openState Optional OpenViewState with: * - `active`: when false, does NOT call setActiveLeaf (defaults to true) * - `state`: passed to view.setState() after opening * - `eState.line`: 0-indexed line number for pending scroll * - `eState.subpath`: heading/block fragment for pending scroll * - `group`: unsupported, logs warning * @public */ async openFile(file: TFile, openState?: OpenViewState): Promise<void> { this._pendingFileOpens++ try { await this._openFile(file, openState) } finally { this._pendingFileOpens-- } }
private async _openFile(file: TFile, openState?: OpenViewState): Promise<void> { this._deferredFile = null // Warn if unsupported `group` option is provided if (openState?.group !== undefined) { console.warn('openFile: group option is not supported in Reams (linked-pane scrolling not implemented)') }
const previousFile = this.file ?? (this.view as { file?: import('./files').TFile | null } | null)?.file ?? null const wasActiveLeaf = this._workspace.activeLeaf === this const operationEpoch = ++this._openFileEpoch let pendingScrollRequest: PendingScrollRequest | null = null const clearPendingScrollIfOwned = () => { if (!pendingScrollRequest) { return } const workspaceWithPendingScrolls = this._workspace as unknown as { _pendingScrolls: Map<string, PendingScrollRequest> } if (workspaceWithPendingScrolls._pendingScrolls.get(pendingScrollRequest.filePath) === pendingScrollRequest) { workspaceWithPendingScrolls._pendingScrolls.delete(pendingScrollRequest.filePath) } pendingScrollRequest = null }
// Content-type → view-type dispatch. Look up the file's PushworkFileDocument // by URL (TFile carries no doc/url accessor), read `doc.contentType` (with // inference fallback for transient pre-heal states), and resolve the // registered view type through the workspace registry. The unsupported // fallback is the safety net for unknown content types. // // When `workspace.app` is not wired (bare-workspace test setups), skip the // doc lookup and infer content type from `file.name` directly. This // preserves backward compatibility with workspace-only tests that predate // the routing cutover. const ws = this._workspace const hostApp = ws.app let fileDoc: { contentType?: string; mimeType?: string } | undefined // Best-effort doc lookup: skip when any wiring is absent (vault missing, // documentManager not set, getUrlForPath returns undefined). Errors fall // back to extension/mime inference rather than throwing. try { const fileUrl = hostApp?.vault?.getUrlForFile?.(file) ?? hostApp?.vault?.getUrlForPath?.(file.path) if (hostApp && fileUrl) { const manager = hostApp.reamsDocumentManager fileDoc = manager.getFileRoutingInfo ? await manager.getFileRoutingInfo(fileUrl) : await manager.getFile(fileUrl) // Guard against a concurrent same-leaf open superseding this request // while the async getFile() was in flight. if (this._openFileEpoch !== operationEpoch) { return } } } catch { // Best-effort: DocumentManager may not be wired in bare-workspace tests, // or the file may not exist yet. Fall back to inference from file name + // (optional) mime. The dispatch path below is still authoritative. } if (this._openFileEpoch !== operationEpoch) return // Diagnose schema mismatch: a doc that exposes a contentType key whose // value is the empty string is malformed (Reams writes a non-empty value // on create and heal-on-access fills missing ones). Warn once per file // path so plugin authors can chase the producer; the dispatcher still // recovers by inferring from name + mime so the open never fails. if ( fileDoc !== undefined && typeof fileDoc.contentType === 'string' && fileDoc.contentType.length === 0 && !ws._emptyContentTypeWarned.has(file.path) ) { ws._emptyContentTypeWarned.add(file.path) console.warn( `WorkspaceLeaf.openFile: file '${file.path}' has empty contentType; ` + 'falling back to extension/mime inference. This message logs once per path.', ) } const ct = typeof fileDoc?.contentType === 'string' && fileDoc.contentType.length > 0 ? fileDoc.contentType : inferContentType(file.name, fileDoc?.mimeType) let viewType: string | undefined for (const entry of ws.fileViewResolvers) { try { const resolvedViewType = await entry.resolver.matches(file) if (resolvedViewType !== null && resolvedViewType !== undefined) { viewType = resolvedViewType break } } catch (error) { console.error(`File view resolver '${entry.resolver.id}' failed:`, error) } if (this._openFileEpoch !== operationEpoch) return } viewType ??= ws.getViewTypeForContentType(ct, { fallbackToUnsupported: true }) if (!viewType) { throw new Error(`No view registered for content type '${ct}' (file '${file.path}')`) } const creator = ws.getViewCreator(viewType) if (!creator) { throw new Error(`View creator for '${viewType}' is missing`) } const view = creator(this) if (this._openFileEpoch !== operationEpoch) { this._closeViewOnce(view).catch(error => { console.error(`Error closing detached view '${view.getViewType()}':`, error) }) return } // View types vary; setting `file` is the lifecycle handshake that all // file-aware views (MarkdownView, ImageView, UnsupportedFileView) expect. ;(view as unknown as { file?: TFile | null }).file = file
// Once replacement starts, this leaf no longer truthfully shows the old file. this.file = null try { await this.open(view, operationEpoch) } catch (err) { if (this._openFileEpoch !== operationEpoch) { return } this.setPendingViewFilePublicationSuppressed(false) // If this leaf became active while open() was in flight, the workspace // may have already published `view.file` via the active-leaf listener. // Re-publish the leaf's now-failed state so file-open dedup resets to the // truth (`null` unless some newer winner replaced it). if (this._workspace.activeLeaf === this) { this._workspace.trigger('active-leaf-change', this) } throw err }
// A concurrent same-leaf open superseded this request. if (this._openFileEpoch !== operationEpoch || this.view !== view) { return }
const committedFile = ((this.view as { file?: import('./files').TFile | null } | null)?.file) ?? file this.file = committedFile
// Handle eState scroll requests: subpath wins over line when both provided const eStateSubpath = typeof openState?.eState?.subpath === 'string' ? openState.eState.subpath : null const rawLine = openState?.eState?.line const eStateLine = typeof rawLine === 'number' && Number.isInteger(rawLine) && rawLine >= 0 ? rawLine : null
if (eStateSubpath) { pendingScrollRequest = { filePath: file.path, fragment: eStateSubpath } this._workspace.setPendingScroll(pendingScrollRequest) } else if (eStateLine !== null) { // Encode line as `:N` fragment (same convention as openLinkText) pendingScrollRequest = { filePath: file.path, fragment: `:${eStateLine}` } this._workspace.setPendingScroll(pendingScrollRequest) }
// Pass state to view.setState if provided if (openState?.state) { try { await view.setState(openState.state, { history: false }) } catch (err) { console.warn('openFile: view.setState threw:', err) } if (this._openFileEpoch !== operationEpoch || this.view !== view) { clearPendingScrollIfOwned() return } }
const wasAlreadyActiveAtCommit = this._workspace.activeLeaf === this
// Set this leaf as active only if active !== false. if (openState?.active !== false) { this._workspace.setActiveLeaf(this) }
this.setPendingViewFilePublicationSuppressed(false)
const shouldManuallyRepublishActiveLeaf = (wasActiveLeaf || wasAlreadyActiveAtCommit) && this._workspace.activeLeaf === this
if (shouldManuallyRepublishActiveLeaf && previousFile !== committedFile) { this._workspace.trigger('active-leaf-change', this) } }
/** * Open a view in this leaf. * @public */ async open(view: View, owningOpenFileEpoch?: number): Promise<View> { this._deferredFile = null if (owningOpenFileEpoch === undefined) this._openFileEpoch += 1 else if (this._openFileEpoch !== owningOpenFileEpoch) { await this._closeOpeningView(view) return view } const operationEpoch = this._beginViewLifecycleTransition()
// Clear the active editor immediately so an active leaf never points at // a closing view while replacement is still in flight. const previousView = this.view const previousWasOpening = previousView ? this._openingViews.has(previousView) : false if (previousView) { this.view = null as unknown as View this._workspace._syncActiveEditor() if (!previousWasOpening) { try { await this._closeViewOnce(previousView) } catch (err) { if (this._isCurrentViewLifecycleTransition(operationEpoch)) { this._viewReadyForActiveEditor = true this._workspace._syncActiveEditor() await this._closeOpeningView(view) throw err } await this._closeOpeningView(view) return view } } if (!this._isCurrentViewLifecycleTransition(operationEpoch)) { await this._closeOpeningView(view) return view } } this._closedViews.delete(view) this._openingViews.add(view) this.view = view let openFailed = false let openError: unknown try { await invokeViewOnOpen(view) } catch (err) { openFailed = true openError = err } this._openingViews.delete(view) const stale = !this._isCurrentViewLifecycleTransition(operationEpoch) || this.view !== view if (openFailed || stale) { if (!stale && this.view === view) { this.view = null as unknown as View this._viewReadyForActiveEditor = true this._workspace._syncActiveEditor() } await this._closeOpeningView(view) if (openFailed && !stale) throw openError return view } this._viewReadyForActiveEditor = true this._workspace._syncActiveEditor() this._workspace.trigger('layout-change') return view }
/** @internal */ private _beginViewLifecycleTransition(): number { this._viewLifecycleEpoch += 1 this._viewReadyForActiveEditor = false return this._viewLifecycleEpoch }
/** @internal */ private _isCurrentViewLifecycleTransition(epoch: number): boolean { return this._viewLifecycleEpoch === epoch }
/** @internal */ isViewReadyForActiveEditor(): boolean { return this._viewReadyForActiveEditor }
/** @internal Detect a newer navigation, including an open still awaiting its * body/view while this leaf's settled file has not changed yet. */ getFileOpenRevision(): number { return this._openFileEpoch }
/** @internal An already-started user open must also win over host replacement. */ isFileOpenPending(): boolean { return this._pendingFileOpens > 0 }
/** @internal Scoped exemption from delete-driven tab closing while an atomic * folder-link replacement is reconciled. It does not prevent user navigation. */ preserveFileReplacement(file: TAbstractFile): () => void { this._fileReplacementLeases.set(file, (this._fileReplacementLeases.get(file) ?? 0) + 1) let released = false return () => { if (released) return released = true const remaining = (this._fileReplacementLeases.get(file) ?? 1) - 1 if (remaining) this._fileReplacementLeases.set(file, remaining) else this._fileReplacementLeases.delete(file) } }
/** @internal Used by the host's ordinary Vault-delete tab handler. */ isFileReplacementPreserved(file: TAbstractFile): boolean { return (this._fileReplacementLeases.get(file) ?? 0) > 0 }
/** @internal */ shouldPublishPendingViewFileForActiveLeaf(): boolean { return !this._suppressPendingViewFilePublication }
/** @internal */ setPendingViewFilePublicationSuppressed(suppressed: boolean): void { this._suppressPendingViewFilePublication = suppressed }
/** @internal */ isPendingHostOpen(): boolean { return ( !this._viewReadyForActiveEditor && this.file === null && ((this.view as { file?: import('./files').TFile | null } | null)?.file ?? null) !== null ) }
/** @internal Invalidate all in-flight lifecycle work before detaching. */ _invalidateForDetach(): View[] { this._deferredFile = null this._openFileEpoch += 1 this._viewLifecycleEpoch += 1 this._viewReadyForActiveEditor = true this.file = null this._suppressPendingViewFilePublication = false const views: View[] = [] if (this.view && !this._openingViews.has(this.view)) views.push(this.view) this._openingViews.clear() this.view = null as unknown as View return [...new Set(views)] }
/** @internal Backwards-compatible pending-host invalidation hook. */ invalidatePendingHostOpen(): void { this._invalidateForDetach() }
/** @internal Close an opening candidate after its onOpen lifecycle settles. */ private async _closeOpeningView(view: View): Promise<void> { try { await this._closeViewOnce(view) } catch (error) { console.error(`Error closing view '${view.getViewType()}':`, error) } }
/** @internal Invoke onClose once even when lifecycle races overlap. */ _closeViewOnce(view: View): Promise<void> { const inFlight = this._closingViewPromises.get(view) if (inFlight) return inFlight if (this._closedViews.has(view)) return Promise.resolve() this._closedViews.add(view) let resolveClose!: () => void let rejectClose!: (error: unknown) => void const tracked = new Promise<void>((resolve, reject) => { resolveClose = resolve rejectClose = reject }) this._closingViewPromises.set(view, tracked) tracked.then( () => this._closingViewPromises.delete(view), () => this._closingViewPromises.delete(view), ) try { const componentLoad = !isComponentLoaded(view) ? view.load?.() as void | Promise<void> : undefined const closeResult = componentLoad && typeof (componentLoad as PromiseLike<void>).then === 'function' ? Promise.resolve(componentLoad).then(() => invokeViewOnClose(view)) : invokeViewOnClose(view) Promise.resolve(closeResult).then( () => { try { view.unload?.() resolveClose() } catch (error) { rejectClose(error) } }, (error) => { try { view.unload?.() } catch (unloadError) { console.error(`Error unloading view '${view.getViewType()}':`, unloadError) } rejectClose(error) }, ) } catch (error) { try { view.unload?.() } catch (unloadError) { rejectClose(Object.assign(new Error(`Error closing and unloading view '${view.getViewType()}'`), { errors: [error, unloadError] })) return tracked } rejectClose(error) } return tracked }
/** @internal */ getViewLifecycleEpoch(): number { return this._viewLifecycleEpoch }
/** * Returns true if this leaf is currently deferred because it is in the background. * A deferred leaf will have a DeferredView as its view, instead of the View that * it should normally have for its type (like MarkdownView for the `markdown` type). * * Restored background tabs retain their file identity while the view is deferred. * * @since 1.7.2 * @public */ get isDeferred(): boolean { return this._deferredFile !== null }
/** * If this view is currently deferred, load it and await that it has fully loaded. * * Resolves through the same content-type dispatch as an ordinary open. * * @since 1.7.2 * @public */ async loadIfDeferred(): Promise<void> { if (this._deferredLoad) return this._deferredLoad if (!this._deferredFile) return const file = this._deferredFile const loading = this._workspace.hydrateDeferredLeaf(this, file) this._deferredLoad = loading const clear = () => { if (this._deferredLoad === loading) this._deferredLoad = null } void loading.then(clear, clear) return loading }
/** * Get the ephemeral state (e.g., scroll position, selection). * @public */ // eslint-disable-next-line @typescript-eslint/no-explicit-any getEphemeralState(): any { return this.view?.getEphemeralState?.() ?? {} }
/** * Set the ephemeral state (e.g., scroll position, selection). * @public */ // eslint-disable-next-line @typescript-eslint/no-explicit-any setEphemeralState(state: any): void { this.view?.setEphemeralState?.(state) }
/** * Toggle the pinned state of this leaf. * @public */ togglePinned(): void { this.setPinned(!this._pinned) }
/** * Set the pinned state of this leaf. * @public */ setPinned(pinned: boolean): void { if (this._pinned === pinned) return this._pinned = pinned this.trigger('pinned-change', pinned) }
/** * Set this leaf to be a group member of another leaf. * Linked panes scroll together. * * NOTE: Linked pane scrolling is not yet implemented in Reams. * * @public */ setGroupMember(other: WorkspaceLeaf): void { this.setGroup(other._group) }
/** * Set the group for this leaf. * Linked panes with the same group scroll together. * * NOTE: Linked pane scrolling is not yet implemented in Reams. * * @public */ setGroup(group: string): void { if (this._group === group) return this._group = group this.trigger('group-change', group) }
/** * Get the icon for this leaf's view. * @public */ getIcon(): IconName { return this.view?.getIcon?.() ?? 'document' }
/** * Called when the leaf is resized. * @public */ onResize(): void { this.view?.onResize?.() }
/** * Register a callback for the 'pinned-change' event. * @public */ // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: 'pinned-change', callback: (pinned: boolean) => any, ctx?: any): EventRef /** * Register a callback for the 'group-change' event. * @public */ // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: 'group-change', callback: (group: string) => any, ctx?: any): EventRef // Implementation signature - must be compatible with all overloads // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: string, callback: (...data: any[]) => any, ctx?: any): EventRef { return super.on(name, callback, ctx) }}
/** * Panel side identifier. * @internal */export type PanelSide = 'left' | 'right'
/** * Workspace event types for typed subscriptions. * * These are Obsidian-shape events that remain on `workspace.on(...)`. * Reams-novel events have moved to `app.reamsEvents` (see #395): * - panel-leaf-change, reveal-panel * - left-sidebar-collapsed-change, right-sidebar-collapsed-change * - view-registered, view-unregistered * - editor-extensions-changed * - editor-suggest-registered, editor-suggest-unregistered */export interface WorkspaceEvents { 'active-leaf-change': [leaf: WorkspaceLeaf | null] /** @internal Fired when workspace.activeEditor changes. */ 'active-editor-change': [editor: MarkdownFileInfo | null] 'layout-change': [] 'file-open': [file: import('./files').TFile | null] /** * Fired when changes to an editor have been applied, either programmatically * or from a user event. * Obsidian-compatible: editor is the Editor abstraction, info is either a * MarkdownView or a MarkdownFileInfo-compatible object. */ 'editor-change': [editor: Editor, info: MarkdownView | MarkdownFileInfo] 'cursor-activity': [editor: unknown] /** * Fired when a navigation has scheduled a deferred scroll-to-fragment. * The React layer (AppShell) listens for this and consumes the pending * scroll once the active leaf matches the requested file. Payload is * the same shape returned by `consumePendingScroll()`. * * @internal Host-internal handoff between Workspace and React AppShell. */ 'pending-scroll-set': [pending: PendingScrollRequest] /** * Fired when content is pasted in the editor. * Obsidian-compatible: evt is the raw ClipboardEvent, editor is the * Editor abstraction, info is a MarkdownFileInfo-compatible object. */ 'editor-paste': [evt: ClipboardEvent, editor: Editor, info: MarkdownFileInfo] /** * Fired when content is dropped in the editor. * Obsidian-compatible: evt is the raw DragEvent, editor is the * Editor abstraction, info is a MarkdownFileInfo-compatible object. */ 'editor-drop': [evt: DragEvent, editor: Editor, info: MarkdownFileInfo] /** * Fired when the window is resized (debounced ~150ms). * Obsidian-compatible: no arguments. */ 'resize': [] // --- UNSUPPORTED EVENTS (documented for transparency) --- // 'css-change': not supported — Reams does not have custom CSS infrastructure // 'quick-preview': not supported — Reams does not have hover preview infrastructure // 'window-open': not applicable — PWA architecture has no popout windows // 'window-close': not applicable — PWA architecture has no popout windows}
/** * A scheduled scroll-to-line request triggered by `openLinkText` with a * fragment. The React layer (AppShell) waits for the active leaf's file * to match `filePath`, then consumes the request and runs the scroll on * the next animation frame. * * @internal Host-internal handoff between Workspace and React AppShell. * Reachable as the payload type for the `'pending-scroll-set'` event, * but not a documented public type. */export interface PendingScrollRequest { /** Vault-absolute path of the file the fragment refers to. */ filePath: string /** Heading slug or `^block` id, including the leading `#` / `^`. */ fragment: string}
/** * Workspace class managing the layout and views. * Wraps LayoutContext and exposes Obsidian-compatible API. */export class Workspace extends Events { leftSplit: WorkspaceSidedock | WorkspaceMobileDrawer = new WorkspaceMobileDrawer() rightSplit: WorkspaceSidedock | WorkspaceMobileDrawer = new WorkspaceMobileDrawer() leftRibbon: WorkspaceRibbon = new WorkspaceRibbon() rightRibbon: WorkspaceRibbon = new WorkspaceRibbon() rootSplit: WorkspaceRoot = new WorkspaceRoot()
/** Main container element */ containerEl: HTMLElement
/** * Back-reference to the App instance. Set by App constructor. * @internal */ app: import('./app').App | null = null
/** Whether layout has been initialized */ layoutReady: boolean = false
requestSaveLayout: Debouncer<[], Promise<void>> = debounce(async () => Promise.resolve(), 0, true)
/** * Whether the left sidebar is collapsed (content hidden). * True = collapsed (ribbon-only or fully hidden), false = expanded (full sidebar visible). * * @internal Private backing field. Use getter/setter for access. */ private _leftSidebarCollapsed: boolean = true
/** * Whether the right sidebar/panel is collapsed. * True = collapsed (hidden), false = expanded (visible). * * @internal Private backing field. Use getter/setter for access. */ private _rightSidebarCollapsed: boolean = true
/** * Read the left sidebar collapsed state. * * @internal Host-internal state for React LayoutContext sync. * External plugins should use `workspace.revealLeaf(leaf)` to show sidebar panels. */ get leftSidebarCollapsed(): boolean { return this._leftSidebarCollapsed }
/** * Read the right sidebar collapsed state. * * @internal Host-internal state for React LayoutContext sync. * External plugins should use `workspace.revealLeaf(leaf)` to show sidebar panels. */ get rightSidebarCollapsed(): boolean { return this._rightSidebarCollapsed }
/** * Currently active leaf (global focus — typically the editor leaf). * This is the leaf that has keyboard focus and is returned by * getActiveViewOfType(). Panel leaves can be "visible" (their tab * is selected) without being the globally active leaf. */ activeLeaf: WorkspaceLeaf | null = null
/** * Currently active editor context. * * Plugins can read this to access the active file and app reference * without going through the leaf/view chain. The property is `null` * whenever the active leaf is showing a non-markdown view (per the * typed-view host contract). It carries a `MarkdownFileInfo` only when * the active leaf contains a `MarkdownView` that has completed its * `onOpen()` lifecycle. * * To react to ANY file becoming active (markdown or not), listen to * `'file-open'`. To react to view-type transitions, combine * `'active-leaf-change'` with `leaf.view?.getViewType()`. * * @see {@link TypedViewHostContract} in * `src/plugin/views/typed-view-host-contract.ts` for the full * invariants list, Patchwork-integration surface, and the rules for * editor events, command availability, and markdown-only plugin APIs. * * @public */ activeEditor: MarkdownFileInfo | null = null
/** @internal Recomputes and publishes workspace.activeEditor. */ _syncActiveEditor(): void { if (this.activeLeaf && this._activeEditorSuppressedLeaves.has(this.activeLeaf)) { this._setActiveEditor(null) return } this._setActiveEditor(resolveActiveEditor(this.activeLeaf)) }
/** @internal Updates workspace.activeEditor and emits when it changes. */ _setActiveEditor(editor: MarkdownFileInfo | null): void { if (this.activeEditor === editor) return this.activeEditor = editor this.trigger('active-editor-change', editor) }
/** * Per-panel active leaves — which tab is visible in each sidebar panel. * Orthogonal to activeLeaf (global focus). A panel leaf can be the * visible tab without having global focus. * @internal */ activeLeftLeaf: WorkspaceLeaf | null = null /** * Per-panel active leaves — which tab is visible in each sidebar panel. * Orthogonal to activeLeaf (global focus). A panel leaf can be the * visible tab without having global focus. * @internal */ activeRightLeaf: WorkspaceLeaf | null = null
private leaves: WorkspaceLeaf[] = [] private leftLeaves: WorkspaceLeaf[] = [] private rightLeaves: WorkspaceLeaf[] = [] private viewRegistry: Map<string, ViewCreator> = new Map() private readonly _fileViewResolvers: FileViewResolverEntry[] = [] /** @internal Ordered file-routing seam used by host integrations. */ get fileViewResolvers(): ReadonlyArray<FileViewResolverEntry> { return this._fileViewResolvers } /** * Content-type → view-type bindings. Last-write-wins on duplicate * registration. Lookup walks the CONFORMS_TO chain (see #406). * @internal */ private contentTypeViewRegistry: Map<string, string> = new Map() /** * View-type registered as the fallback when {@link getViewTypeForContentType} * is called with `{ fallbackToUnsupported: true }` and no specific binding * resolves. * @internal */ private unsupportedViewType: string | null = null /** * Per-path guard so a malformed `PushworkFileDocument` whose `contentType` * is an empty string (e.g. a non-Reams Patchwork producer that hasn't been * healed yet) logs once per affected file path. The dispatcher falls back * to `inferContentType(file.name, doc.mimeType)` in that case — without * this warn the schema mismatch would be invisible to plugin authors. * * @internal */ _emptyContentTypeWarned: Set<string> = new Set() private pendingLayoutCallbacks: Array<() => void> = [] private _firingPanelLeafChange = false private _ensureSideLeafPendingOp: WeakMap< WorkspaceLeaf, { viewType: string promise: Promise<void> lifecycleEpoch: number invalidated: boolean followUp: Promise<void> latestFollowUpId: number } > = new WeakMap()
/** Last file emitted via 'file-open' for dedup (reference comparison) */ private _lastFileOpenFile: import('./files').TFile | null = null private _lastOpenFiles: string[] = []
/** * Pending scroll-to-fragment requests, keyed by file path. Set by * `openLinkText` when a fragment is present and consumed by the * React layer once the requested file is the active leaf. * * Per-file slots (one fragment per file at a time) instead of a * global single slot — chained navigations like * `openLinkText('A#h1') → openLinkText('B#h2')` in the same tick * would otherwise drop A's scroll silently. Per-file storage means * each request survives until consumed by its file becoming active, * or overwritten by another request for the SAME file. * (Adversary review C-NEW-1: chainlink #313.) */ private _pendingScrolls: Map<string, PendingScrollRequest> = new Map()
/** * Leaves currently being bulk-removed. Suppresses activeEditor rebound when * active-leaf fallback temporarily lands on another doomed leaf mid-batch. */ private _activeEditorSuppressedLeaves: Set<WorkspaceLeaf> = new Set() private readonly _leafPreTeardown = new WeakMap<WorkspaceLeaf, Promise<void>>()
/** * Private backing array for editorSuggests. Mutated only via * registerEditorSuggest / unregisterEditorSuggest. */ private _editorSuggests: EditorSuggest<unknown>[] = []
/** * Read-only view of registered EditorSuggest instances. * The dispatcher iterates this on every keystroke. * * Returns ReadonlyArray to prevent external mutation — callers cannot * push/splice/pop the array, enforcing that all registration goes through * registerEditorSuggest (which deduplicates). * * @internal */ get editorSuggests(): ReadonlyArray<EditorSuggest<unknown>> { return this._editorSuggests }
/** * Registry of CM6 extensions registered by plugins via registerEditorExtension. * Keyed by plugin manifest ID. Multiple extensions per plugin are stored as array. * @see registerEditorExtension, unregisterEditorExtensions */ private editorExtensionsRegistry: Map<string, Extension[]> = new Map()
/** Bound resize handler for cleanup in destroy() */ private _resizeHandler: (() => void) | null = null /** Debounce timer for resize events */ private _resizeTimer: ReturnType<typeof setTimeout> | null = null /** Flag to prevent resize events after destroy (race condition guard) */ private _destroyed = false
// Typed event overloads on(name: 'quick-preview', callback: (file: TFile, data: string) => any, ctx?: any): EventRef on(name: 'resize', callback: () => any, ctx?: any): EventRef on(name: 'active-leaf-change', callback: (leaf: WorkspaceLeaf | null) => any, ctx?: any): EventRef /** @internal Reams-internal event. */ on(name: 'active-editor-change', callback: (editor: MarkdownFileInfo | null) => any, ctx?: any): EventRef on(name: 'file-open', callback: (file: TFile | null) => any, ctx?: any): EventRef on(name: 'layout-change', callback: () => any, ctx?: any): EventRef on(name: 'window-open', callback: (win: WorkspaceWindow, window: Window) => any, ctx?: any): EventRef on(name: 'window-close', callback: (win: WorkspaceWindow, window: Window) => any, ctx?: any): EventRef on(name: 'css-change', callback: () => any, ctx?: any): EventRef on(name: 'file-menu', callback: (menu: Menu, file: import('./files').TAbstractFile, source: string, leaf?: WorkspaceLeaf) => any, ctx?: any): EventRef on(name: 'files-menu', callback: (menu: Menu, files: import('./files').TAbstractFile[], source: string, leaf?: WorkspaceLeaf) => any, ctx?: any): EventRef on(name: 'url-menu', callback: (menu: Menu, url: string) => any, ctx?: any): EventRef on(name: 'editor-menu', callback: (menu: Menu, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef on(name: 'editor-change', callback: (editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef on(name: 'editor-paste', callback: (evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef on(name: 'editor-drop', callback: (evt: DragEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef on(name: 'quit', callback: (tasks: Tasks) => any, ctx?: any): EventRef /** @internal Reams-internal event. */ on(name: 'pending-scroll-set', callback: (pending: PendingScrollRequest) => any, ctx?: any): EventRef /** @internal Reams-internal event. */ on(name: 'cursor-activity', callback: (editor: unknown) => any, ctx?: any): EventRef /** @internal Reams-internal event. */ on(name: 'view-unregistered', callback: (viewType: string) => any, ctx?: any): EventRef on(name: string, callback: (...data: any[]) => any, ctx?: any): EventRef { return super.on(name, callback, ctx) }
/** * Fire an event on `app.reamsEvents`. * * Helper that routes Reams-novel events to the dedicated events bus. * No-ops silently if app is not yet wired (during early construction). * * @internal Host-internal. Reams-novel events fire here, not on Workspace. */ private _triggerReamsEvent(name: string, ...args: unknown[]): void { this.app?.reamsEvents.trigger(name, ...args) }
/** * @internal */ constructor( containerEl: HTMLElement, private readonly _getFileIdentity: (file: TFile) => unknown = (file) => file.path, ) { super() this.containerEl = containerEl
// Internal listener: fire 'file-open' when active leaf's file changes. // Uses reference equality for dedup — fires once per distinct file object. // This is the SINGLE source of truth for 'file-open' events. // (AppShell's file-open effect is being deleted in the same commit.) this.on('active-leaf-change', (leaf) => { const file = leaf?.file ?? (leaf?.shouldPublishPendingViewFileForActiveLeaf() ? (leaf.view as { file?: import('./files').TFile | null } | null)?.file ?? null : null) if (file !== this._lastFileOpenFile) { this._lastFileOpenFile = file if (file) { this._lastOpenFiles = [file.path, ...this._lastOpenFiles.filter(path => path !== file.path)].slice(0, 10) } this.trigger('file-open', file) } })
// Debounced resize handler — fires 'resize' event ~150ms after the last // resize event settles. Matches Obsidian's behavior (debounced, no args). this._resizeHandler = () => { // Guard against race: event queued before destroy() but fired after if (this._destroyed) return if (this._resizeTimer !== null) { clearTimeout(this._resizeTimer) } this._resizeTimer = setTimeout(() => { this._resizeTimer = null // Double-check in case destroy() was called during debounce if (!this._destroyed) { this.trigger('resize') } }, 150) } window.addEventListener('resize', this._resizeHandler)
// Register core views unconditionally so dispatch can resolve them at any // time — markdown is no longer a host-side special case (#406). this._registerCoreViews() }
/** * @internal Host-only. Registers the workspace-owned core views that the * routing dispatcher depends on: * - `markdown` view + bind `public.markdown` * - `reams-unsupported` view + pin it as the routing fallback * * Reams-novel events fired during these registrations are optional-chained * (`_triggerReamsEvent`) so it is safe to call during construction before * `this.app` is wired up. */ private _registerCoreViews(): void { this.registerViewType('markdown', (leaf) => new MarkdownView(leaf)) this.registerViewType(UNSUPPORTED_VIEW_TYPE, (leaf) => new UnsupportedFileView(leaf)) this.registerViewTypeForContentType(KnownContentTypes.Markdown, 'markdown') this._registerUnsupportedFallback(UNSUPPORTED_VIEW_TYPE) }
/** * Clean up workspace resources (resize listener, etc.). * Called on unmount or when the workspace is discarded. * * Note: This follows the same pattern as other App-owned resources — * it's intentionally NOT called from App.destroy() because App survives * React StrictMode unmount/remount cycles. The resize listener is only * truly cleaned up when the page unloads. The `_destroyed` flag prevents * the handler from firing events after an explicit destroy() call. * * @internal Host-internal lifecycle. Plugins must not call this — it is * invoked by the Reams host when tearing down the workspace. */ destroy(): void { if (this._destroyed) return this._destroyed = true const ownedLeaves = [...new Set([...this.leaves, ...this.leftLeaves, ...this.rightLeaves])] const viewsToClose = ownedLeaves.flatMap(leaf => leaf._invalidateForDetach().map(view => ({ leaf, view })), ) this.leaves = [] this.leftLeaves = [] this.rightLeaves = [] this.activeLeaf = null this.activeLeftLeaf = null this.activeRightLeaf = null this._syncActiveEditor() for (const leaf of ownedLeaves) { const pending = this._ensureSideLeafPendingOp.get(leaf) if (pending) { pending.invalidated = true this._ensureSideLeafPendingOp.delete(leaf) } } const closedViews = new Set<View>() for (const { leaf, view } of viewsToClose) { if (closedViews.has(view)) continue closedViews.add(view) try { void leaf._closeViewOnce(view).catch(error => { console.error(`Error closing view '${view.getViewType()}':`, error) }) } catch (error) { console.error(`Error closing view '${view.getViewType()}':`, error) } } this.pauseDeferredViews() if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler) this._resizeHandler = null } if (this._resizeTimer !== null) { clearTimeout(this._resizeTimer) this._resizeTimer = null } this._fileViewResolvers.length = 0 }
/** * Register a file resolver. Resolvers run in registration order and the * returned cleanup removes only this exact registration. * @internal Host integration seam. Plugin lifecycle code owns the cleanup. */ registerFileViewResolver(resolver: FileViewResolver): () => void { if (this._fileViewResolvers.some(entry => entry.resolver.id === resolver.id)) { throw new Error(`File view resolver '${resolver.id}' is already registered`) } const entry: FileViewResolverEntry = { resolver } this._fileViewResolvers.push(entry) void this._refreshUnsupportedFileViews(entry) let registered = true return () => { if (!registered) return registered = false const index = this._fileViewResolvers.indexOf(entry) if (index !== -1) this._fileViewResolvers.splice(index, 1) } }
private async _refreshUnsupportedFileViews(entry: FileViewResolverEntry): Promise<void> { const leaves = this.getLeavesOfType('reams-unsupported') for (const leaf of leaves) { if (!this._fileViewResolvers.includes(entry)) return const file = leaf.file if (!file) continue try { const viewType = await entry.resolver.matches(file) if ( !viewType || !this.getViewCreator(viewType) || leaf.file !== file || leaf.view?.getViewType() !== 'reams-unsupported' ) continue await leaf.openFile(file, { active: false }) } catch (error) { console.error(`File view resolver '${entry.resolver.id}' failed while refreshing '${file.path}':`, error) } } }
/** * Run callback when layout is ready. * * ## Reams vs Obsidian behavior difference * * In Obsidian, `onLayoutReady` callbacks fire exactly once — the first * time layout becomes ready. If a plugin is disabled and re-enabled * while the app is running, Obsidian does NOT re-fire the callback. * * In Reams, this method fires synchronously on every registration when * `layoutReady` is already true. This is intentional: * * 1. **React StrictMode double-mount:** In dev mode, React unmounts and * remounts components. Plugins see two `onload()` cycles. The second * `onLayoutReady()` call must still work (synchronously), or the * plugin's post-layout setup never runs. * * 2. **Plugin reload cycles:** When a user disables and re-enables a * plugin at runtime, the plugin's `onload()` runs again. Layout is * already ready, so the callback should fire immediately. * * Obsidian's fire-once semantics assume plugins are loaded once at * startup. Reams' React architecture requires per-mount callback firing. * Plugins should write idempotent callbacks (safe to call multiple * times) to be compatible with both environments. */ onLayoutReady(callback: () => any): void { if (this.layoutReady) { try { callback() } catch (err) { console.error('onLayoutReady callback threw:', err) } } else { this.pendingLayoutCallbacks.push(callback) } }
/** * Mark layout as ready and run pending callbacks. * * Idempotent: once `layoutReady` is `true` it stays `true`. Subsequent * calls drain any newly-queued pending callbacks but do not reset the * flag. This means `onLayoutReady()` callbacks registered *after* the * first `setLayoutReady()` call execute immediately (synchronously) at * registration time, which is the correct behavior for plugin reload * cycles (e.g. React StrictMode double-mount). * * @internal Host-internal lifecycle. Plugins should use `workspace.onLayoutReady(cb)` * (Obsidian-shape) to register callbacks that fire when layout is ready. */ setLayoutReady(): void { this.layoutReady = true for (const cb of this.pendingLayoutCallbacks) { try { cb() } catch (err) { console.error('onLayoutReady callback threw:', err) } } this.pendingLayoutCallbacks = [] }
/** * Get a workspace leaf. * If newLeaf is false, returns an existing leaf. * If newLeaf is true or 'tab', creates a new leaf. */ getLeaf(newLeaf?: 'split', direction?: SplitDirection): WorkspaceLeaf getLeaf(newLeaf?: PaneType | boolean): WorkspaceLeaf getLeaf(newLeaf?: PaneType | boolean, _direction?: SplitDirection): WorkspaceLeaf { if (!newLeaf && this.leaves.length > 0) { return this.leaves[0] } const leaf = new WorkspaceLeaf(this) this.leaves.push(leaf) return leaf }
/** Move an existing main-area tab without replacing its leaf or view. * @internal Host tab-bar operation; not part of the Obsidian plugin API. */ moveRootLeaf(leaf: WorkspaceLeaf, destinationIndex: number): void { const from = this.leaves.indexOf(leaf) if (from < 0 || !Number.isInteger(destinationIndex) || destinationIndex < 0 || destinationIndex >= this.leaves.length || from === destinationIndex) return
this.leaves.splice(from, 1) this.leaves.splice(destinationIndex, 0, leaf) this.trigger('layout-change') }
/** * Create a leaf in a parent split at the given index. */ createLeafInParent(_parent: WorkspaceSplit, _index: number): WorkspaceLeaf { return this.getLeaf(true) }
createLeafBySplit(_leaf: WorkspaceLeaf, _direction?: SplitDirection, _before?: boolean): WorkspaceLeaf { return this.getLeaf(true) }
/** * Set the active leaf. */ setActiveLeaf(leaf: WorkspaceLeaf, pushHistory: boolean, focus: boolean): void setActiveLeaf(leaf: WorkspaceLeaf, params?: { focus?: boolean }): void setActiveLeaf( leaf: WorkspaceLeaf, _pushHistoryOrParams?: boolean | { focus?: boolean }, _focus?: boolean, ): void { if (!this.leaves.includes(leaf)) return const oldLeaf = this.activeLeaf this.activeLeaf = leaf if (leaf.isDeferred) void leaf.loadIfDeferred().catch(error => console.error('Could not open deferred tab:', error)) this._syncActiveEditor() if (oldLeaf !== leaf) { this.trigger('active-leaf-change', leaf) } }
/** * Set the left sidebar collapsed state. * Fires 'left-sidebar-collapsed-change' when the state changes. * React's LayoutContext subscribes to sync its UI state. * * Note: `setLeftSidebarCollapsed(true)` may be a no-op when React is in * 'ribbon' mode, since the sidebar content is already hidden in that mode. * The asymmetry is intentional — "collapsed" means sidebar content is not * visible, regardless of whether the ribbon is showing. * * @internal Host-internal wiring between Workspace and React LayoutContext. * External plugins should use `workspace.revealLeaf(leaf)` to show sidebar panels, * which calls this internally. * * @param collapsed true = collapsed (content hidden), false = expanded (full sidebar) */ setLeftSidebarCollapsed(collapsed: boolean): void { if (this._leftSidebarCollapsed === collapsed) return this._leftSidebarCollapsed = collapsed this._triggerReamsEvent('left-sidebar-collapsed-change', collapsed) }
/** * Set the right sidebar/panel collapsed state. * Fires 'right-sidebar-collapsed-change' when the state changes. * React's LayoutContext subscribes to sync its UI state. * * @internal Host-internal wiring between Workspace and React LayoutContext. * External plugins should use `workspace.revealLeaf(leaf)` to show sidebar panels, * which calls this internally. * * @param collapsed true = collapsed (hidden), false = expanded (visible) */ setRightSidebarCollapsed(collapsed: boolean): void { if (this._rightSidebarCollapsed === collapsed) return this._rightSidebarCollapsed = collapsed this._triggerReamsEvent('right-sidebar-collapsed-change', collapsed) }
/** * Open a file in a main-area leaf. * * Tab-aware: if a main-area leaf already shows this file, that leaf is * activated. Otherwise a new leaf is created with a MarkdownView and the * file is attached. Either way, the resulting leaf becomes the active * leaf (firing 'active-leaf-change' which the workspace's internal * listener uses to fire 'file-open' with dedup). * * Callers (FileTree, AppShell, etc.) should use this method rather than * going through the navigateToFile / WorkspaceLeaf.openFile compat path, * which exists only to bridge the Obsidian plugin API. * * @internal */ openFile(file: import('./files').TFile): void { const leaf = this._ensureMainLeafForFile(file) this.setActiveLeaf(leaf) }
/** @internal Resolve the main-area leaf for a file, creating one if needed. */ private _ensureMainLeafForFile(file: import('./files').TFile): WorkspaceLeaf { const existingLeaf = this._findRootLeafForFile(file) if (existingLeaf) { this._retargetHostOpenLeafFile(existingLeaf, file) return existingLeaf }
const leaf = this.getLeaf(true) this._startOpeningLeaf(leaf, file) return leaf }
/** @internal Finds a root leaf showing or truthfully opening a file identity. */ private _findRootLeafForFile(file: TFile): WorkspaceLeaf | null { const identity = this._getFileIdentity(file) let existingLeaf: WorkspaceLeaf | null = null this.iterateRootLeaves((leaf) => { const leafFile = this._getLeafFileForHostOpen(leaf) if (!existingLeaf && leafFile && this._getFileIdentity(leafFile) === identity) { existingLeaf = leaf } }) return existingLeaf }
/** @internal Pending opens resolve from view.file; settled leaves from leaf.file. */ private _getLeafFileForHostOpen(leaf: WorkspaceLeaf): TFile | null { if (leaf.file) return leaf.file if (leaf.isViewReadyForActiveEditor()) return null return (leaf.view as { file?: TFile | null } | null)?.file ?? null }
/** @internal Pending opens dedup by view.file; settled leaves dedup by leaf.file. */ private _getLeafFilePathForHostOpen(leaf: WorkspaceLeaf): string | null { return this._getLeafFileForHostOpen(leaf)?.path ?? null }
/** @internal Keep a dedup-reused host leaf truthful to the latest TFile object. */ private _retargetHostOpenLeafFile( leaf: WorkspaceLeaf, file: import('./files').TFile, ): void { const previousPublishedFile = leaf.file ?? (leaf.shouldPublishPendingViewFileForActiveLeaf() ? (leaf.view as { file?: import('./files').TFile | null } | null)?.file ?? null : null)
const leafView = leaf.view as { file?: import('./files').TFile | null } | null const identity = this._getFileIdentity(file) const reusingPendingLeaf = leaf.file === null && !leaf.isViewReadyForActiveEditor() && leafView?.file !== null && leafView?.file !== undefined && this._getFileIdentity(leafView.file) === identity
if (reusingPendingLeaf) { leaf.setPendingViewFilePublicationSuppressed(true) }
if (leaf.file && this._getFileIdentity(leaf.file) === identity) { leaf.file = file }
if (leafView?.file && this._getFileIdentity(leafView.file) === identity) { leafView.file = file if (leaf.file === null && leaf.isPendingHostOpen()) { // Same-path host dedup reuses the in-flight open, so the pending view // must carry the freshest TFile object through to eventual commit. leaf.view = leafView as View } }
if (reusingPendingLeaf && this.activeLeaf === leaf && previousPublishedFile !== null) { this.trigger('active-leaf-change', leaf) return }
if (this.activeLeaf === leaf && previousPublishedFile !== file && leaf.file === file) { this.trigger('active-leaf-change', leaf) } }
/** * @internal Open a file in a leaf through the truthful lifecycle. View-type * resolution happens inside `WorkspaceLeaf.openFile` via the content-type * registry — this helper just owns the suppression/error-recovery semantics. */ private async _openLeaf( leaf: WorkspaceLeaf, file: import('./files').TFile, ): Promise<void> { leaf.setPendingViewFilePublicationSuppressed(true) try { await leaf.openFile(file, { active: false }) } catch (err) { this._flushPendingScrollIfOrphaned(file.path) if (this.leaves.includes(leaf)) { this.detachLeaf(leaf) } throw err } finally { leaf.setPendingViewFilePublicationSuppressed(false) } }
/** * @internal Fire-and-forget wrapper around {@link _openLeaf} for the sync * open paths that must return a leaf synchronously to their callers * (`_ensureMainLeafForFile`, `_resolveLeafForOpen`). Async callers should * call `_openLeaf` directly so they can await the open. The plan's helper * consolidation (#406 Task 6) intentionally retains this thin wrapper — * deleting it would push the void-promise + catch boilerplate to two * separate call sites. */ private _startOpeningLeaf( leaf: WorkspaceLeaf, file: import('./files').TFile, ): void { void this._openLeaf(leaf, file).catch((err) => { console.error(`openFile: failed to open '${file.path}':`, err) }) }
/** * Obsidian-compatible navigation primitive. * * Resolves a wikilink-style `linktext` (with optional `#heading` / * `^block` fragment) and opens the resulting file in a leaf. * * await app.workspace.openLinkText('Some Note', 'current/path.md') * await app.workspace.openLinkText('Note#Section', 'src.md', true) * await app.workspace.openLinkText('#heading', 'src.md') // same-doc nav * * Resolution rules: * - Strip fragment first. * - Pure-fragment (`#heading` with empty base): use the active file * as the target. No-op if no active file. * - Otherwise resolve the base via * `metadataCache.getFirstLinkpathDest(base, sourcePath)`. * - If unresolved AND target is markdown (no extension or `.md`): * auto-create the markdown file (using TemplatePlugin if * available). This matches the long-standing wikilink-click * behavior. * - If unresolved AND target has a non-markdown extension: silent * no-op (don't auto-create binaries). * * Open semantics: * - `newLeaf=true`: dedup by path — focus existing leaf if any, * otherwise create a new tab via `getLeaf('tab')`. * - `newLeaf` falsy: route through `workspace.openFile(file)` which * reuses the active editor leaf when possible. * - `openViewState.active === false`: open without focus shift * (does NOT call `setActiveLeaf`). * - `openViewState.eState.subpath` overrides the linktext fragment * when both are present. * * Fragment scroll: * - When a fragment is present (from linktext or eState.subpath), * `setPendingScroll` is called with the resolved file path. The * React layer (AppShell) consumes this on `active-leaf-change` * and schedules a `scrollActiveEditorToLine` once the editor has * mounted. * - Same-doc + fragment or eState.line: skip the open entirely; only * schedule the scroll. Avoids tab flicker. */ async openLinkText( linktext: string, sourcePath: string, newLeaf?: PaneType | boolean, openViewState?: OpenViewState, ): Promise<void> { if (!this.app) { console.warn('openLinkText: no App attached to workspace') return } const trimmed = linktext.trim() if (!trimmed) return
const { base, fragment: linkFragment } = stripWikilinkFragment(trimmed) // openViewState.eState.subpath wins over the linktext fragment when both // are supplied — the explicit eState payload reflects caller intent. const eStateSubpath = typeof openViewState?.eState?.subpath === 'string' ? (openViewState.eState.subpath as string) : null const fragment = eStateSubpath || linkFragment
// Snapshot the active file BEFORE any awaits. Used for: // 1. Pure-fragment target → scroll within currently-active file. // 2. Same-doc short-circuit → skip open if target == active. // Reading getActiveFile() AFTER `await vault.create(...)` would race // with concurrent navigations (user clicks elsewhere during the // await) — we'd compare the post-await active file to the freshly // resolved file, mis-classifying the situation. // (Adversary review C2/C3: chainlink #313.) const activeFileSnapshot = this.getActiveFile()
// Pure-fragment target: scroll within the active file. if (!base && fragment) { if (!activeFileSnapshot) return this.setPendingScroll({ filePath: activeFileSnapshot.path, fragment }) return } if (!base) return
// Resolve via metadata cache (sibling-relative + fallbacks). let file = this.app.metadataCache.getFirstLinkpathDest(base, sourcePath)
if (!file) { // Auto-create markdown when the target looks like markdown. // // The "looks like a non-markdown asset" test uses an allowlist // of known extensions, NOT the previous "anything with a dot" // heuristic. Filenames like `My.Note`, `v1.2.3-spec`, or // `2024-Q1.budget` are legitimate markdown documents in // Obsidian — Reams must not silently refuse to create them. // (Adversary review C6 + m-NEW-1: chainlink #313.) const dotIdx = base.lastIndexOf('.') const trailingExt = dotIdx >= 0 ? base.slice(dotIdx + 1).toLowerCase() : '' if (trailingExt && NON_MARKDOWN_EXTENSIONS.has(trailingExt)) { // Known non-markdown extension that doesn't exist in the // vault — silent no-op (don't auto-create binary or special // file types we can't open). return } // Always normalize to a markdown extension. Two cases pass through // as-is (after case normalization): // - `.md` (the canonical extension) // - `.markdown` (Obsidian also recognizes this as markdown and // creates with the literal extension) // Anything else gets `.md` appended. const endsWithMd = /\.md$/i.test(base) const endsWithMarkdown = /\.markdown$/i.test(base) let path: string if (endsWithMd) { path = base.replace(/\.md$/i, '.md') } else if (endsWithMarkdown) { path = base.replace(/\.markdown$/i, '.markdown') } else { path = `${base}.md` } try { // Prefer TemplatePlugin if available so new docs respect templates. // We type-check the duck shape because the plugin lookup returns // unknown — same guard the wikilink plugin used previously. const templatePlugin = this.app.plugins.getPlugin('reams-templates') as unknown if ( templatePlugin && typeof templatePlugin === 'object' && typeof (templatePlugin as { createFileFromTemplate?: unknown }).createFileFromTemplate === 'function' ) { file = await (templatePlugin as { createFileFromTemplate: (path: string, ctx: { now: Date }) => Promise<import('./files').TFile> }).createFileFromTemplate(path, { now: new Date() }) } else { file = await this.app.vault.create(path, '') } } catch (err) { console.error(`openLinkText: failed to create '${path}':`, err) return } }
// Same-doc short-circuit (avoids tab flicker for `[[Note#h]]` clicks // when Note was already active). Uses the pre-await snapshot so the // decision reflects the user's intent at click time, not whatever // the active file is after vault.create resolved. const isSameDoc = activeFileSnapshot?.path === file.path const active = openViewState?.active !== false // default true
// Validate the requested line before same-doc navigation too: backlink // rows can point into an already-active source document. const rawLine = openViewState?.eState?.line const eStateLine = typeof rawLine === 'number' && Number.isInteger(rawLine) && rawLine >= 0 ? rawLine : null
if (isSameDoc) { if (fragment) { this.setPendingScroll({ filePath: file.path, fragment }) } else if (eStateLine !== null) { this.setPendingScroll({ filePath: file.path, fragment: `:${eStateLine}` }) } return }
// Schedule a scroll request when the caller has asked for one. There // are two ways to request a scroll: // - `fragment` (from linktext or openViewState.eState.subpath) → // resolved against the file's heading slugs by AppShell. // - `openViewState.eState.line` (Obsidian's footnote/back-link // pattern) → 0-indexed line number; passed verbatim through // the scroll request so AppShell can call // scrollActiveEditorToLine without doing a slug lookup. // // Pre-arming the request before active-leaf-change fires avoids a // race where the activate event lands before the consumer is set // up. AppShell still gates consumption on the active path matching // pending.filePath — so an over-eager request for a leaf that // never opens is harmless (the next openLinkText overwrites it). // Validate eState.line: must be a finite, non-negative integer. // NaN, Infinity, negative, fractional values are treated as // absent and don't drive a scroll request. // (Adversary review M-NEW-2.) // Capture the request we're about to write so we can do a // conditional cleanup if leaf resolution fails. Using object // identity prevents the cleanup from blowing away a different // (still-valid) request that happens to share the same file path. // (Adversary review C3-1: chainlink #313.) let ownRequest: PendingScrollRequest | null = null if (fragment) { ownRequest = { filePath: file.path, fragment } this.setPendingScroll(ownRequest) } else if (eStateLine !== null) { // Encode the line as a synthetic fragment-style request so the // consumer keeps a single API. AppShell distinguishes line vs // heading via the leading marker: `:42` means line 42 (0-indexed). ownRequest = { filePath: file.path, fragment: `:${eStateLine}` } this.setPendingScroll(ownRequest) }
const leaf = this._resolveLeafForOpen( file, newLeaf === true || newLeaf === 'tab' || newLeaf === 'split' || newLeaf === 'window', ) if (!leaf) { // _resolveLeafForOpen returned null — the React tab system // didn't surface a leaf for the file (e.g. setNavigateHandler // unwired during a remount). Drop our pending scroll request // (if it's still ours) so it doesn't get applied to the wrong // file later, log, and bail. (Adversary review M-NEW-1 + C3-1.) // // Token-checked deletion: only remove the slot if it still // contains the exact request we wrote. If a concurrent // openLinkText overwrote our write, leave that newer request // alone — its own resolution will run independently. if (ownRequest && this._pendingScrolls.get(file.path) === ownRequest) { this._pendingScrolls.delete(file.path) } console.warn( `openLinkText: could not resolve a leaf for '${file.path}' — navigation handler may be detached.`, ) return } if (active) { this.setActiveLeaf(leaf) } else { // openViewState.active === false: do NOT change focus. The leaf // exists in this.leaves (visible in TabBar) but the previous // active leaf retains keyboard focus. }
// openViewState.state passed to view.setState (best-effort). if (openViewState?.state && leaf.view) { try { leaf.view.setState(openViewState.state, { history: false }) } catch (err) { console.warn('openLinkText: leaf.view.setState threw:', err) } } }
/** * Resolve which leaf should host the requested file. * * Dedups by path: walks `iterateRootLeaves` first and returns any leaf * whose `file.path` matches. This applies BOTH to `newLeaf=true` and * `newLeaf=false` per the dedup-by-path decision (Reams diverges from * Obsidian's "always create new" Cmd-click semantics). * * If no existing leaf matches: * - `newLeaf=true`: create via `getLeaf('tab')`, attach a new * MarkdownView, set the file. * - `newLeaf=false`: route through `this.openFile(file)` which * reuses the active editor leaf when possible. We then re-fetch * the leaf via `iterateRootLeaves` since `openFile` doesn't * return the leaf. * * Returns `null` if `openFile` dispatched but no matching leaf * surfaces in `iterateRootLeaves`. This can happen in legitimate * edge cases (e.g., StrictMode unmount detached the navigate * handler between the openLinkText call and this resolution). The * caller (`openLinkText`) logs and bails gracefully rather than * throwing, since throwing here propagates to user-facing actions * (wikilink clicks) where a silent bail is the lesser harm. * (Adversary review M-NEW-1: chainlink #313.) */ private _resolveLeafForOpen( file: import('./files').TFile, newLeaf: boolean, ): WorkspaceLeaf | null { const existing = this._findRootLeafForFile(file) if (existing) return existing
if (newLeaf) { const leaf = this.getLeaf('tab') this._startOpeningLeaf(leaf, file) return leaf }
return this._ensureMainLeafForFile(file) }
/** * Set or clear a pending scroll-to-fragment request. * * Per-file storage: a newer request for the SAME file overwrites the * older one (re-clicking `[[Note#h1]]` then `[[Note#h2]]` lands on * h2). Requests for DIFFERENT files coexist and each survives until * consumed when its file becomes active. * * Pass `null` to clear ALL pending requests (used by tests / shutdown). * * Fires `pending-scroll-set` for the new request so subscribers can * wake up immediately. For the same-doc fragment fast path * (openLinkText short-circuit when target === active file), this * event is the ONLY wake-up signal — the AppShell effect's * active-leaf-change listener doesn't fire because no active leaf * change happens. * * INVARIANT: stores the request object BY REFERENCE; does NOT clone. * `openLinkText`'s failed-resolution cleanup uses object identity * (`_pendingScrolls.get(path) === ownRequest`) to detect whether a * concurrent writer has overwritten its slot. If a future refactor * starts cloning here, that token-check silently turns into a * permanent no-op and the C3-1 leak returns. Don't clone. * * @internal Host-internal handoff between Workspace and React AppShell. * Plugins must not reach this; fragment scroll is driven by `openLinkText`. */ setPendingScroll(pending: PendingScrollRequest | null): void { if (pending === null) { this._pendingScrolls.clear() return } this._pendingScrolls.set(pending.filePath, pending) this.trigger('pending-scroll-set', pending) }
/** * Read and remove the pending scroll request for `filePath`. * * If `filePath` is omitted, removes and returns the FIRST pending * request (insertion order) — convenience for tests; production * callers always supply the active file's path so each request is * delivered to its correct file. * * Returns `null` if no request exists for the given path. * * @internal Host-internal handoff between Workspace and React AppShell. * Plugins must not reach this; fragment scroll is driven by `openLinkText`. */ consumePendingScroll(filePath?: string): PendingScrollRequest | null { if (filePath === undefined) { const first = this._pendingScrolls.values().next() if (first.done) return null this._pendingScrolls.delete(first.value.filePath) return first.value } const pending = this._pendingScrolls.get(filePath) if (!pending) return null this._pendingScrolls.delete(filePath) return pending }
/** * Peek at the pending scroll request for `filePath` without * removing it. If `filePath` is omitted, returns the FIRST pending * request (insertion order) — convenience for tests. * * @internal Host-internal handoff between Workspace and React AppShell. * Plugins must not reach this; fragment scroll is driven by `openLinkText`. */ peekPendingScroll(filePath?: string): PendingScrollRequest | null { if (filePath === undefined) { const first = this._pendingScrolls.values().next() return first.done ? null : first.value } return this._pendingScrolls.get(filePath) ?? null }
/** * Determine which panel a leaf belongs to. * Returns null if the leaf is in the main area (not a panel leaf). * * @internal */ getPanelSide(leaf: WorkspaceLeaf): PanelSide | null { if (this.leftLeaves.indexOf(leaf) !== -1) return 'left' if (this.rightLeaves.indexOf(leaf) !== -1) return 'right' return null }
/** * Set the active (visible) leaf for a sidebar panel. * * This controls which tab is visible in the panel — it is orthogonal * to the global activeLeaf (keyboard focus). Clicking a sidebar tab * changes the panel active leaf but does NOT steal global focus from * the editor. * * Always fires 'panel-leaf-change' unconditionally — this is a "please * show this tab" command, not a state notification, so it must work on * repeated calls even if the leaf hasn't changed. * * No-ops silently if the leaf is not in any sidebar panel. * * @internal */ setActivePanelLeaf(leaf: WorkspaceLeaf): void { const side = this.getPanelSide(leaf) if (!side) return
if (side === 'left') { this.activeLeftLeaf = leaf } else { this.activeRightLeaf = leaf }
this._firePanelLeafChange(side, leaf) }
/** * Fire 'panel-leaf-change' with reentrancy guard. * All panel-leaf-change emissions MUST go through this method to prevent * infinite loops if a listener calls setActivePanelLeaf or revealLeaf. */ private _firePanelLeafChange(side: PanelSide, leaf: WorkspaceLeaf | null): void { if (this._firingPanelLeafChange) return this._firingPanelLeafChange = true try { this._triggerReamsEvent('panel-leaf-change', side, leaf) } finally { this._firingPanelLeafChange = false } }
/** * Internal hook called by `View.requestHeaderActionsUpdate()` to notify * sidebar containers that a view's header actions need re-rendering even * though the active leaf is unchanged. Fires `view-header-actions-change` * for subscribers like `SidebarViewContainer`. No-op when the leaf isn't * in a sidebar panel — only sidebar panels render the chrome-row actions * slot. * * **No reentrancy guard, by design.** Unlike `_firePanelLeafChange`, * this method does NOT need a global `_firing*` boolean: * * 1. `SidebarViewContainer` consumes the event via a monotonically * increasing `useState` counter — no recursive emit pathway exists * in the host. * 2. `useSyncExternalStore` bails out on `Object.is`-equal snapshots, * so a listener that calls `requestHeaderActionsUpdate()` on the * same view without changing its visible state cannot trigger a * render storm. * 3. A legitimate cross-leaf cascade (a listener on leaf-A's chrome * update calls `viewB.requestHeaderActionsUpdate()` for the other * panel) must NOT be silently dropped — that's a real plugin use * case (e.g. mirroring a sort change across two related panels), * and a global boolean guard would swallow the second fan-out. * * If someone later genuinely needs to suppress true reentrancy on the * SAME leaf, prefer a per-leaf `WeakSet<WorkspaceLeaf>` rather than a * global flag. * * @internal */ _requestHeaderActionsUpdate(leaf: WorkspaceLeaf): void { if (this.getPanelSide(leaf) === null) return this._triggerReamsEvent('view-header-actions-change', leaf) }
/** * Get the active view of a specific type. */ getActiveViewOfType<T extends View>(type: Constructor<T>): T | null { if (!this.activeLeaf?.view) return null if (this.activeLeaf.view instanceof type) { return this.activeLeaf.view as T } return null }
/** * Get leaf by ID. */ getLeafById(id: string): WorkspaceLeaf | null { return this.leaves.find(l => l.id === id) ?? null }
/** * Get most recently active leaf. */ getMostRecentLeaf(_root?: WorkspaceParent): WorkspaceLeaf | null { return this.activeLeaf }
/** * Detach a leaf from the workspace. * No-ops silently (without emitting layout-change) if the leaf is not * found in any leaf array. * * When detaching the active leaf, activates the sibling: * - If there's a leaf at the same index (next sibling), activate it * - Otherwise activate the new last leaf * - If no leaves remain, activeLeaf becomes null * * @internal Host-internal. Plugins should use `leaf.detach()` (Obsidian-shape) * which delegates to this method. */ detachLeaf(leaf: WorkspaceLeaf): void { // Capture the path BEFORE any mutation so the pendingScroll flush // below sees a consistent value regardless of which branch we // take. Use the same truthful host-open path logic here so a // detached host-created leaf still owns its pending fragment while // `leaf.file` is intentionally unset and only `view.file` is // populated. const detachedPath = this._getLeafFilePathForHostOpen(leaf) ?? leaf.file?.path const isAttached = this.leaves.includes(leaf) || this.leftLeaves.includes(leaf) || this.rightLeaves.includes(leaf) if (!isAttached) return
const viewsToClose = leaf._invalidateForDetach() for (const closingView of viewsToClose) { this._activeEditorSuppressedLeaves.add(leaf) this._syncActiveEditor() try { leaf._closeViewOnce(closingView).catch((error) => { console.error(`Error closing view '${closingView.getViewType()}':`, error) }) } catch (error) { console.error(`Error closing view '${closingView.getViewType()}':`, error) } finally { this._activeEditorSuppressedLeaves.delete(leaf) } } const pendingEnsureSideLeaf = this._ensureSideLeafPendingOp.get(leaf) if (pendingEnsureSideLeaf) { pendingEnsureSideLeaf.invalidated = true this._ensureSideLeafPendingOp.delete(leaf) } // Check main leaves const mainIndex = this.leaves.indexOf(leaf) if (mainIndex !== -1) { this.leaves.splice(mainIndex, 1) this._flushPendingScrollIfOrphaned(detachedPath) if (this.activeLeaf === leaf) { // Activate sibling: prefer the leaf now at the same index (was "next"), // fall back to the new last leaf, or null if empty. if (this.leaves.length === 0) { this.activeLeaf = null } else if (mainIndex < this.leaves.length) { this.activeLeaf = this.leaves[mainIndex] } else { this.activeLeaf = this.leaves[this.leaves.length - 1] } this._syncActiveEditor() this.trigger('active-leaf-change', this.activeLeaf) } this.trigger('layout-change') return }
// Check left panel leaves const leftIndex = this.leftLeaves.indexOf(leaf) if (leftIndex !== -1) { this.leftLeaves.splice(leftIndex, 1) this._flushPendingScrollIfOrphaned(detachedPath) if (this.activeLeftLeaf === leaf) { this.activeLeftLeaf = this.leftLeaves[0] ?? null this._firePanelLeafChange('left', this.activeLeftLeaf) } this.trigger('layout-change') return }
// Check right panel leaves const rightIndex = this.rightLeaves.indexOf(leaf) if (rightIndex !== -1) { this.rightLeaves.splice(rightIndex, 1) this._flushPendingScrollIfOrphaned(detachedPath) if (this.activeRightLeaf === leaf) { this.activeRightLeaf = this.rightLeaves[0] ?? null this._firePanelLeafChange('right', this.activeRightLeaf) } this.trigger('layout-change') return } }
/** @internal Await view-owned drafts before an explicit host detach. */ async detachLeafAfterPreTeardown(leaf: WorkspaceLeaf): Promise<void> { const view = leaf.view await this.prepareLeafForTeardown(leaf) if (leaf.view === view) this.detachLeaf(leaf) }
/** @internal Await matching views before a host operation tears them down. */ async prepareLeavesForTeardown(predicate: (view: View) => boolean): Promise<void> { const leaves: WorkspaceLeaf[] = [] this.iterateAllLeaves(leaf => { if (leaf.view && predicate(leaf.view)) leaves.push(leaf) }) await Promise.all(leaves.map(leaf => this.prepareLeafForTeardown(leaf))) }
private prepareLeafForTeardown(leaf: WorkspaceLeaf): Promise<void> { const existing = this._leafPreTeardown.get(leaf) if (existing) return existing const view = leaf.view as View & { prepareForTeardown?: () => Promise<void> } const file = leaf.file ?? (view as { file?: TFile | null })?.file ?? null const documentId = file ? this.app?.vault.getUrlForFile(file) : undefined const pending = (async () => { if (documentId && !await this.app?.flushDocumentSync(documentId)) throw new Error('Document drafts could not be flushed') await view?.prepareForTeardown?.() })().finally(() => { if (this._leafPreTeardown.get(leaf) === pending) this._leafPreTeardown.delete(leaf) }) this._leafPreTeardown.set(leaf, pending) return pending }
/** * If `path` is non-empty and no leaf in any pool (main, left, * right) still references that file, drop any pendingScroll * request for it. Used by `detachLeaf` to prevent a stale fragment * from re-firing when the file is reopened later. * * Applies the path-still-referenced check across ALL leaf pools so * panel leaves with attached files (any future use case) don't * spuriously flush requests that another pool's leaf still owns. */ private _flushPendingScrollIfOrphaned(path: string | undefined): void { if (!path) return const stillReferenced = this.leaves.some(l => this._getLeafFilePathForHostOpen(l) === path) || this.leftLeaves.some(l => this._getLeafFilePathForHostOpen(l) === path) || this.rightLeaves.some(l => this._getLeafFilePathForHostOpen(l) === path) if (!stillReferenced) { this._pendingScrolls.delete(path) } }
/** * Register a view type with its creator function. * @throws If the view type is already registered. * * @internal */ registerViewType(type: string, creator: ViewCreator): void { if (this.viewRegistry.has(type)) { throw new Error(`View type '${type}' is already registered`) } this.viewRegistry.set(type, creator) this._triggerReamsEvent('view-registered', type) }
/** * Unregister a view type and detach any leaves showing it. * Also checks main leaves array for completeness. * Note: onClose() is fire-and-forget — rejections are caught and logged * to avoid unhandled promise rejections while still cleaning up synchronously. * * @internal */ unregisterViewType(type: string): void { if (!this.viewRegistry.has(type)) return const closingLeaves = this.getLeavesOfType(type) this.viewRegistry.delete(type) // Clear any dangling content-type bindings that pointed at this view type. this.unregisterAllContentTypeBindingsForViewType(type) // If this view served as the unsupported fallback, drop the pin so a // later `getViewTypeForContentType(ct, { fallbackToUnsupported: true })` // doesn't hand back a now-orphaned view-type string that has no creator // (which would make `WorkspaceLeaf.openFile` throw on dispatch). if (this.unsupportedViewType === type) { this.unsupportedViewType = null }
for (const leaf of closingLeaves) { this._activeEditorSuppressedLeaves.add(leaf) }
// Track which panel active leaves need updating. We defer the // panel-leaf-change event until after all removals complete so the // fallback leaf isn't one that's about to be removed in the same loop. let leftPanelDirty = false let rightPanelDirty = false
try { // Detach leaves showing this view type from all leaf arrays for (const leaves of [this.leftLeaves, this.rightLeaves, this.leaves]) { for (let i = leaves.length - 1; i >= 0; i--) { if (leaves[i].view?.getViewType() === type) { const leaf = leaves[i] const detachedPath = this._getLeafFilePathForHostOpen(leaf) ?? leaf.file?.path const viewsToClose = leaf._invalidateForDetach() this._syncActiveEditor() leaves.splice(i, 1) this._flushPendingScrollIfOrphaned(detachedPath) for (const closingView of viewsToClose) { leaf._closeViewOnce(closingView).catch((err) => { console.error(`Error closing view '${type}':`, err) }) } // If this was the active leaf, clear activeLeaf if (leaves === this.leaves && this.activeLeaf === leaf) { this.activeLeaf = this.leaves[0] ?? null this._syncActiveEditor() this.trigger('active-leaf-change', this.activeLeaf) } // Mark per-panel active leaves as needing update if (leaves === this.leftLeaves && this.activeLeftLeaf === leaf) { leftPanelDirty = true } if (leaves === this.rightLeaves && this.activeRightLeaf === leaf) { rightPanelDirty = true } } } } } finally { for (const leaf of closingLeaves) { this._activeEditorSuppressedLeaves.delete(leaf) } }
// Now that all removals are done, update panel active leaves. // The fallback leaf is safe — it won't be removed in a subsequent iteration. if (leftPanelDirty) { this.activeLeftLeaf = this.leftLeaves[0] ?? null this._firePanelLeafChange('left', this.activeLeftLeaf) } if (rightPanelDirty) { this.activeRightLeaf = this.rightLeaves[0] ?? null this._firePanelLeafChange('right', this.activeRightLeaf) }
this._triggerReamsEvent('view-unregistered', type) this.trigger('layout-change') }
/** * Get the creator for a registered view type. * * @internal Host-internal. Used by WorkspaceLeaf.openView() and * setViewState() to instantiate views. Plugins don't need this — * they open views via leaf.setViewState({ type }). */ getViewCreator(type: string): ViewCreator | undefined { return this.viewRegistry.get(type) }
/** * Get all registered view type strings. * * @internal Host-internal. Used by SettingsModal to enumerate installed * views. Plugins don't need to enumerate all view types — they register * their own via Plugin.registerView() and open by known type. */ getRegisteredViewTypes(): string[] { return Array.from(this.viewRegistry.keys()) }
/** * Bind a content type (UTI-style identifier) to a registered view type. * Last-write-wins on duplicate registration. * * @throws If `viewType` was not registered via {@link registerViewType}. * @internal Used by Plugin.registerViewForContentType and host bootstrap. */ registerViewTypeForContentType(contentType: string, viewType: string): void { if (!this.viewRegistry.has(viewType)) { throw new Error( `Cannot bind contentType '${contentType}' to unregistered view type '${viewType}'`, ) } this.contentTypeViewRegistry.set(contentType, viewType) this._triggerReamsEvent('content-type-view-registered', { contentType, viewType }) // Tabs restored before plugins activate may already have the unsupported // fallback. Re-route them when their actual content type becomes known. void this._refreshUnsupportedContentTypeViews(contentType, viewType) }
private async _refreshUnsupportedContentTypeViews(contentType: string, viewType: string): Promise<void> { const leaves = this.getLeavesOfType('reams-unsupported') for (const leaf of leaves) { const file = leaf.file if (!this.app || this.contentTypeViewRegistry.get(contentType) !== viewType) return if (!file) continue try { const url = this.app.vault.getUrlForFile(file) if (!url) continue const doc = await this.app.reamsDocumentManager.getFileRoutingInfo(url) const actualType = doc?.contentType || inferContentType(file.name, doc?.mimeType) if (leaf.file !== file || leaf.view?.getViewType() !== 'reams-unsupported' || this.contentTypeViewRegistry.get(contentType) !== viewType || this.getViewTypeForContentType(actualType) !== viewType) continue await leaf.openFile(file, { active: false }) } catch (error) { console.error(`Failed to refresh restored view for '${file.path}':`, error) } } }
/** * Remove a content-type binding only when the current binding matches * `viewType`. Silently no-ops when the current binding has been overridden * by another caller. * * @internal */ unregisterViewTypeForContentType(contentType: string, viewType: string): void { const current = this.contentTypeViewRegistry.get(contentType) if (current === viewType) { this.contentTypeViewRegistry.delete(contentType) this._triggerReamsEvent('content-type-view-unregistered', { contentType, viewType }) } }
/** * Remove every content-type binding pointing at `viewType`. * Used by {@link unregisterViewType} so plugins that unregister a view type * never leave dangling content-type bindings. * * @internal */ unregisterAllContentTypeBindingsForViewType(viewType: string): void { for (const [ct, vt] of Array.from(this.contentTypeViewRegistry)) { if (vt === viewType) { this.contentTypeViewRegistry.delete(ct) } } }
/** * Resolve a view type for a content type. Walks the CONFORMS_TO chain so a * registration on a parent type (e.g. `public.image`) matches a leaf type * (e.g. `public.png`). When `options.fallbackToUnsupported` is true and the * walk finds nothing, returns the unsupported fallback view type. * * @internal */ getViewTypeForContentType( contentType: string, options?: { fallbackToUnsupported?: boolean }, ): string | undefined { let cursor: string | undefined = contentType const seen = new Set<string>() while (cursor && !seen.has(cursor)) { seen.add(cursor) const hit = this.contentTypeViewRegistry.get(cursor) if (hit) return hit cursor = CONFORMS_TO[cursor] } if (options?.fallbackToUnsupported && this.unsupportedViewType) { return this.unsupportedViewType } return undefined }
/** * @internal Host-only. Registers the view type that is returned when * {@link getViewTypeForContentType} is called with the fallback flag and * no specific binding resolves. */ _registerUnsupportedFallback(viewType: string): void { if (!this.viewRegistry.has(viewType)) { throw new Error(`Unsupported fallback view type '${viewType}' is not registered`) } this.unsupportedViewType = viewType }
/** * Register an EditorSuggest for editor-anchored autocomplete. * Idempotent: re-registering the same instance is a no-op. Fires * 'editor-suggest-registered' once on first registration. * * @internal */ registerEditorSuggest(suggest: EditorSuggest<unknown>): void { if (this._editorSuggests.includes(suggest)) return this._editorSuggests.push(suggest) this._triggerReamsEvent('editor-suggest-registered', suggest) }
/** * Unregister an EditorSuggest. Silent no-op if not registered. * Fires 'editor-suggest-unregistered' so dispatchers can force-close * any in-flight dropdown owned by this suggester. * * @internal */ unregisterEditorSuggest(suggest: EditorSuggest<unknown>): void { const i = this._editorSuggests.indexOf(suggest) if (i === -1) return this._editorSuggests.splice(i, 1) this._triggerReamsEvent('editor-suggest-unregistered', suggest) }
/** * Register a CM6 extension for a plugin. * * Called by Plugin.registerEditorExtension(). Multiple calls for the same * pluginId accumulate extensions. Fires 'editor-extensions-changed' so the * editor can reconfigure its shared compartment. * * Known limitation: no conflict resolution if plugins register competing * decorations — last-registered wins (matches Obsidian behavior). * * @param pluginId The plugin manifest ID (used for cleanup on unload) * @param extension The CM6 Extension to register * * @internal */ registerEditorExtension(pluginId: string, extension: Extension): void { const existing = this.editorExtensionsRegistry.get(pluginId) ?? [] existing.push(extension) this.editorExtensionsRegistry.set(pluginId, existing) this._triggerReamsEvent('editor-extensions-changed', this.getAllEditorExtensions()) }
/** * Unregister all CM6 extensions for a plugin. * * Called during plugin unload (cleanupPluginResources). No-op if the plugin * has no registered extensions (does not fire event in that case). * * @param pluginId The plugin manifest ID * * @internal */ unregisterEditorExtensions(pluginId: string): void { if (!this.editorExtensionsRegistry.has(pluginId)) return this.editorExtensionsRegistry.delete(pluginId) this._triggerReamsEvent('editor-extensions-changed', this.getAllEditorExtensions()) }
/** * Legacy CM5 iteration hook. Real Obsidian preserves this on Workspace for * backward compat but no-ops it under CM6 (there are no CM5 instances). * * Plugins like cm-typewriter-scroll-obsidian call it from their disable * path to clear CM5 options; under CM6 the relevant state is owned by * registered editor extensions and disposed when the plugin unloads. * * Stub accepts the callback but never invokes it — matches Obsidian CM6 * runtime behavior. * * @internal */ iterateCodeMirrors(_callback: (cm: unknown) => void): void { // No-op under CM6. }
/** * Force-refresh registered editor extensions on all open editors. * * Obsidian plugins (e.g. obsidian42-strange-new-worlds) call * `app.workspace.updateOptions()` after dynamically toggling whether their * extensions should be included, to push the new extension list to all * mounted CM6 instances. Our `editor-extensions-changed` event already * triggers a reconfigure on the shared compartment (see CM6Editor wiring), * so this method just re-emits that event with the current extension list. * * Plugins also call this with no expectation of a return value — keep it * void to match the documented Obsidian signature. */ updateOptions(): void { this._triggerReamsEvent('editor-extensions-changed', this.getAllEditorExtensions()) }
/** * Get the flat array of all plugin-registered CM6 extensions. * * Used by CM6Editor to populate its shared compartment. Order: iteration * order of Map (insertion order), then array order within each plugin. * * @internal Host-internal. Used by CM6Editor when reconfiguring the editor * compartment. Plugins register extensions via Plugin.registerEditorExtension() * and don't need to query the aggregate list. */ getAllEditorExtensions(): Extension[] { const all: Extension[] = [] for (const exts of this.editorExtensionsRegistry.values()) { all.push(...exts) } return all }
/** * Get or create a leaf in the left sidebar panel. * * When `newLeaf` is false (or omitted), returns an existing leaf that * has no view attached (view === null). If all existing leaves already * have views, creates a new leaf. This prevents plugins from clobbering * each other's views — e.g., Calendar calling `getLeftLeaf(false)` won't * overwrite the File Tree's leaf. */ /** @internal Reams permits omitted argument for existing callers. */ getLeftLeaf(): WorkspaceLeaf getLeftLeaf(split: boolean): WorkspaceLeaf getLeftLeaf(newLeaf: boolean = false): WorkspaceLeaf { if (!newLeaf) { const emptyLeaf = this.leftLeaves.find(l => l.view === null) if (emptyLeaf) return emptyLeaf } const leaf = new WorkspaceLeaf(this) this.leftLeaves.push(leaf) this.trigger('layout-change') return leaf }
/** * Get or create a leaf in the right panel. * * When `newLeaf` is false (or omitted), returns an existing leaf that * has no view attached (view === null). If all existing leaves already * have views, creates a new leaf. This prevents plugins from clobbering * each other's views — e.g., Calendar calling `getRightLeaf(false)` won't * overwrite the Backlinks leaf. */ /** @internal Reams permits omitted argument for existing callers. */ getRightLeaf(): WorkspaceLeaf getRightLeaf(split: boolean): WorkspaceLeaf getRightLeaf(newLeaf: boolean = false): WorkspaceLeaf { if (!newLeaf) { const emptyLeaf = this.rightLeaves.find(l => l.view === null) if (emptyLeaf) return emptyLeaf } const leaf = new WorkspaceLeaf(this) this.rightLeaves.push(leaf) this.trigger('layout-change') return leaf }
/** * Ensure a side panel leaf of the given view type exists. * If a leaf with the view type already exists in the target side, * returns it. Otherwise, creates a new leaf and fires off view * opening asynchronously while still honoring Obsidian's Promise contract. * * Obsidian-compatible API. * * @param viewType - The registered view type to ensure exists * @param side - Which side panel ('left' or 'right'), defaults to 'left' * @param options - { active?: boolean, reveal?: boolean } whether to reveal the leaf. * Reveal uses `active` when provided, otherwise `reveal`, otherwise defaults to true. */ ensureSideLeaf( viewType: string, side: Side, options?: { active?: boolean; split?: boolean; reveal?: boolean; state?: any } ): Promise<WorkspaceLeaf> /** @internal Back-compat runtime overload for omitted side. */ ensureSideLeaf( viewType: string, options?: { active?: boolean; split?: boolean; reveal?: boolean; state?: any } ): Promise<WorkspaceLeaf> ensureSideLeaf( viewType: string, sideOrOptions: Side | { active?: boolean; split?: boolean; reveal?: boolean; state?: any } = 'left', options?: { active?: boolean; split?: boolean; reveal?: boolean; state?: any } ): Promise<WorkspaceLeaf> { const side = typeof sideOrOptions === 'string' ? sideOrOptions : 'left' const resolvedOptions = typeof sideOrOptions === 'string' ? options : sideOrOptions const { active, reveal, state } = resolvedOptions ?? {} const isAttachedEligibleSideLeaf = (leaf: WorkspaceLeaf, expectedEpoch: number): boolean => { if (leaf.getViewLifecycleEpoch() !== expectedEpoch) { return false } if (leaf.view?.getViewType() !== viewType) { return false } if (!leaf.isViewReadyForActiveEditor()) { return false } return this.getPanelSide(leaf) === side } const applyState = (leaf: WorkspaceLeaf): Promise<void> | null => { if (state === undefined || !leaf.view) { return null }
try { const result = leaf.view.setState(state, { history: false }) if (result && typeof (result as PromiseLike<unknown>).then === 'function') { return Promise.resolve(result).catch((err) => { console.warn(`ensureSideLeaf: failed to apply state for view '${viewType}':`, err) }) } return Promise.resolve() } catch (err) { console.warn(`ensureSideLeaf: failed to apply state for view '${viewType}':`, err) return Promise.resolve() } } const revealLeafBestEffort = (leaf: WorkspaceLeaf): void => { try { const result = this.revealLeaf(leaf) if (result && typeof (result as PromiseLike<unknown>).then === 'function') { void Promise.resolve(result).catch((err) => { console.warn(`ensureSideLeaf: failed to reveal view '${viewType}':`, err) }) } } catch (err) { console.warn(`ensureSideLeaf: failed to reveal view '${viewType}':`, err) } } // Reveal uses `active` when provided, otherwise `reveal`, otherwise defaults to true. const shouldReveal = active ?? reveal ?? true const sideLeaves = side === 'left' ? this.leftLeaves : this.rightLeaves const finishEnsureSideLeaf = async ( leaf: WorkspaceLeaf, expectedEpoch: number, { detachOnMissingView, pendingOp, followUpId }: { detachOnMissingView: boolean pendingOp?: { invalidated: boolean; latestFollowUpId: number } followUpId?: number }, ): Promise<void> => { if (pendingOp?.invalidated) { return } if (leaf.getViewLifecycleEpoch() !== expectedEpoch) { return } if (leaf.view?.getViewType() !== viewType) { if (detachOnMissingView && leaf.isViewReadyForActiveEditor()) { this.detachLeaf(leaf) } return } if (!leaf.isViewReadyForActiveEditor()) { return } const pendingStateApply = applyState(leaf) if (pendingStateApply) { await pendingStateApply // Give any microtasks queued by setState() completion a chance to detach // or replace the leaf before reveal revalidates it. await Promise.resolve() } if (!shouldReveal) { return } if (pendingOp && followUpId !== undefined && followUpId !== pendingOp.latestFollowUpId) { return } if (pendingOp?.invalidated) { return } if (isAttachedEligibleSideLeaf(leaf, expectedEpoch)) { revealLeafBestEffort(leaf) } } const queuePendingEnsureSideLeafFollowUp = ( leaf: WorkspaceLeaf, pendingOp: { promise: Promise<void> lifecycleEpoch: number invalidated: boolean latestFollowUpId: number followUp: Promise<void> }, detachOnMissingView: boolean, ): void => { const followUpId = pendingOp.latestFollowUpId + 1 pendingOp.latestFollowUpId = followUpId pendingOp.followUp = pendingOp.followUp.then(async () => { try { await pendingOp.promise } catch { return }
await finishEnsureSideLeaf(leaf, pendingOp.lifecycleEpoch, { detachOnMissingView, pendingOp, followUpId, }) }) }
// Check if a leaf with this view type already exists on the target side const existing = sideLeaves.find(l => l.view?.getViewType() === viewType) if (existing) { const pendingExisting = this._ensureSideLeafPendingOp.get(existing) if ( pendingExisting && pendingExisting.viewType === viewType && pendingExisting.lifecycleEpoch === existing.getViewLifecycleEpoch() && !existing.isViewReadyForActiveEditor() ) { queuePendingEnsureSideLeafFollowUp(existing, pendingExisting, false) return Promise.resolve(existing) } void finishEnsureSideLeaf(existing, existing.getViewLifecycleEpoch(), { detachOnMissingView: false, }) return Promise.resolve(existing) }
// Create a new leaf on the appropriate side const leaf = side === 'left' ? this.getLeftLeaf(true) : this.getRightLeaf(true)
// Open the view asynchronously via setViewState (fire-and-forget). // ensureSideLeaf itself still returns Promise<WorkspaceLeaf> immediately, // while opening/state application continue best-effort in the background. // Use setViewState only for opening here so state application can preserve // falsy payloads and isolate sync/async setState failures consistently. const pendingOpen = leaf.setViewState({ type: viewType }) const pendingEpoch = leaf.getViewLifecycleEpoch() const pendingEnsureSideLeaf = { viewType, promise: pendingOpen, lifecycleEpoch: pendingEpoch, invalidated: false, followUp: Promise.resolve(), latestFollowUpId: 0, } this._ensureSideLeafPendingOp.set(leaf, pendingEnsureSideLeaf) queuePendingEnsureSideLeafFollowUp(leaf, pendingEnsureSideLeaf, true) pendingOpen.then(() => { if (pendingEnsureSideLeaf.invalidated) { return } void pendingEnsureSideLeaf.followUp.finally(() => { if (this._ensureSideLeafPendingOp.get(leaf)?.promise === pendingOpen) { this._ensureSideLeafPendingOp.delete(leaf) } }) }).catch((err) => { if (pendingEnsureSideLeaf.invalidated) { return } if (this._ensureSideLeafPendingOp.get(leaf)?.promise === pendingOpen) { this._ensureSideLeafPendingOp.delete(leaf) } if (leaf.getViewLifecycleEpoch() !== pendingEpoch) { return } this.detachLeaf(leaf) console.error(`ensureSideLeaf: failed to open view '${viewType}':`, err) })
return Promise.resolve(leaf) }
/** * Get all left panel leaves. * Returns a copy of the internal array; mutations do not affect workspace state. * * @internal */ getLeftLeaves(): ReadonlyArray<WorkspaceLeaf> { return this.leftLeaves.slice() }
/** * Get all right panel leaves. * Returns a copy of the internal array; mutations do not affect workspace state. * * @internal */ getRightLeaves(): ReadonlyArray<WorkspaceLeaf> { return this.rightLeaves.slice() }
/** * Get all leaves that are displaying a view of a specific type. * Used by plugins like Calendar to find their view leaves. */ getLeavesOfType(viewType: string): WorkspaceLeaf[] { const result: WorkspaceLeaf[] = [] for (const leaf of this.leaves) { if (leaf.view?.getViewType() === viewType) { result.push(leaf) } } for (const leaf of this.leftLeaves) { if (leaf.view?.getViewType() === viewType) { result.push(leaf) } } for (const leaf of this.rightLeaves) { if (leaf.view?.getViewType() === viewType) { result.push(leaf) } } return result }
/** * Iterate over all leaves of a specific type. * Obsidian-compatible API. * * @internal */ iterateLeaves(callback: (leaf: WorkspaceLeaf) => void, viewType?: string): void { const allLeaves = [...this.leaves, ...this.leftLeaves, ...this.rightLeaves] for (const leaf of allLeaves) { if (!viewType || leaf.view?.getViewType() === viewType) { callback(leaf) } } }
/** * Reveal a leaf, making it visible and active. * * For panel leaves (left/right sidebar): sets the panel's active tab * via setActivePanelLeaf(), expands the panel via setLeftSidebarCollapsed/ * setRightSidebarCollapsed, and fires 'reveal-panel' for backward compat. * Does NOT set the global activeLeaf — panel tab visibility is orthogonal * to keyboard focus. * * For main leaves: sets global activeLeaf as before. * * Obsidian-compatible API. */ revealLeaf(leaf: WorkspaceLeaf): Promise<void> revealLeaf(leaf: WorkspaceLeaf): void | Promise<void> { const side = this.getPanelSide(leaf) if (side) { // Panel leaf: switch to its tab and expand the panel this.setActivePanelLeaf(leaf) // Expand the sidebar (Workspace now owns collapsed state) if (side === 'left') { this.setLeftSidebarCollapsed(false) } else { this.setRightSidebarCollapsed(false) } // Keep 'reveal-panel' for backward compatibility with existing listeners this._triggerReamsEvent('reveal-panel', side) } else { // Main leaf: set global focus this.setActiveLeaf(leaf, { focus: true }) } }
/** * Scroll the active editor so that `line` (0-indexed) sits at the top of * the visible viewport with a mobile-aware margin. The margin policy * (24px baseline + visualViewport.offsetTop) lives in the `scrollViewToLine` * primitive — this method is a thin wrapper that resolves the active leaf's * CM6 adapter and delegates. * * Returns `true` when the scroll was dispatched, `false` when there is * no active editor (no active leaf, leaf has no MarkdownView, or the * view's CM6 adapter is unbound — common in tests / during editor mount). * * @param line 0-indexed line number. Negative values clamp to 0; values * ≥ lineCount clamp to the last line. * @param options.behavior 'smooth' (default) or 'instant'. * * @internal */ scrollActiveEditorToLine( line: number, options?: { behavior?: 'smooth' | 'instant' }, ): boolean { const adapter = this._getActiveCM6Adapter() if (!adapter) return false return adapter.scrollToLine(line, { behavior: options?.behavior ?? 'smooth' }) }
/** * Resolve the active editor's CM6 adapter, or null. Internal helper. */ private _getActiveCM6Adapter(): CM6EditorAdapter | null { const editor = this.activeEditor?.editor if (!(editor instanceof CM6EditorAdapter)) return null return editor }
/** * Public: return the active editor's underlying CM6 EditorView, or * null. Single source of truth for "what is the live CM6 view right * now?" — used by `scrollActiveEditorToLine` (above) and by the * sticky-highlight hook in `toc-sticky-highlight.ts`. * * Returns null when there is no active leaf, the active leaf is not a * MarkdownView, the view's editor is not a CM6EditorAdapter, or the * adapter has not yet bound a view (common during mount). * * @internal */ getActiveCM6View(): import('@codemirror/view').EditorView | null { return this._getActiveCM6Adapter()?.getView() ?? null }
/** * Get the file currently open in the active leaf. * Checks the leaf's file property first (set by openFile), * then falls back to the view's file property (set by FileView/MarkdownView). * Obsidian-compatible API. */ getActiveFile(): import('./files').TFile | null { if (!this.activeLeaf) return null if (this.activeLeaf.file) return this.activeLeaf.file // Fallback: check the view's file property (FileView, MarkdownView) const view = this.activeLeaf.view as { file?: import('./files').TFile | null } | null return view?.file ?? null }
/** * Get an unpinned leaf. * Deprecated Obsidian API — delegates to getLeaf(false). */ getUnpinnedLeaf(): WorkspaceLeaf { return this.getLeaf(false) }
/** * Split the active leaf. * Deprecated Obsidian API — delegates to getLeaf('split'). */ splitActiveLeaf(direction?: SplitDirection): WorkspaceLeaf { return this.getLeaf('split', direction) }
/** * Iterate over root (main-area) leaves only. * Excludes sidebar leaves. Used by plugins like QuickAdd to find * already-open file tabs without matching sidebar panel views. * Obsidian-compatible API. */ iterateRootLeaves(callback: (leaf: WorkspaceLeaf) => any): void { for (const leaf of this.leaves) { callback(leaf) } }
/** * Iterate over ALL leaves in the workspace (main, left, right). * Unlike iterateLeaves() which can filter by viewType, this iterates * every leaf unconditionally. * Obsidian-compatible API. */ iterateAllLeaves(callback: (leaf: WorkspaceLeaf) => any): void { for (const leaf of this.leaves) callback(leaf) for (const leaf of this.leftLeaves) callback(leaf) for (const leaf of this.rightLeaves) callback(leaf) }
/** * Detach all leaves of a specific view type. * Closes the view on each leaf before removing. * Obsidian-compatible API. */ detachLeavesOfType(viewType: string): void { const leaves = this.getLeavesOfType(viewType) for (const leaf of leaves) { this._activeEditorSuppressedLeaves.add(leaf) } try { for (const leaf of leaves) { this.detachLeaf(leaf) } } finally { for (const leaf of leaves) { this._activeEditorSuppressedLeaves.delete(leaf) } } }
async duplicateLeaf(leaf: WorkspaceLeaf, direction?: SplitDirection): Promise<WorkspaceLeaf> async duplicateLeaf(leaf: WorkspaceLeaf, leafType: PaneType | boolean, direction?: SplitDirection): Promise<WorkspaceLeaf> async duplicateLeaf( leaf: WorkspaceLeaf, leafTypeOrDirection?: PaneType | boolean | SplitDirection, direction?: SplitDirection, ): Promise<WorkspaceLeaf> { const inferredDirection = leafTypeOrDirection === 'vertical' || leafTypeOrDirection === 'horizontal' ? leafTypeOrDirection : direction const target = inferredDirection ? this.createLeafBySplit(leaf, inferredDirection) : this.getLeaf(true) if (leaf.file) { await target.openFile(leaf.file, { active: false }) } else if (leaf.view) { await target.setViewState(leaf.getViewState(), leaf.getEphemeralState()) } return target }
moveLeafToPopout(_leaf: WorkspaceLeaf, _data?: WorkspaceWindowInitData): WorkspaceWindow { throw new Error('Workspace.moveLeafToPopout() is not available in Reams (browser-based PWA).') }
openPopoutLeaf(_data?: WorkspaceWindowInitData): WorkspaceLeaf { throw new Error('Workspace.openPopoutLeaf() is not available in Reams (browser-based PWA).') }
getGroupLeaves(group: string): WorkspaceLeaf[] { const matches: WorkspaceLeaf[] = [] this.iterateAllLeaves((leaf) => { if ((leaf as any)._group === group) { matches.push(leaf) } }) return matches }
getLastOpenFiles(): string[] { return this._lastOpenFiles.slice() }
handleLinkContextMenu(_menu: Menu, _linktext: string, _sourcePath: string, _leaf?: WorkspaceLeaf): boolean { return false }
/** * Register a hover link source. * No-op stub — Reams does not implement hover previews. * Plugins like Recent Files call this to enable link hover previews. * Obsidian-compatible API. * * @internal */ registerHoverLinkSource(_id: string, _info: HoverLinkSource): void { // No-op: Reams does not implement hover link previews }
/** * Unregister a hover link source. * No-op stub — Reams does not implement hover previews. * Obsidian-compatible API. * * @internal */ unregisterHoverLinkSource(_id: string): void { // No-op: Reams does not implement hover link previews }
/** * Get a serializable manifest of all sidebar leaves. * Returns `{ viewType, side }` for each leaf that has a view. * Used by the persistence layer to save sidebar layout. * * @internal Host-internal persistence. Called by AppShell before page * unload to save sidebar state. Plugins should not rely on this format. */ getSidebarLeafManifest(): Array<{ viewType: string; side: 'left' | 'right' }> { const result: Array<{ viewType: string; side: 'left' | 'right' }> = [] for (const leaf of this.leftLeaves) { if (leaf.view) { result.push({ viewType: leaf.view.getViewType(), side: 'left' }) } } for (const leaf of this.rightLeaves) { if (leaf.view) { result.push({ viewType: leaf.view.getViewType(), side: 'right' }) } } return result }
/** * Restore sidebar leaves from a persisted manifest. * * For each entry, creates a leaf on the specified side and opens the view * — but only if the view type is registered and no leaf of that type * already exists on that side (idempotent with built-in plugins that * create their own leaves in onload/onLayoutReady). * * Must be called after all plugins have registered their view types * (i.e., after enableStoredPlugins()) but before setLayoutReady(). * * @internal Host-internal persistence. Called by AppShell on startup * to restore saved sidebar state. Plugins should not call this directly. */ async restoreSidebarLeaves(manifest: Array<{ viewType: string; side: 'left' | 'right' }>): Promise<void> { const pending: Promise<void>[] = []
for (const entry of manifest) { // Skip if view type isn't registered (plugin uninstalled or disabled) if (!this.viewRegistry.has(entry.viewType)) continue
// Skip if a leaf of this type already exists on this side // (built-in plugin already created it during onload) const sideLeaves = entry.side === 'left' ? this.leftLeaves : this.rightLeaves const exists = sideLeaves.some(l => l.view?.getViewType() === entry.viewType) if (exists) continue
// Create leaf and open view — collect promises so caller can await. // If setViewState fails, detach the zombie leaf (no view) so // onLayoutReady guards using getLeavesOfType won't miss it. const leaf = entry.side === 'left' ? this.getLeftLeaf(true) : this.getRightLeaf(true)
pending.push( leaf.setViewState({ type: entry.viewType }).catch(err => { console.error(`Failed to restore sidebar leaf '${entry.viewType}':`, err) leaf.detach() }) ) }
// Wait for all views to be created so callers (AppShell) can // call setLayoutReady() only after leaves have their views populated. // This prevents built-in plugins from creating duplicate leaves. await Promise.all(pending) }
/** * Serialize main-area tab layout for persistence. * Returns `{tabs: [{docUrl}], activeIndex}` for workspace-owned tab persistence. * * Only serializes docUrl — titles are resolved from MetadataCache at load time. * Leaf IDs are transient (regenerated on restore). * * Precondition: `this.app` must be set (via App constructor wiring). * * @internal Host-internal persistence. Called by the tab persistence layer * before page unload. Plugins should not rely on this format. */ serializeLayout(): { tabs: Array<{ docUrl: string }>; activeIndex: number } { const tabs: Array<{ docUrl: string }> = [] let activeIndex = -1 this.iterateRootLeaves(leaf => { const file = leaf.file ?? (leaf.view as { file?: import('./files').TFile | null } | null)?.file if (file && this.app) { const url = this.app.vault.getUrlForPath(file.path) if (url) { if (leaf === this.activeLeaf) { activeIndex = tabs.length } tabs.push({ docUrl: url }) } } }) return { tabs, activeIndex: activeIndex >= 0 ? activeIndex : 0 } }
/** * Get the workspace layout in Obsidian's layout tree format. * * Returns a degenerate tree representing main-area tabs as leaf children * of a single 'split' node. Reams is mobile-first without splits, so this * returns a flat structure rather than Obsidian's full nested split tree. * * Shape matches Obsidian's: `{ main, left, right, floating, active }`. * - `main`: The main editing area with tabs as leaf children of a single split * - `left`/`right`/`floating`: Stub empty splits (Reams doesn't support sidebars in layout) * - `active`: The active leaf ID or null * * Note: This is an Obsidian-shape stub with limited functionality. Reams * does not support true split layouts — this returns a degenerate single- * split representation for API compatibility. For full tab persistence in * Reams, use `serializeLayout()` / `restoreLayout()` instead. */ getLayout(): Record<string, unknown> { const children: Array<{ type: 'leaf'; id: string }> = [] this.iterateRootLeaves(leaf => { children.push({ type: 'leaf', id: leaf.id }) })
// Obsidian's layout includes left/right/floating sidebars. // Reams is mobile-first without sidebars in layout, so these are stubs. // Each stub gets its own object to avoid shared-reference mutation issues. return { main: { type: 'split', children }, left: { type: 'split', children: [] as unknown[] }, right: { type: 'split', children: [] as unknown[] }, floating: { type: 'split', children: [] as unknown[] }, active: this.activeLeaf?.id ?? null, } }
/** * Set the workspace layout from Obsidian's layout tree format. * * **NOT IMPLEMENTED.** Reams is mobile-first and does not support split * layouts. This method always rejects with a `NotImplementedError` and a * helpful message directing callers to Reams-specific alternatives. * * For tab persistence in Reams, use `serializeLayout()` to save and * `restoreLayout()` to restore main-area tab state. * * @throws {NotImplementedError} Always rejects — Reams does not support * Obsidian's split layout system. */ async changeLayout( _workspace: any, ): Promise<void> { const err = new Error( 'Reams does not support split layouts. Use workspace.serializeLayout/restoreLayout for tab state.', ) err.name = 'NotImplementedError' throw err }
/** * Restore main-area tab layout from persisted state. * Creates a WorkspaceLeaf + MarkdownView for each tab, sets files from vault lookup. * * Must be called after vault.init() so file lookups succeed. * Fires 'layout-change' once after all leaves are created. * * Note: This is async only because it awaits the MarkdownView import. If * MarkdownView is already loaded (typical case), this completes synchronously * modulo the Promise wrapper. * * @internal Host-internal persistence. Called by the tab persistence layer * on startup. Plugins should not call this directly. * * @param state Persisted layout from serializeLayout * @param vault Vault instance for file lookup */ async restoreLayout( state: { tabs: Array<{ docUrl: string }>; activeIndex: number }, vault: import('./vault').Vault, ): Promise<void> { // Coalesce concurrent calls onto a single in-flight promise. Critical // for React StrictMode: the first mount calls `restoreLayout`, gets // unmounted mid-loop, and a second mount fires while the first is // still awaiting `_openLeaf`. Without coalescing, the second mount // would either (a) see phantom leaves in `iterateRootLeaves` from the // first mount's partial restore (causing the URL load to race against // an inconsistent workspace), or (b) call `restoreLayout` again, which // would clear those in-flight leaves and produce duplicates. // // The flag survives StrictMode unmount because Workspace lives on // `pluginApp` (useMemo, no remount). Callers `await` whatever the // workspace's current restore is — fresh or already-running — and // see a stable state by the time the await resolves. if (this._restoreLayoutInFlight) { return this._restoreLayoutInFlight } this._restoreLayoutInFlight = this._doRestoreLayout(state, vault) try { await this._restoreLayoutInFlight } finally { this._restoreLayoutInFlight = null } }
/** * Returns the in-flight `restoreLayout` promise if one is running, else * a resolved promise. Lets bootstrap callers `await` whatever restore * the workspace is currently doing without needing to know whether they * are first-mount or remount. * * @internal Host-internal lifecycle for App.tsx bootstrap sequencing. */ whenLayoutRestored(): Promise<void> { return this._restoreLayoutInFlight ?? Promise.resolve() }
/** @internal Backing field for `restoreLayout` coalescing. */ private _restoreLayoutInFlight: Promise<void> | null = null
/** @internal Load a saved tab through the ordinary epoch-guarded lifecycle. */ async hydrateDeferredLeaf(leaf: WorkspaceLeaf, file: TFile): Promise<void> { if (!this.leaves.includes(leaf) || !leaf.isDeferred || leaf.file !== file) return await this._openLeaf(leaf, file) if (this.activeLeaf === leaf) this.trigger('layout-change') }
/** @internal Continue restoring background tabs once the active editor is visible. */ resumeDeferredViews(): void { if (this._deferredHydrationStarted) return this._deferredHydrationStarted = true const generation = this._deferredHydrationGeneration const hydrateNext = async () => { if (generation !== this._deferredHydrationGeneration) return const leaf = this.leaves.find(candidate => candidate.isDeferred) if (!leaf) { this._deferredHydrationStarted = false; return } try { await leaf.loadIfDeferred() } catch (error) { console.error('Could not restore deferred tab:', error) } if (generation === this._deferredHydrationGeneration) this._deferredHydrationTimer = setTimeout(() => { void hydrateNext() }, 50) } // Allow a paint before any inactive file lookup. Clicking a tab loads it // immediately, regardless of this background schedule. this._deferredHydrationTimer = setTimeout(() => { void hydrateNext() }, 250) } private _deferredHydrationStarted = false private _deferredHydrationGeneration = 0 private _deferredHydrationTimer: ReturnType<typeof setTimeout> | null = null
/** @internal Stop the background pass on remount or layout replacement. */ pauseDeferredViews(): void { this._deferredHydrationGeneration++ this._deferredHydrationStarted = false if (this._deferredHydrationTimer) clearTimeout(this._deferredHydrationTimer) this._deferredHydrationTimer = null }
/** @internal The actual restore work. Separated so {@link restoreLayout} * can coalesce concurrent calls onto a single in-flight promise. */ private async _doRestoreLayout( state: { tabs: Array<{ docUrl: string }>; activeIndex: number }, vault: import('./vault').Vault, ): Promise<void> { this.pauseDeferredViews() // Clear existing main-area leaves (but not sidebar leaves) const existingLeaves: WorkspaceLeaf[] = [] this.iterateRootLeaves(leaf => existingLeaves.push(leaf)) for (const leaf of existingLeaves) { this.detachLeaf(leaf) }
// Create leaves for each persisted tab. View-type dispatch is content-type // driven (see WorkspaceLeaf.openFile): persisted state stays migration-free // because the view type is re-derived from the file's content type on // restore. const newLeaves: WorkspaceLeaf[] = [] const deferInactive = state.tabs.length >= 12 for (const tab of state.tabs) { const file = vault.getFileByUrl(tab.docUrl as AutomergeUrl) if (!file) continue // Skip tabs whose files no longer exist
const leaf = this.getLeaf(true) if (deferInactive) leaf.installDeferredFile(file) else await this._openLeaf(leaf, file) newLeaves.push(leaf) }
// Set active leaf if (newLeaves.length > 0) { const activeIdx = Math.min(Math.max(0, state.activeIndex), newLeaves.length - 1) if (deferInactive) await newLeaves[activeIdx]!.loadIfDeferred() this.setActiveLeaf(newLeaves[activeIdx]) }
this.trigger('layout-change') }}