Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
13 kB · 431 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432/** * Pure tab-state model for the tiling workspace. * * Each tile owns an ordered list of tabs and one active tab. State is keyed by * tile id (`TileTabsMap`) so it survives tile component remounts (e.g. after a * split), mirroring how `src/tiling/model.ts` owns the split tree. * * This module is intentionally free of React and of document-handle side * effects: it describes *what* the tab structure becomes. The owning hook * (`useTilingDocumentSlots`) performs the impure work — running opens and * releasing `DocumentHandle`s — driven by the results returned here. `closeTab` * reports which tab ids it removed so the hook knows exactly which handles to * release. */import type { DocumentSlotState } from '../../documents/useDocumentSlot';import type { OpenDocRequest } from '../workspace';
export type TileTab = { tabId: string; slot: DocumentSlotState;};
export type TileTabs = { tabs: TileTab[]; activeTabId: string;};
/** Tab state for the whole workspace, keyed by tile id. */export type TileTabsMap = Record<string, TileTabs>;
const emptySlot = (): DocumentSlotState => ({ kind: 'empty' });
/** A fresh tile: one empty tab, active. */export function createTile(tabId: string): TileTabs { return { tabs: [{ tabId, slot: emptySlot() }], activeTabId: tabId };}
/** Add a tile (with one empty tab) if it does not already exist. */export function ensureTile( map: TileTabsMap, tileId: string, tabId: string,): TileTabsMap { if (map[tileId]) return map; return { ...map, [tileId]: createTile(tabId) };}
export function getTabs(map: TileTabsMap, tileId: string): readonly TileTab[] { return map[tileId]?.tabs ?? [];}
export function getActiveTabId( map: TileTabsMap, tileId: string,): string | null { return map[tileId]?.activeTabId ?? null;}
/** The slot shown by a tile = its active tab's slot. */export function getActiveSlot( map: TileTabsMap, tileId: string,): DocumentSlotState { const tile = map[tileId]; if (!tile) return emptySlot(); return ( tile.tabs.find((t) => t.tabId === tile.activeTabId)?.slot ?? emptySlot() );}
/** Activate an existing tab. No-op if the tile/tab is missing or already active. */export function setActiveTab( map: TileTabsMap, tileId: string, tabId: string,): TileTabsMap { const tile = map[tileId]; if (!tile) return map; if (tile.activeTabId === tabId) return map; if (!tile.tabs.some((t) => t.tabId === tabId)) return map; return { ...map, [tileId]: { ...tile, activeTabId: tabId } };}
/** * Append a new empty tab and activate it. If the tab id already exists, just * activate it (defensive against duplicate ids). */export function addTab( map: TileTabsMap, tileId: string, tabId: string,): TileTabsMap { const tile = map[tileId]; if (!tile) return { ...map, [tileId]: createTile(tabId) }; if (tile.tabs.some((t) => t.tabId === tabId)) { return setActiveTab(map, tileId, tabId); } return { ...map, [tileId]: { tabs: [...tile.tabs, { tabId, slot: emptySlot() }], activeTabId: tabId, }, };}
/** Replace one tab's slot state (loading/open/error/empty). */export function setTabSlot( map: TileTabsMap, tileId: string, tabId: string, slot: DocumentSlotState,): TileTabsMap { const tile = map[tileId]; if (!tile) return map; let changed = false; const tabs = tile.tabs.map((t) => { if (t.tabId !== tabId) return t; changed = true; return { ...t, slot }; }); if (!changed) return map; return { ...map, [tileId]: { ...tile, tabs } };}
/** Whether a slot is showing (loading/open/error) the requested document. */function slotShowsRequest( slot: DocumentSlotState, request: OpenDocRequest,): boolean { return ( slot.kind !== 'empty' && slot.request.providerId === request.providerId && slot.request.entryId === request.entryId );}
/** * Find a tab in a tile already showing the requested document (matched by * provider + entry), or null. Used to focus an existing tab instead of opening * a duplicate. */export function findTabForRequest( map: TileTabsMap, tileId: string, request: OpenDocRequest,): string | null { const tile = map[tileId]; if (!tile) return null; return ( tile.tabs.find((t) => slotShowsRequest(t.slot, request))?.tabId ?? null );}
/** The id of an empty (document-less) tab in the tile, or null. */export function findEmptyTab(map: TileTabsMap, tileId: string): string | null { const tile = map[tileId]; if (!tile) return null; return tile.tabs.find((t) => t.slot.kind === 'empty')?.tabId ?? null;}
export type OpenPlan = { map: TileTabsMap; /** The tab the document should open into (or that was focused). */ tabId: string; /** True when the document was already open and we only changed focus. */ alreadyOpen: boolean;};
/** * Decide where an open lands in a tile: * 1. Focus an existing tab already showing the same document (no reopen). * 2. Otherwise reuse the active empty "start" tab if there is one. * 3. Otherwise append a new tab (creating the tile if absent). * * `makeTabId` is only invoked when a new tab is actually created, so callers' * id counters do not advance on focus/reuse. */export function planOpen( map: TileTabsMap, tileId: string, request: OpenDocRequest, makeTabId: () => string,): OpenPlan { const existing = findTabForRequest(map, tileId, request); if (existing) { return { map: setActiveTab(map, tileId, existing), tabId: existing, alreadyOpen: true, }; }
const activeTabId = getActiveTabId(map, tileId); if (activeTabId !== null && getActiveSlot(map, tileId).kind === 'empty') { return { map, tabId: activeTabId, alreadyOpen: false }; }
const tabId = makeTabId(); return { map: addTab(map, tileId, tabId), tabId, alreadyOpen: false };}
/** * Move the active tab within a tile by `delta` (+1 next, -1 previous), * wrapping around. No-op for a missing tile or one with fewer than two tabs. */export function cycleActiveTab( map: TileTabsMap, tileId: string, delta: number,): TileTabsMap { const tile = map[tileId]; if (!tile || tile.tabs.length < 2) return map; const index = tile.tabs.findIndex((t) => t.tabId === tile.activeTabId); if (index === -1) return map; const count = tile.tabs.length; const nextIndex = (((index + delta) % count) + count) % count; return setActiveTab(map, tileId, tile.tabs[nextIndex].tabId);}
export type CloseTabResult = { map: TileTabsMap; /** Tab ids whose document handles the caller must release. */ removedTabIds: string[]; /** * True when the closed tab was the tile's last: the tile entry is removed * from the map. The caller decides whether to collapse the split * (`removeLeaf`) or, for the sole remaining tile, reseed an empty tile. */ tileEmptied: boolean;};
/** * Close a tab. If it was the active tab, the right neighbor (else the left) * becomes active. If it was the tile's last tab, the tile entry is dropped and * `tileEmptied` is set. */export function closeTab( map: TileTabsMap, tileId: string, tabId: string,): CloseTabResult { const tile = map[tileId]; if (!tile) return { map, removedTabIds: [], tileEmptied: false };
const index = tile.tabs.findIndex((t) => t.tabId === tabId); if (index === -1) return { map, removedTabIds: [], tileEmptied: false };
if (tile.tabs.length === 1) { const next = { ...map }; delete next[tileId]; return { map: next, removedTabIds: [tabId], tileEmptied: true }; }
const tabs = tile.tabs.filter((t) => t.tabId !== tabId); let activeTabId = tile.activeTabId; if (activeTabId === tabId) { const neighbor = tile.tabs[index + 1] ?? tile.tabs[index - 1]; activeTabId = neighbor.tabId; } return { map: { ...map, [tileId]: { tabs, activeTabId } }, removedTabIds: [tabId], tileEmptied: false, };}
export type CloseManyResult = { map: TileTabsMap; /** Tab ids whose document handles the caller must release. */ removedTabIds: string[];};
/** Close every tab in the tile except `keepTabId` (which stays active). */export function closeOthers( map: TileTabsMap, tileId: string, keepTabId: string,): CloseManyResult { const tile = map[tileId]; const kept = tile?.tabs.find((t) => t.tabId === keepTabId); if (!tile || !kept) return { map, removedTabIds: [] }; const removedTabIds = tile.tabs .filter((t) => t.tabId !== keepTabId) .map((t) => t.tabId); if (removedTabIds.length === 0) return { map, removedTabIds: [] }; return { map: { ...map, [tileId]: { tabs: [kept], activeTabId: keepTabId } }, removedTabIds, };}
function closeSide( map: TileTabsMap, tileId: string, tabId: string, side: 'left' | 'right',): CloseManyResult { const tile = map[tileId]; if (!tile) return { map, removedTabIds: [] }; const index = tile.tabs.findIndex((t) => t.tabId === tabId); if (index === -1) return { map, removedTabIds: [] }; const kept = side === 'right' ? tile.tabs.slice(0, index + 1) : tile.tabs.slice(index); const removed = side === 'right' ? tile.tabs.slice(index + 1) : tile.tabs.slice(0, index); if (removed.length === 0) return { map, removedTabIds: [] }; const activeTabId = kept.some((t) => t.tabId === tile.activeTabId) ? tile.activeTabId : tabId; return { map: { ...map, [tileId]: { tabs: kept, activeTabId } }, removedTabIds: removed.map((t) => t.tabId), };}
/** Close every tab to the right of `tabId` in the tile. */export function closeToRight( map: TileTabsMap, tileId: string, tabId: string,): CloseManyResult { return closeSide(map, tileId, tabId, 'right');}
/** Close every tab to the left of `tabId` in the tile. */export function closeToLeft( map: TileTabsMap, tileId: string, tabId: string,): CloseManyResult { return closeSide(map, tileId, tabId, 'left');}
export type CloseAllOfFileResult = CloseManyResult & { /** Tiles that lost all their tabs (entry removed) — caller collapses these. */ emptiedTileIds: string[];};
/** Close every tab showing the requested document across all tiles. */export function closeAllOfFile( map: TileTabsMap, request: OpenDocRequest,): CloseAllOfFileResult { const next: TileTabsMap = { ...map }; const removedTabIds: string[] = []; const emptiedTileIds: string[] = [];
for (const tileId of Object.keys(map)) { const tile = map[tileId]; const removed = tile.tabs.filter((t) => slotShowsRequest(t.slot, request)); if (removed.length === 0) continue; removedTabIds.push(...removed.map((t) => t.tabId));
const kept = tile.tabs.filter((t) => !slotShowsRequest(t.slot, request)); if (kept.length === 0) { delete next[tileId]; emptiedTileIds.push(tileId); } else { const activeTabId = kept.some((t) => t.tabId === tile.activeTabId) ? tile.activeTabId : kept[kept.length - 1].tabId; next[tileId] = { tabs: kept, activeTabId }; } }
return { map: next, removedTabIds, emptiedTileIds };}
export type MoveTabResult = { map: TileTabsMap; /** True when the source tile lost its last tab (cross-tile move). */ sourceEmptied: boolean;};
/** Index to insert before `beforeTabId` in `tabs`, or the end when absent. */function insertIndex(tabs: TileTab[], beforeTabId: string | null): number { if (!beforeTabId) return tabs.length; const i = tabs.findIndex((t) => t.tabId === beforeTabId); return i === -1 ? tabs.length : i;}
/** * Move a tab to `toTileId`, inserting it before `beforeTabId` (or appending * when null), and make it active there. Works within a tile (reorder) or * across tiles; the document handle travels with the tab id, so no reopen. * When a cross-tile move empties the source tile, its entry is dropped and * `sourceEmptied` is set so the caller can collapse the split. */export function moveTab( map: TileTabsMap, fromTileId: string, tabId: string, toTileId: string, beforeTabId: string | null,): MoveTabResult { const source = map[fromTileId]; const moving = source?.tabs.find((t) => t.tabId === tabId); if (!source || !moving) return { map, sourceEmptied: false };
if (fromTileId === toTileId) { if (beforeTabId === tabId) return { map, sourceEmptied: false }; const without = source.tabs.filter((t) => t.tabId !== tabId); const at = insertIndex(without, beforeTabId); const tabs = [...without.slice(0, at), moving, ...without.slice(at)]; return { map: { ...map, [fromTileId]: { tabs, activeTabId: tabId } }, sourceEmptied: false, }; }
const next: TileTabsMap = { ...map }; const sourceTabs = source.tabs.filter((t) => t.tabId !== tabId); let sourceEmptied = false; if (sourceTabs.length === 0) { delete next[fromTileId]; sourceEmptied = true; } else { const activeTabId = sourceTabs.some((t) => t.tabId === source.activeTabId) ? source.activeTabId : sourceTabs[sourceTabs.length - 1].tabId; next[fromTileId] = { tabs: sourceTabs, activeTabId }; }
// The destination may not exist yet (e.g. a tile just created by a split). const destTabs = map[toTileId]?.tabs ?? []; const at = insertIndex(destTabs, beforeTabId); const tabs = [...destTabs.slice(0, at), moving, ...destTabs.slice(at)]; next[toTileId] = { tabs, activeTabId: tabId }; return { map: next, sourceEmptied };}