/** * Per-vault persistence of the tiling layout to localStorage. * * We persist the split tree, the active tile, and each tile's *documents* * (provider + entry + title) plus which one was active. Runtime-only state — * document handles and loading/error status — is not persisted; documents are * re-opened on restore, which recreates their handles. */ import { listTileIds, type TilingNode } from '../../tiling/model'; import type { EditorMode } from '../../editors/types'; import type { OpenDocRequest } from '../workspace'; import { createTile, type TileTabsMap } from './tabsModel'; const STORAGE_PREFIX = 'textile.tiling.'; const VERSION = 2; const DEFAULT_MODE: EditorMode = 'edit'; /** A persisted tab: its document request plus rendering mode. */ export type PersistedTab = { request: OpenDocRequest; mode: EditorMode; }; export type PersistedTile = { tileId: string; /** Open documents in tab order (empty start tabs are not persisted). */ tabs: PersistedTab[]; /** Index into `tabs` of the active document, or -1 when the tile has none. */ activeIndex: number; }; export type PersistedLayout = { version: typeof VERSION; tree: TilingNode; activeTileId: string; tiles: PersistedTile[]; }; /** * Normalize a persisted tab across schema versions: v1 stored a bare * `OpenDocRequest`; v2 stores `{ request, mode }`. Missing mode defaults to * `edit`. */ function normalizeTab(tab: PersistedTab | OpenDocRequest): PersistedTab { if ('request' in tab) { return { request: tab.request, mode: tab.mode ?? DEFAULT_MODE }; } return { request: tab, mode: DEFAULT_MODE }; } /** Serialize the live layout into a persistable snapshot. */ export function serializeLayout( tree: TilingNode, activeTileId: string, tileTabs: TileTabsMap, ): PersistedLayout { const tiles: PersistedTile[] = listTileIds(tree).map((tileId) => { const tile = tileTabs[tileId]; const tabs: PersistedTab[] = []; let activeIndex = -1; if (tile) { for (const t of tile.tabs) { if (t.slot.kind === 'empty') continue; if (t.tabId === tile.activeTabId) activeIndex = tabs.length; tabs.push({ request: t.slot.request, mode: t.mode }); } } return { tileId, tabs, activeIndex }; }); return { version: VERSION, tree, activeTileId, tiles }; } export type InitialTileTabs = { map: TileTabsMap; /** Documents to open after the initial (loading) state is rendered. */ pending: { tileId: string; tabId: string; request: OpenDocRequest }[]; /** Next free tab-id counter value (so the owner never reuses an id). */ nextTabCounter: number; }; /** * Build the initial tab map for a workspace from persisted tiles. Open * documents start in `loading`; the owner opens them (via `pending`) once * mounted. With no persisted tiles, falls back to a single empty tile. */ export function buildInitialTileTabs( tiles: PersistedTile[] | null | undefined, fallbackTileId: string, ): InitialTileTabs { let counter = 1; const mkId = () => `tab-${counter++}`; if (!tiles || tiles.length === 0) { return { map: { [fallbackTileId]: createTile(mkId()) }, pending: [], nextTabCounter: counter, }; } const map: TileTabsMap = {}; const pending: InitialTileTabs['pending'] = []; for (const t of tiles) { if (t.tabs.length === 0) { map[t.tileId] = createTile(mkId()); continue; } const tabs = t.tabs.map((rawTab) => { const { request, mode } = normalizeTab(rawTab); const tabId = mkId(); pending.push({ tileId: t.tileId, tabId, request }); return { tabId, slot: { kind: 'loading' as const, request }, mode }; }); const idx = Math.max(0, Math.min(t.activeIndex, tabs.length - 1)); map[t.tileId] = { tabs, activeTabId: tabs[idx].tabId }; } return { map, pending, nextTabCounter: counter }; } /** Largest numeric `-N` suffix across all node ids, for seeding id counters. */ export function maxIdSuffix(tree: TilingNode): number { let max = 0; const visit = (node: TilingNode) => { const m = /-(\d+)$/.exec(node.id); if (m) max = Math.max(max, Number(m[1])); if (node.kind === 'split') { visit(node.first); visit(node.second); } }; visit(tree); return max; } function storageKey(vaultId: string): string { return STORAGE_PREFIX + vaultId; } /** Read and validate a persisted layout for a vault, or null. */ export function loadLayout(vaultId: string): PersistedLayout | null { if (typeof localStorage === 'undefined') return null; try { const raw = localStorage.getItem(storageKey(vaultId)); if (!raw) return null; const parsed = JSON.parse(raw) as Omit & { version: number; }; // Accept v1 (tabs were bare OpenDocRequests) and v2 (tabs carry a mode); // `normalizeTab` in buildInitialTileTabs upgrades v1 tabs on read. if ( (parsed?.version !== 1 && parsed?.version !== 2) || !parsed.tree || typeof parsed.activeTileId !== 'string' || !Array.isArray(parsed.tiles) ) { return null; } return { ...parsed, version: VERSION }; } catch { return null; } } /** Persist a pre-serialized layout JSON for a vault. */ export function saveLayoutJson(vaultId: string, json: string): void { if (typeof localStorage === 'undefined') return; try { localStorage.setItem(storageKey(vaultId), json); } catch { // Quota / disabled storage — best effort, drop silently. } }