From cac66648ffe819a51e5d04deac340cd6432f1abb Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Sat, 27 Jun 2026 11:45:00 -0400 Subject: [PATCH] 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.. 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 --- src/App.tsx | 24 ++- src/workspaces/tiling/tiling.tsx | 35 ++++- .../tiling/tilingPersistence.test.ts | 117 ++++++++++++++ src/workspaces/tiling/tilingPersistence.ts | 147 ++++++++++++++++++ .../tiling/useTilingDocumentSlots.ts | 56 ++++++- src/workspaces/workspace.tsx | 6 + 6 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 src/workspaces/tiling/tilingPersistence.test.ts create mode 100644 src/workspaces/tiling/tilingPersistence.ts diff --git a/src/App.tsx b/src/App.tsx index 0fc18c5..c3933e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,6 +81,17 @@ function loadInitialWorkspaceKind(): WorkspaceKind { } } +const SELECTED_VAULT_STORAGE_KEY = 'textile.selectedVault'; + +function loadInitialSelectedVault(): string { + if (typeof localStorage === 'undefined') return ''; + try { + return localStorage.getItem(SELECTED_VAULT_STORAGE_KEY) ?? ''; + } catch { + return ''; + } +} + function AppShell() { const [theme, setTheme] = useState<'light' | 'dark'>(() => { if (typeof document === 'undefined') return 'light'; @@ -93,7 +104,9 @@ function AppShell() { ); const [openRequest, setOpenRequest] = useState(null); const [openRequestId, setOpenRequestId] = useState(0); - const [selectedProviderId, setSelectedProviderId] = useState(''); + const [selectedProviderId, setSelectedProviderId] = useState( + loadInitialSelectedVault, + ); const [vaultManagerOpen, setVaultManagerOpen] = useState(false); const [mountedVaults, setMountedVaults] = useState(() => vaultRegistry.getMounted(), @@ -280,6 +293,15 @@ function AppShell() { } }, [workspaceKind]); + useEffect(() => { + if (typeof localStorage === 'undefined' || !selectedProviderId) return; + try { + localStorage.setItem(SELECTED_VAULT_STORAGE_KEY, selectedProviderId); + } catch { + // Best effort — selection simply won't persist if storage is unavailable. + } + }, [selectedProviderId]); + const handleOpenEntry = useCallback((request: OpenDocRequest) => { setOpenRequest(request); setOpenRequestId((id) => id + 1); diff --git a/src/workspaces/tiling/tiling.tsx b/src/workspaces/tiling/tiling.tsx index 933a8b6..720075f 100644 --- a/src/workspaces/tiling/tiling.tsx +++ b/src/workspaces/tiling/tiling.tsx @@ -22,6 +22,12 @@ import { useTilingDocumentSlots } from './useTilingDocumentSlots'; import { TileTabStrip, type TabAction } from './TileTabStrip'; import { TileStartView } from './TileStartView'; import type { TileTab } from './tabsModel'; +import { + loadLayout, + maxIdSuffix, + saveLayoutJson, + serializeLayout, +} from './tilingPersistence'; import type { TilingHandle } from '../../actions'; type SplitterProps = { @@ -162,21 +168,32 @@ function TilingTile({ export function TilingWorkspace({ active, + instanceKey, openRequestId, openRequest, resolveProvider, tilingHandleRef, onNewFile, }: WorkspaceProps) { - const nextIdRef = useRef(2); + // Restore this vault's persisted layout once (null when there is none). + const [persisted] = useState(() => loadLayout(instanceKey)); + + // Seed the tile/split id counter past any persisted id to avoid collisions. + const [initialIdCounter] = useState(() => + persisted ? maxIdSuffix(persisted.tree) + 1 : 2, + ); + const nextIdRef = useRef(initialIdCounter); const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; - const [tree, setTree] = useState(() => - createInitialTree('tile-1'), + const [tree, setTree] = useState( + () => persisted?.tree ?? createInitialTree('tile-1'), + ); + const [activeTileId, setActiveTileId] = useState( + () => persisted?.activeTileId ?? 'tile-1', ); - const [activeTileId, setActiveTileId] = useState('tile-1'); const { + tileTabs, getSlot, getTabs, getActiveTabId, @@ -195,8 +212,18 @@ export function TilingWorkspace({ openRequest, resolveProvider, activeTileId, + initialTiles: persisted?.tiles ?? null, }); + // Persist layout changes for this vault (skips no-op rewrites). + const lastSavedRef = useRef(null); + useEffect(() => { + const json = JSON.stringify(serializeLayout(tree, activeTileId, tileTabs)); + if (json === lastSavedRef.current) return; + lastSavedRef.current = json; + saveLayoutJson(instanceKey, json); + }, [instanceKey, tree, activeTileId, tileTabs]); + /** * Close a tab. When it was the tile's last tab, collapse the split (or, for * the sole remaining tile, reseed it as an empty tile) and move focus. diff --git a/src/workspaces/tiling/tilingPersistence.test.ts b/src/workspaces/tiling/tilingPersistence.test.ts new file mode 100644 index 0000000..ff4ddd4 --- /dev/null +++ b/src/workspaces/tiling/tilingPersistence.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import type { TilingNode } from '../../tiling/model'; +import type { OpenDocRequest } from '../workspace'; +import type { TileTabsMap } from './tabsModel'; +import { + buildInitialTileTabs, + maxIdSuffix, + serializeLayout, +} from './tilingPersistence'; + +function req(entryId: string): OpenDocRequest { + return { providerId: 'fs', entryId, title: entryId }; +} + +function splitRow( + id: string, + first: TilingNode, + second: TilingNode, +): TilingNode { + return { kind: 'split', id, orientation: 'row', ratio: 0.5, first, second }; +} + +describe('serializeLayout', () => { + it('captures each tile’s documents and active index, skipping empty tabs', () => { + const tree = splitRow( + 'split-1', + { kind: 'tile', id: 'tile-1' }, + { kind: 'tile', id: 'tile-2' }, + ); + const tileTabs: TileTabsMap = { + 'tile-1': { + tabs: [ + { tabId: 'tab-a', slot: { kind: 'loading', request: req('a') } }, + { tabId: 'tab-b', slot: { kind: 'loading', request: req('b') } }, + ], + activeTabId: 'tab-b', + }, + 'tile-2': { + tabs: [{ tabId: 'tab-empty', slot: { kind: 'empty' } }], + activeTabId: 'tab-empty', + }, + }; + + const layout = serializeLayout(tree, 'tile-2', tileTabs); + expect(layout.version).toBe(1); + expect(layout.activeTileId).toBe('tile-2'); + expect(layout.tree).toEqual(tree); + expect(layout.tiles).toEqual([ + { tileId: 'tile-1', tabs: [req('a'), req('b')], activeIndex: 1 }, + { tileId: 'tile-2', tabs: [], activeIndex: -1 }, + ]); + }); +}); + +describe('buildInitialTileTabs', () => { + it('falls back to a single empty tile when there is nothing persisted', () => { + const result = buildInitialTileTabs(null, 'tile-1'); + expect(result.pending).toEqual([]); + expect(result.map['tile-1'].tabs).toHaveLength(1); + expect(result.map['tile-1'].tabs[0].slot).toEqual({ kind: 'empty' }); + }); + + it('builds loading tabs and pending opens from persisted tiles', () => { + const result = buildInitialTileTabs( + [ + { tileId: 'tile-1', tabs: [req('a'), req('b')], activeIndex: 1 }, + { tileId: 'tile-2', tabs: [], activeIndex: -1 }, + ], + 'tile-1', + ); + + // Two documents to open, all on tile-1. + expect(result.pending.map((p) => p.request)).toEqual([req('a'), req('b')]); + expect(result.pending.every((p) => p.tileId === 'tile-1')).toBe(true); + + const tile1 = result.map['tile-1']; + expect(tile1.tabs.map((t) => t.slot.kind)).toEqual(['loading', 'loading']); + // active index 1 -> second tab active. + expect(tile1.activeTabId).toBe(tile1.tabs[1].tabId); + + // Empty persisted tile becomes a fresh empty tile. + expect(result.map['tile-2'].tabs[0].slot).toEqual({ kind: 'empty' }); + + // Counter advanced past every minted id (3 tabs total). + expect(result.nextTabCounter).toBe(4); + }); + + it('clamps an out-of-range active index', () => { + const result = buildInitialTileTabs( + [{ tileId: 'tile-1', tabs: [req('a')], activeIndex: 9 }], + 'tile-1', + ); + expect(result.map['tile-1'].activeTabId).toBe( + result.map['tile-1'].tabs[0].tabId, + ); + }); +}); + +describe('maxIdSuffix', () => { + it('finds the largest numeric suffix across tile and split ids', () => { + const tree = splitRow( + 'split-7', + { kind: 'tile', id: 'tile-2' }, + splitRow( + 'split-3', + { kind: 'tile', id: 'tile-9' }, + { kind: 'tile', id: 'tile-4' }, + ), + ); + expect(maxIdSuffix(tree)).toBe(9); + }); + + it('returns the suffix of a lone tile', () => { + expect(maxIdSuffix({ kind: 'tile', id: 'tile-1' })).toBe(1); + }); +}); diff --git a/src/workspaces/tiling/tilingPersistence.ts b/src/workspaces/tiling/tilingPersistence.ts new file mode 100644 index 0000000..876f5db --- /dev/null +++ b/src/workspaces/tiling/tilingPersistence.ts @@ -0,0 +1,147 @@ +/** + * 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 { OpenDocRequest } from '../workspace'; +import { createTile, type TileTabsMap } from './tabsModel'; + +const STORAGE_PREFIX = 'textile.tiling.'; +const VERSION = 1; + +export type PersistedTile = { + tileId: string; + /** Open documents in tab order (empty start tabs are not persisted). */ + tabs: OpenDocRequest[]; + /** 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[]; +}; + +/** 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: OpenDocRequest[] = []; + 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(t.slot.request); + } + } + 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((request) => { + const tabId = mkId(); + pending.push({ tileId: t.tileId, tabId, request }); + return { tabId, slot: { kind: 'loading' as const, request } }; + }); + 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 PersistedLayout; + if ( + parsed?.version !== VERSION || + !parsed.tree || + typeof parsed.activeTileId !== 'string' || + !Array.isArray(parsed.tiles) + ) { + return null; + } + return parsed; + } 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. + } +} diff --git a/src/workspaces/tiling/useTilingDocumentSlots.ts b/src/workspaces/tiling/useTilingDocumentSlots.ts index af2a36e..66a6162 100644 --- a/src/workspaces/tiling/useTilingDocumentSlots.ts +++ b/src/workspaces/tiling/useTilingDocumentSlots.ts @@ -6,9 +6,9 @@ import type { DocumentHandle } from '../../documents/types'; import type { OpenDocRequest, WorkspaceProps } from '../workspace'; import * as tabsModel from './tabsModel'; import type { TileTab, TileTabsMap } from './tabsModel'; +import { buildInitialTileTabs, type PersistedTile } from './tilingPersistence'; const INITIAL_TILE_ID = 'tile-1'; -const INITIAL_TAB_ID = 'tab-1'; export type UseTilingDocumentSlotsInput = { /** When false (workspace not shown), open intents are ignored. */ @@ -17,6 +17,8 @@ export type UseTilingDocumentSlotsInput = { openRequest: OpenDocRequest | null; resolveProvider: WorkspaceProps['resolveProvider']; activeTileId: string; + /** Persisted tiles to restore on mount (their documents are re-opened). */ + initialTiles?: PersistedTile[] | null; }; /** @@ -34,10 +36,14 @@ export function useTilingDocumentSlots({ openRequest, resolveProvider, activeTileId, + initialTiles, }: UseTilingDocumentSlotsInput) { - const [tileTabs, setTileTabs] = useState(() => ({ - [INITIAL_TILE_ID]: tabsModel.createTile(INITIAL_TAB_ID), - })); + // Build the initial map from persisted tiles (documents start in `loading`; + // they are opened by the mount effect below). Computed once. + const [initial] = useState(() => + buildInitialTileTabs(initialTiles, INITIAL_TILE_ID), + ); + const [tileTabs, setTileTabs] = useState(initial.map); // Mirror for synchronous reads inside callbacks/effects without adding // `tileTabs` to dependency arrays. const tileTabsRef = useRef(tileTabs); @@ -46,8 +52,8 @@ export function useTilingDocumentSlots({ const handleRefs = useRef(new Map()); const lastSeenIdRef = useRef(openRequestId); - // Mints tab ids; starts past the seeded INITIAL_TAB_ID. - const nextTabId = useRef(2); + // Mints tab ids; starts past every id minted for the initial state. + const nextTabId = useRef(initial.nextTabCounter); const makeTabId = useCallback(() => `tab-${nextTabId.current++}`, []); const releaseTab = useCallback((tabId: string) => { @@ -67,6 +73,42 @@ export function useTilingDocumentSlots({ }; }, []); + // Open the documents restored from persistence. Runs once on mount; opens are + // cancelled on unmount (cancelled opens release any handle they produced). + useEffect(() => { + if (initial.pending.length === 0) return; + const cancels = initial.pending.map(({ tileId, tabId, request }) => + startDocumentOpen(request, resolveProvider, { + onLoading: () => {}, + onOpen: (handle) => { + handleRefs.current.set(tabId, handle); + setTileTabs((prev) => + tabsModel.setTabSlot(prev, tileId, tabId, { + kind: 'open', + request, + handle, + }), + ); + }, + onError: (message) => { + setTileTabs((prev) => + tabsModel.setTabSlot(prev, tileId, tabId, { + kind: 'error', + request, + message, + }), + ); + }, + takePreviousHandle: () => null, + isStaleHandle: () => false, + }), + ); + return () => { + for (const cancel of cancels) cancel(); + }; + // Mount-only: `initial` is stable and we capture the mount-time provider. + }, []); + useEffect(() => { if (openRequestId === lastSeenIdRef.current) return; lastSeenIdRef.current = openRequestId; @@ -237,6 +279,8 @@ export function useTilingDocumentSlots({ ); return { + /** Live tab map — exposed read-only for layout serialization. */ + tileTabs, getSlot, getTabs, getActiveTabId, diff --git a/src/workspaces/workspace.tsx b/src/workspaces/workspace.tsx index c08a142..4ba3e02 100644 --- a/src/workspaces/workspace.tsx +++ b/src/workspaces/workspace.tsx @@ -42,6 +42,11 @@ export type WorkspaceProps = { * open intents and do not paint the bottom bar. */ active: boolean; + /** + * Stable per-vault key (the vault's provider id). Used as the persistence + * namespace so each vault restores its own layout. + */ + instanceKey: string; /** * Monotonically bumped each time the shell issues a new open intent. * Workspaces detect "a new open arrived" by comparing against their @@ -163,6 +168,7 @@ export function WorkspaceHost({