Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
11 kB · 339 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340import { useCallback, useEffect, useRef, useState } from 'react';
import type { DocumentSlotState } from '../../documents/useDocumentSlot';import { startDocumentOpen } from '../../documents/documentSlotOpen';import type { DocumentHandle } from '../../documents/types';import type { EditorMode } from '../../editors/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';
export type UseTilingDocumentSlotsInput = { /** When false (workspace not shown), open intents are ignored. */ active: boolean; openRequestId: number; openRequest: OpenDocRequest | null; resolveProvider: WorkspaceProps['resolveProvider']; activeTileId: string; /** Persisted tiles to restore on mount (their documents are re-opened). */ initialTiles?: PersistedTile[] | null;};
/** * Per-tile tab state owned by the tiling workspace. Each tile holds an ordered * list of tabs (one document slot each) and an active tab; state lives here so * tile components can remount (e.g. after a split) without losing documents. * * Pure tab transitions live in `./tabsModel`; this hook layers on the impure * concerns: running opens via `startDocumentOpen` and releasing * `DocumentHandle`s (tracked per tab id) on close and unmount. */export function useTilingDocumentSlots({ active, openRequestId, openRequest, resolveProvider, activeTileId, initialTiles,}: UseTilingDocumentSlotsInput) { // 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<TileTabsMap>(initial.map); // Mirror for synchronous reads inside callbacks/effects without adding // `tileTabs` to dependency arrays. const tileTabsRef = useRef(tileTabs); tileTabsRef.current = tileTabs;
const handleRefs = useRef(new Map<string, DocumentHandle>()); const lastSeenIdRef = useRef(openRequestId);
// 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) => { const handle = handleRefs.current.get(tabId); if (handle) { handle.release(); handleRefs.current.delete(tabId); } }, []);
useEffect(() => { return () => { for (const handle of handleRefs.current.values()) { handle.release(); } handleRefs.current.clear(); }; }, []);
// 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; // Advance last-seen even when inactive so a switch-back never replays an // open that landed in another workspace. if (!active || !openRequest) return;
const request = openRequest; const tileId = activeTileId;
// Focus an existing tab for this document, reuse the empty start tab, or // append a new tab — all decided by tabsModel.planOpen. const plan = tabsModel.planOpen( tileTabsRef.current, tileId, request, makeTabId, ); setTileTabs(plan.map); if (plan.alreadyOpen) return; const tabId = plan.tabId;
const setSlot = (slot: DocumentSlotState) => setTileTabs((prev) => tabsModel.setTabSlot(prev, tileId, tabId, slot));
return startDocumentOpen(request, resolveProvider, { onLoading: () => setSlot({ kind: 'loading', request }), onOpen: (handle) => { handleRefs.current.set(tabId, handle); setSlot({ kind: 'open', request, handle }); }, onError: (message) => { handleRefs.current.delete(tabId); setSlot({ kind: 'error', request, message }); }, takePreviousHandle: () => { const prev = handleRefs.current.get(tabId) ?? null; handleRefs.current.delete(tabId); return prev; }, isStaleHandle: (handle) => handleRefs.current.get(tabId) !== handle, }); }, [ active, openRequestId, openRequest, activeTileId, resolveProvider, makeTabId, ]);
const ensureTile = useCallback( (tileId: string) => { setTileTabs((prev) => prev[tileId] ? prev : tabsModel.ensureTile(prev, tileId, makeTabId()), ); }, [makeTabId], );
/** Active tab's slot for a tile (the content the tile renders). */ const getSlot = useCallback( (tileId: string): DocumentSlotState => tabsModel.getActiveSlot(tileTabs, tileId), [tileTabs], );
const getTabs = useCallback( (tileId: string): readonly TileTab[] => tabsModel.getTabs(tileTabs, tileId), [tileTabs], );
const getActiveTabId = useCallback( (tileId: string): string | null => tabsModel.getActiveTabId(tileTabs, tileId), [tileTabs], );
/** The active tab's rendering mode for a tile (defaults to `edit`). */ const getActiveMode = useCallback( (tileId: string): EditorMode => tabsModel.getActiveMode(tileTabs, tileId), [tileTabs], );
const setTabMode = useCallback( (tileId: string, tabId: string, mode: EditorMode) => { setTileTabs((prev) => tabsModel.setTabMode(prev, tileId, tabId, mode)); }, [], );
const setActiveTab = useCallback((tileId: string, tabId: string) => { setTileTabs((prev) => tabsModel.setActiveTab(prev, tileId, tabId)); }, []);
/** Move the active tab within a tile (+1 next, -1 previous), wrapping. */ const cycleTab = useCallback((tileId: string, delta: number) => { setTileTabs((prev) => tabsModel.cycleActiveTab(prev, tileId, delta)); }, []);
/** * Focus the tile's empty "start" tab, creating one only if none exists. * Empty tabs are the start page (not chips), so we never pile them up. * Returns the focused tab id. */ const newTab = useCallback( (tileId: string): string => { const existingEmpty = tabsModel.findEmptyTab(tileTabsRef.current, tileId); if (existingEmpty) { setTileTabs((prev) => tabsModel.setActiveTab(prev, tileId, existingEmpty), ); return existingEmpty; } const tabId = makeTabId(); setTileTabs((prev) => tabsModel.addTab(prev, tileId, tabId)); return tabId; }, [makeTabId], );
/** * Close a tab, releasing its document handle. Returns `tileEmptied: true` * when the tile lost its last tab, so the caller can collapse the split (or * reseed the sole remaining tile). */ const closeTab = useCallback( (tileId: string, tabId: string): { tileEmptied: boolean } => { const result = tabsModel.closeTab(tileTabsRef.current, tileId, tabId); for (const removed of result.removedTabIds) { releaseTab(removed); } setTileTabs(result.map); return { tileEmptied: result.tileEmptied }; }, [releaseTab], );
const applyClose = useCallback( (result: tabsModel.CloseManyResult) => { for (const removed of result.removedTabIds) { releaseTab(removed); } setTileTabs(result.map); }, [releaseTab], );
const closeOtherTabs = useCallback( (tileId: string, tabId: string) => { applyClose(tabsModel.closeOthers(tileTabsRef.current, tileId, tabId)); }, [applyClose], );
const closeTabsToRight = useCallback( (tileId: string, tabId: string) => { applyClose(tabsModel.closeToRight(tileTabsRef.current, tileId, tabId)); }, [applyClose], );
const closeTabsToLeft = useCallback( (tileId: string, tabId: string) => { applyClose(tabsModel.closeToLeft(tileTabsRef.current, tileId, tabId)); }, [applyClose], );
/** * Close every tab showing `request` across all tiles. Returns the tiles that * lost their last tab so the caller can collapse them in the split tree. */ const closeAllOfFile = useCallback( (request: OpenDocRequest): { emptiedTileIds: string[] } => { const result = tabsModel.closeAllOfFile(tileTabsRef.current, request); applyClose(result); return { emptiedTileIds: result.emptiedTileIds }; }, [applyClose], );
/** * Move a tab within or between tiles (drag-and-drop). The handle travels with * the tab id, so no reopen. Returns `sourceEmptied` so the caller can collapse * a tile whose last tab moved away. */ const moveTab = useCallback( ( fromTileId: string, tabId: string, toTileId: string, beforeTabId: string | null, ): { sourceEmptied: boolean } => { const result = tabsModel.moveTab( tileTabsRef.current, fromTileId, tabId, toTileId, beforeTabId, ); setTileTabs(result.map); return { sourceEmptied: result.sourceEmptied }; }, [], );
return { /** Live tab map — exposed read-only for layout serialization. */ tileTabs, getSlot, getTabs, getActiveTabId, getActiveMode, setTabMode, ensureTile, setActiveTab, cycleTab, newTab, closeTab, closeOtherTabs, closeTabsToRight, closeTabsToLeft, closeAllOfFile, moveTab, };}