Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
5.5 kB · 172 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173/** * 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<PersistedLayout, 'version'> & { 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. }}