/** * 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; 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 }; }