A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Tabs per tile (phase 5c): persist layout + selection to localStorage

Each vault's tile layout and tabs now survive an app restart, along with
the selected vault and (pre-existing) workspace kind.

- tilingPersistence: serializeLayout / buildInitialTileTabs / maxIdSuffix
(pure + tested) and load/save to localStorage under
textile.tiling.<vaultId>. Persists the split tree, active tile, and
each tile's documents + active index; not handles or load state.
- useTilingDocumentSlots: accept initialTiles, seed the tab map with
loading tabs, and re-open the documents in a mount-only effect
(cancelled on unmount). Exposes tileTabs for serialization.
- TilingWorkspace: lazy-init tree/activeTile/id-counter from the
persisted layout (keyed by instanceKey = vault provider id) and write
on change (skipping no-op rewrites).
- App: persist + restore selectedProviderId (textile.selectedVault); the
registry loads vaults synchronously so the restored vault survives the
auto-select effects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Ethan Graf (Jun 27, 2026, 11:45 AM EDT) cac66648 c345c38a

+374 -11
+23 -1
src/App.tsx
··· 81 81 } 82 82 } 83 83 84 + const SELECTED_VAULT_STORAGE_KEY = 'textile.selectedVault'; 85 + 86 + function loadInitialSelectedVault(): string { 87 + if (typeof localStorage === 'undefined') return ''; 88 + try { 89 + return localStorage.getItem(SELECTED_VAULT_STORAGE_KEY) ?? ''; 90 + } catch { 91 + return ''; 92 + } 93 + } 94 + 84 95 function AppShell() { 85 96 const [theme, setTheme] = useState<'light' | 'dark'>(() => { 86 97 if (typeof document === 'undefined') return 'light'; ··· 93 104 ); 94 105 const [openRequest, setOpenRequest] = useState<OpenDocRequest | null>(null); 95 106 const [openRequestId, setOpenRequestId] = useState(0); 96 - const [selectedProviderId, setSelectedProviderId] = useState<string>(''); 107 + const [selectedProviderId, setSelectedProviderId] = useState<string>( 108 + loadInitialSelectedVault, 109 + ); 97 110 const [vaultManagerOpen, setVaultManagerOpen] = useState(false); 98 111 const [mountedVaults, setMountedVaults] = useState(() => 99 112 vaultRegistry.getMounted(), ··· 279 292 // Storage may be unavailable (e.g. private mode); preference simply won't persist. 280 293 } 281 294 }, [workspaceKind]); 295 + 296 + useEffect(() => { 297 + if (typeof localStorage === 'undefined' || !selectedProviderId) return; 298 + try { 299 + localStorage.setItem(SELECTED_VAULT_STORAGE_KEY, selectedProviderId); 300 + } catch { 301 + // Best effort — selection simply won't persist if storage is unavailable. 302 + } 303 + }, [selectedProviderId]); 282 304 283 305 const handleOpenEntry = useCallback((request: OpenDocRequest) => { 284 306 setOpenRequest(request);
+31 -4
src/workspaces/tiling/tiling.tsx
··· 22 22 import { TileTabStrip, type TabAction } from './TileTabStrip'; 23 23 import { TileStartView } from './TileStartView'; 24 24 import type { TileTab } from './tabsModel'; 25 + import { 26 + loadLayout, 27 + maxIdSuffix, 28 + saveLayoutJson, 29 + serializeLayout, 30 + } from './tilingPersistence'; 25 31 import type { TilingHandle } from '../../actions'; 26 32 27 33 type SplitterProps = { ··· 162 168 163 169 export function TilingWorkspace({ 164 170 active, 171 + instanceKey, 165 172 openRequestId, 166 173 openRequest, 167 174 resolveProvider, 168 175 tilingHandleRef, 169 176 onNewFile, 170 177 }: WorkspaceProps) { 171 - const nextIdRef = useRef(2); 178 + // Restore this vault's persisted layout once (null when there is none). 179 + const [persisted] = useState(() => loadLayout(instanceKey)); 180 + 181 + // Seed the tile/split id counter past any persisted id to avoid collisions. 182 + const [initialIdCounter] = useState(() => 183 + persisted ? maxIdSuffix(persisted.tree) + 1 : 2, 184 + ); 185 + const nextIdRef = useRef(initialIdCounter); 172 186 const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; 173 187 174 - const [tree, setTree] = useState<TilingNode>(() => 175 - createInitialTree('tile-1'), 188 + const [tree, setTree] = useState<TilingNode>( 189 + () => persisted?.tree ?? createInitialTree('tile-1'), 190 + ); 191 + const [activeTileId, setActiveTileId] = useState( 192 + () => persisted?.activeTileId ?? 'tile-1', 176 193 ); 177 - const [activeTileId, setActiveTileId] = useState('tile-1'); 178 194 179 195 const { 196 + tileTabs, 180 197 getSlot, 181 198 getTabs, 182 199 getActiveTabId, ··· 195 212 openRequest, 196 213 resolveProvider, 197 214 activeTileId, 215 + initialTiles: persisted?.tiles ?? null, 198 216 }); 217 + 218 + // Persist layout changes for this vault (skips no-op rewrites). 219 + const lastSavedRef = useRef<string | null>(null); 220 + useEffect(() => { 221 + const json = JSON.stringify(serializeLayout(tree, activeTileId, tileTabs)); 222 + if (json === lastSavedRef.current) return; 223 + lastSavedRef.current = json; 224 + saveLayoutJson(instanceKey, json); 225 + }, [instanceKey, tree, activeTileId, tileTabs]); 199 226 200 227 /** 201 228 * Close a tab. When it was the tile's last tab, collapse the split (or, for
+117
src/workspaces/tiling/tilingPersistence.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import type { TilingNode } from '../../tiling/model'; 4 + import type { OpenDocRequest } from '../workspace'; 5 + import type { TileTabsMap } from './tabsModel'; 6 + import { 7 + buildInitialTileTabs, 8 + maxIdSuffix, 9 + serializeLayout, 10 + } from './tilingPersistence'; 11 + 12 + function req(entryId: string): OpenDocRequest { 13 + return { providerId: 'fs', entryId, title: entryId }; 14 + } 15 + 16 + function splitRow( 17 + id: string, 18 + first: TilingNode, 19 + second: TilingNode, 20 + ): TilingNode { 21 + return { kind: 'split', id, orientation: 'row', ratio: 0.5, first, second }; 22 + } 23 + 24 + describe('serializeLayout', () => { 25 + it('captures each tile’s documents and active index, skipping empty tabs', () => { 26 + const tree = splitRow( 27 + 'split-1', 28 + { kind: 'tile', id: 'tile-1' }, 29 + { kind: 'tile', id: 'tile-2' }, 30 + ); 31 + const tileTabs: TileTabsMap = { 32 + 'tile-1': { 33 + tabs: [ 34 + { tabId: 'tab-a', slot: { kind: 'loading', request: req('a') } }, 35 + { tabId: 'tab-b', slot: { kind: 'loading', request: req('b') } }, 36 + ], 37 + activeTabId: 'tab-b', 38 + }, 39 + 'tile-2': { 40 + tabs: [{ tabId: 'tab-empty', slot: { kind: 'empty' } }], 41 + activeTabId: 'tab-empty', 42 + }, 43 + }; 44 + 45 + const layout = serializeLayout(tree, 'tile-2', tileTabs); 46 + expect(layout.version).toBe(1); 47 + expect(layout.activeTileId).toBe('tile-2'); 48 + expect(layout.tree).toEqual(tree); 49 + expect(layout.tiles).toEqual([ 50 + { tileId: 'tile-1', tabs: [req('a'), req('b')], activeIndex: 1 }, 51 + { tileId: 'tile-2', tabs: [], activeIndex: -1 }, 52 + ]); 53 + }); 54 + }); 55 + 56 + describe('buildInitialTileTabs', () => { 57 + it('falls back to a single empty tile when there is nothing persisted', () => { 58 + const result = buildInitialTileTabs(null, 'tile-1'); 59 + expect(result.pending).toEqual([]); 60 + expect(result.map['tile-1'].tabs).toHaveLength(1); 61 + expect(result.map['tile-1'].tabs[0].slot).toEqual({ kind: 'empty' }); 62 + }); 63 + 64 + it('builds loading tabs and pending opens from persisted tiles', () => { 65 + const result = buildInitialTileTabs( 66 + [ 67 + { tileId: 'tile-1', tabs: [req('a'), req('b')], activeIndex: 1 }, 68 + { tileId: 'tile-2', tabs: [], activeIndex: -1 }, 69 + ], 70 + 'tile-1', 71 + ); 72 + 73 + // Two documents to open, all on tile-1. 74 + expect(result.pending.map((p) => p.request)).toEqual([req('a'), req('b')]); 75 + expect(result.pending.every((p) => p.tileId === 'tile-1')).toBe(true); 76 + 77 + const tile1 = result.map['tile-1']; 78 + expect(tile1.tabs.map((t) => t.slot.kind)).toEqual(['loading', 'loading']); 79 + // active index 1 -> second tab active. 80 + expect(tile1.activeTabId).toBe(tile1.tabs[1].tabId); 81 + 82 + // Empty persisted tile becomes a fresh empty tile. 83 + expect(result.map['tile-2'].tabs[0].slot).toEqual({ kind: 'empty' }); 84 + 85 + // Counter advanced past every minted id (3 tabs total). 86 + expect(result.nextTabCounter).toBe(4); 87 + }); 88 + 89 + it('clamps an out-of-range active index', () => { 90 + const result = buildInitialTileTabs( 91 + [{ tileId: 'tile-1', tabs: [req('a')], activeIndex: 9 }], 92 + 'tile-1', 93 + ); 94 + expect(result.map['tile-1'].activeTabId).toBe( 95 + result.map['tile-1'].tabs[0].tabId, 96 + ); 97 + }); 98 + }); 99 + 100 + describe('maxIdSuffix', () => { 101 + it('finds the largest numeric suffix across tile and split ids', () => { 102 + const tree = splitRow( 103 + 'split-7', 104 + { kind: 'tile', id: 'tile-2' }, 105 + splitRow( 106 + 'split-3', 107 + { kind: 'tile', id: 'tile-9' }, 108 + { kind: 'tile', id: 'tile-4' }, 109 + ), 110 + ); 111 + expect(maxIdSuffix(tree)).toBe(9); 112 + }); 113 + 114 + it('returns the suffix of a lone tile', () => { 115 + expect(maxIdSuffix({ kind: 'tile', id: 'tile-1' })).toBe(1); 116 + }); 117 + });
+147
src/workspaces/tiling/tilingPersistence.ts
··· 1 + /** 2 + * Per-vault persistence of the tiling layout to localStorage. 3 + * 4 + * We persist the split tree, the active tile, and each tile's *documents* 5 + * (provider + entry + title) plus which one was active. Runtime-only state — 6 + * document handles and loading/error status — is not persisted; documents are 7 + * re-opened on restore, which recreates their handles. 8 + */ 9 + import { listTileIds, type TilingNode } from '../../tiling/model'; 10 + import type { OpenDocRequest } from '../workspace'; 11 + import { createTile, type TileTabsMap } from './tabsModel'; 12 + 13 + const STORAGE_PREFIX = 'textile.tiling.'; 14 + const VERSION = 1; 15 + 16 + export type PersistedTile = { 17 + tileId: string; 18 + /** Open documents in tab order (empty start tabs are not persisted). */ 19 + tabs: OpenDocRequest[]; 20 + /** Index into `tabs` of the active document, or -1 when the tile has none. */ 21 + activeIndex: number; 22 + }; 23 + 24 + export type PersistedLayout = { 25 + version: typeof VERSION; 26 + tree: TilingNode; 27 + activeTileId: string; 28 + tiles: PersistedTile[]; 29 + }; 30 + 31 + /** Serialize the live layout into a persistable snapshot. */ 32 + export function serializeLayout( 33 + tree: TilingNode, 34 + activeTileId: string, 35 + tileTabs: TileTabsMap, 36 + ): PersistedLayout { 37 + const tiles: PersistedTile[] = listTileIds(tree).map((tileId) => { 38 + const tile = tileTabs[tileId]; 39 + const tabs: OpenDocRequest[] = []; 40 + let activeIndex = -1; 41 + if (tile) { 42 + for (const t of tile.tabs) { 43 + if (t.slot.kind === 'empty') continue; 44 + if (t.tabId === tile.activeTabId) activeIndex = tabs.length; 45 + tabs.push(t.slot.request); 46 + } 47 + } 48 + return { tileId, tabs, activeIndex }; 49 + }); 50 + return { version: VERSION, tree, activeTileId, tiles }; 51 + } 52 + 53 + export type InitialTileTabs = { 54 + map: TileTabsMap; 55 + /** Documents to open after the initial (loading) state is rendered. */ 56 + pending: { tileId: string; tabId: string; request: OpenDocRequest }[]; 57 + /** Next free tab-id counter value (so the owner never reuses an id). */ 58 + nextTabCounter: number; 59 + }; 60 + 61 + /** 62 + * Build the initial tab map for a workspace from persisted tiles. Open 63 + * documents start in `loading`; the owner opens them (via `pending`) once 64 + * mounted. With no persisted tiles, falls back to a single empty tile. 65 + */ 66 + export function buildInitialTileTabs( 67 + tiles: PersistedTile[] | null | undefined, 68 + fallbackTileId: string, 69 + ): InitialTileTabs { 70 + let counter = 1; 71 + const mkId = () => `tab-${counter++}`; 72 + 73 + if (!tiles || tiles.length === 0) { 74 + return { 75 + map: { [fallbackTileId]: createTile(mkId()) }, 76 + pending: [], 77 + nextTabCounter: counter, 78 + }; 79 + } 80 + 81 + const map: TileTabsMap = {}; 82 + const pending: InitialTileTabs['pending'] = []; 83 + for (const t of tiles) { 84 + if (t.tabs.length === 0) { 85 + map[t.tileId] = createTile(mkId()); 86 + continue; 87 + } 88 + const tabs = t.tabs.map((request) => { 89 + const tabId = mkId(); 90 + pending.push({ tileId: t.tileId, tabId, request }); 91 + return { tabId, slot: { kind: 'loading' as const, request } }; 92 + }); 93 + const idx = Math.max(0, Math.min(t.activeIndex, tabs.length - 1)); 94 + map[t.tileId] = { tabs, activeTabId: tabs[idx].tabId }; 95 + } 96 + return { map, pending, nextTabCounter: counter }; 97 + } 98 + 99 + /** Largest numeric `-N` suffix across all node ids, for seeding id counters. */ 100 + export function maxIdSuffix(tree: TilingNode): number { 101 + let max = 0; 102 + const visit = (node: TilingNode) => { 103 + const m = /-(\d+)$/.exec(node.id); 104 + if (m) max = Math.max(max, Number(m[1])); 105 + if (node.kind === 'split') { 106 + visit(node.first); 107 + visit(node.second); 108 + } 109 + }; 110 + visit(tree); 111 + return max; 112 + } 113 + 114 + function storageKey(vaultId: string): string { 115 + return STORAGE_PREFIX + vaultId; 116 + } 117 + 118 + /** Read and validate a persisted layout for a vault, or null. */ 119 + export function loadLayout(vaultId: string): PersistedLayout | null { 120 + if (typeof localStorage === 'undefined') return null; 121 + try { 122 + const raw = localStorage.getItem(storageKey(vaultId)); 123 + if (!raw) return null; 124 + const parsed = JSON.parse(raw) as PersistedLayout; 125 + if ( 126 + parsed?.version !== VERSION || 127 + !parsed.tree || 128 + typeof parsed.activeTileId !== 'string' || 129 + !Array.isArray(parsed.tiles) 130 + ) { 131 + return null; 132 + } 133 + return parsed; 134 + } catch { 135 + return null; 136 + } 137 + } 138 + 139 + /** Persist a pre-serialized layout JSON for a vault. */ 140 + export function saveLayoutJson(vaultId: string, json: string): void { 141 + if (typeof localStorage === 'undefined') return; 142 + try { 143 + localStorage.setItem(storageKey(vaultId), json); 144 + } catch { 145 + // Quota / disabled storage — best effort, drop silently. 146 + } 147 + }
+50 -6
src/workspaces/tiling/useTilingDocumentSlots.ts
··· 6 6 import type { OpenDocRequest, WorkspaceProps } from '../workspace'; 7 7 import * as tabsModel from './tabsModel'; 8 8 import type { TileTab, TileTabsMap } from './tabsModel'; 9 + import { buildInitialTileTabs, type PersistedTile } from './tilingPersistence'; 9 10 10 11 const INITIAL_TILE_ID = 'tile-1'; 11 - const INITIAL_TAB_ID = 'tab-1'; 12 12 13 13 export type UseTilingDocumentSlotsInput = { 14 14 /** When false (workspace not shown), open intents are ignored. */ ··· 17 17 openRequest: OpenDocRequest | null; 18 18 resolveProvider: WorkspaceProps['resolveProvider']; 19 19 activeTileId: string; 20 + /** Persisted tiles to restore on mount (their documents are re-opened). */ 21 + initialTiles?: PersistedTile[] | null; 20 22 }; 21 23 22 24 /** ··· 34 36 openRequest, 35 37 resolveProvider, 36 38 activeTileId, 39 + initialTiles, 37 40 }: UseTilingDocumentSlotsInput) { 38 - const [tileTabs, setTileTabs] = useState<TileTabsMap>(() => ({ 39 - [INITIAL_TILE_ID]: tabsModel.createTile(INITIAL_TAB_ID), 40 - })); 41 + // Build the initial map from persisted tiles (documents start in `loading`; 42 + // they are opened by the mount effect below). Computed once. 43 + const [initial] = useState(() => 44 + buildInitialTileTabs(initialTiles, INITIAL_TILE_ID), 45 + ); 46 + const [tileTabs, setTileTabs] = useState<TileTabsMap>(initial.map); 41 47 // Mirror for synchronous reads inside callbacks/effects without adding 42 48 // `tileTabs` to dependency arrays. 43 49 const tileTabsRef = useRef(tileTabs); ··· 46 52 const handleRefs = useRef(new Map<string, DocumentHandle>()); 47 53 const lastSeenIdRef = useRef(openRequestId); 48 54 49 - // Mints tab ids; starts past the seeded INITIAL_TAB_ID. 50 - const nextTabId = useRef(2); 55 + // Mints tab ids; starts past every id minted for the initial state. 56 + const nextTabId = useRef(initial.nextTabCounter); 51 57 const makeTabId = useCallback(() => `tab-${nextTabId.current++}`, []); 52 58 53 59 const releaseTab = useCallback((tabId: string) => { ··· 65 71 } 66 72 handleRefs.current.clear(); 67 73 }; 74 + }, []); 75 + 76 + // Open the documents restored from persistence. Runs once on mount; opens are 77 + // cancelled on unmount (cancelled opens release any handle they produced). 78 + useEffect(() => { 79 + if (initial.pending.length === 0) return; 80 + const cancels = initial.pending.map(({ tileId, tabId, request }) => 81 + startDocumentOpen(request, resolveProvider, { 82 + onLoading: () => {}, 83 + onOpen: (handle) => { 84 + handleRefs.current.set(tabId, handle); 85 + setTileTabs((prev) => 86 + tabsModel.setTabSlot(prev, tileId, tabId, { 87 + kind: 'open', 88 + request, 89 + handle, 90 + }), 91 + ); 92 + }, 93 + onError: (message) => { 94 + setTileTabs((prev) => 95 + tabsModel.setTabSlot(prev, tileId, tabId, { 96 + kind: 'error', 97 + request, 98 + message, 99 + }), 100 + ); 101 + }, 102 + takePreviousHandle: () => null, 103 + isStaleHandle: () => false, 104 + }), 105 + ); 106 + return () => { 107 + for (const cancel of cancels) cancel(); 108 + }; 109 + // Mount-only: `initial` is stable and we capture the mount-time provider. 68 110 }, []); 69 111 70 112 useEffect(() => { ··· 237 279 ); 238 280 239 281 return { 282 + /** Live tab map — exposed read-only for layout serialization. */ 283 + tileTabs, 240 284 getSlot, 241 285 getTabs, 242 286 getActiveTabId,
+6
src/workspaces/workspace.tsx
··· 43 43 */ 44 44 active: boolean; 45 45 /** 46 + * Stable per-vault key (the vault's provider id). Used as the persistence 47 + * namespace so each vault restores its own layout. 48 + */ 49 + instanceKey: string; 50 + /** 46 51 * Monotonically bumped each time the shell issues a new open intent. 47 52 * Workspaces detect "a new open arrived" by comparing against their 48 53 * last-seen id, which avoids re-opening on unrelated re-renders. ··· 163 168 <WorkspaceActiveProvider value={isActive}> 164 169 <Component 165 170 active={isActive} 171 + instanceKey={vaultKey} 166 172 openRequestId={openRequestId} 167 173 openRequest={openRequest} 168 174 resolveProvider={resolveProvider}