From 17d57f8a3ac1ee80601760c181e7a7ac07cf6705 Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Wed, 24 Jun 2026 20:40:02 -0400 Subject: [PATCH] Tabs per tile (phase 1): tab-state model + split collapse Foundation for multiple tabs per tile in the tiling workspace. No UI change yet; behavior is preserved. - model.ts: add removeLeaf (collapse split into sibling; root removal is a no-op) and listTileIds, for last-tab close. - tabsModel.ts: pure tab-state model (TileTabsMap), React-free and side-effect-free. closeTab reports removedTabIds + tileEmptied so the hook owns handle release and the caller owns collapse-vs-reseed. - useTilingDocumentSlots: re-back onto TileTabsMap, key DocumentHandles by tab id, preserve getSlot/ensureTile, add getTabs/getActiveTabId/ setActiveTab/newTab/closeTab for the upcoming tab UI. Co-Authored-By: Claude Opus 4.8 --- src/tiling/model.test.ts | 64 ++++++ src/tiling/model.ts | 38 ++++ src/workspaces/tiling/tabsModel.test.ts | 194 +++++++++++++++++ src/workspaces/tiling/tabsModel.ts | 196 ++++++++++++++++++ .../tiling/useTilingDocumentSlots.ts | 151 ++++++++++---- 5 files changed, 598 insertions(+), 45 deletions(-) create mode 100644 src/workspaces/tiling/tabsModel.test.ts create mode 100644 src/workspaces/tiling/tabsModel.ts diff --git a/src/tiling/model.test.ts b/src/tiling/model.test.ts index 15ed0fa..ee4803d 100644 --- a/src/tiling/model.test.ts +++ b/src/tiling/model.test.ts @@ -4,8 +4,10 @@ import { clampRatioForAxis, createInitialTree, DEFAULT_SPLIT_RATIO, + listTileIds, MIN_TILE_HEIGHT_PX, MIN_TILE_WIDTH_PX, + removeLeaf, splitLeaf, updateSplitRatio, type SplitNode, @@ -127,6 +129,68 @@ describe('updateSplitRatio', () => { }); }); +describe('removeLeaf', () => { + it('collapses a split into its sibling when removing the first child', () => { + const tree = splitRow('root', 0.4, tile('a'), tile('b')); + expect(removeLeaf(tree, 'a')).toEqual(tile('b')); + }); + + it('collapses a split into its sibling when removing the second child', () => { + const tree = splitRow('root', 0.4, tile('a'), tile('b')); + expect(removeLeaf(tree, 'b')).toEqual(tile('a')); + }); + + it('collapses a nested split, preserving the rest of the tree', () => { + const tree: TilingNode = splitRow( + 'root', + 0.5, + tile('keep'), + splitRow('inner', 0.5, tile('gone'), tile('survivor')), + ); + expect(removeLeaf(tree, 'gone')).toEqual( + splitRow('root', 0.5, tile('keep'), tile('survivor')), + ); + }); + + it('is a no-op when removing the only (root) tile', () => { + const tree = createInitialTree('only'); + expect(removeLeaf(tree, 'only')).toBe(tree); + }); + + it('returns the same reference when the tile is not found', () => { + const tree = splitRow('root', 0.5, tile('a'), tile('b')); + expect(removeLeaf(tree, 'missing')).toBe(tree); + }); + + it('does not mutate the original tree', () => { + const tree: TilingNode = splitRow( + 'root', + 0.5, + tile('keep'), + splitRow('inner', 0.5, tile('gone'), tile('survivor')), + ); + const before = structuredClone(tree); + removeLeaf(tree, 'gone'); + expect(tree).toEqual(before); + }); +}); + +describe('listTileIds', () => { + it('returns the single id for a lone tile', () => { + expect(listTileIds(tile('solo'))).toEqual(['solo']); + }); + + it('lists tile ids left-to-right, depth-first', () => { + const tree: TilingNode = splitRow( + 'root', + 0.5, + tile('a'), + splitRow('inner', 0.5, tile('b'), tile('c')), + ); + expect(listTileIds(tree)).toEqual(['a', 'b', 'c']); + }); +}); + describe('clampRatioForAxis', () => { it('returns the input ratio when axis size is non-positive', () => { expect(clampRatioForAxis(0.1, 'row', 0)).toBe(0.1); diff --git a/src/tiling/model.ts b/src/tiling/model.ts index 685619e..4280557 100644 --- a/src/tiling/model.ts +++ b/src/tiling/model.ts @@ -139,3 +139,41 @@ export function updateSplitRatio( second: updateSplitRatio(node.second, splitId, ratio), }; } + +/** + * Remove a tile from the tree, collapsing its parent split into the sibling + * subtree (the sibling expands to fill the freed space — VS Code / Obsidian + * behavior on closing the last tab in a pane). + * + * Removing the root (only) tile is a **no-op**: there is always at least one + * tile, so callers should keep it as a single empty tile rather than emptying + * the workspace. Returns the same reference when `tileId` is not found. + */ +export function removeLeaf(node: TilingNode, tileId: string): TilingNode { + if (node.kind === 'tile') { + // The only tile cannot be removed; the workspace always has >= 1 tile. + return node; + } + if (node.first.kind === 'tile' && node.first.id === tileId) { + return node.second; + } + if (node.second.kind === 'tile' && node.second.id === tileId) { + return node.first; + } + const first = removeLeaf(node.first, tileId); + const second = removeLeaf(node.second, tileId); + if (first === node.first && second === node.second) { + return node; + } + return { ...node, first, second }; +} + +/** + * In-order (left-to-right, depth-first) list of every tile id in the tree. + * Used to detect the single-tile case and to pick a focus target after a + * collapse. + */ +export function listTileIds(node: TilingNode): string[] { + if (node.kind === 'tile') return [node.id]; + return [...listTileIds(node.first), ...listTileIds(node.second)]; +} diff --git a/src/workspaces/tiling/tabsModel.test.ts b/src/workspaces/tiling/tabsModel.test.ts new file mode 100644 index 0000000..24cec22 --- /dev/null +++ b/src/workspaces/tiling/tabsModel.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; + +import type { OpenDocRequest } from '../workspace'; +import { + addTab, + closeTab, + createTile, + ensureTile, + findTabForRequest, + getActiveSlot, + getActiveTabId, + getTabs, + setActiveTab, + setTabSlot, + type TileTabsMap, +} from './tabsModel'; + +function request(entryId: string, providerId = 'fs'): OpenDocRequest { + return { providerId, entryId, title: entryId }; +} + +/** A tile with the given tab ids, each holding a `loading` slot for its entry. */ +function tileWithDocs( + tileId: string, + entries: string[], + activeEntry = entries[0], +): TileTabsMap { + return { + [tileId]: { + tabs: entries.map((entryId) => ({ + tabId: `tab-${entryId}`, + slot: { kind: 'loading', request: request(entryId) }, + })), + activeTabId: `tab-${activeEntry}`, + }, + }; +} + +describe('createTile / ensureTile', () => { + it('creates a tile with one empty active tab', () => { + expect(createTile('tab-1')).toEqual({ + tabs: [{ tabId: 'tab-1', slot: { kind: 'empty' } }], + activeTabId: 'tab-1', + }); + }); + + it('ensureTile adds a missing tile', () => { + const next = ensureTile({}, 'tile-1', 'tab-1'); + expect(getActiveTabId(next, 'tile-1')).toBe('tab-1'); + expect(getActiveSlot(next, 'tile-1')).toEqual({ kind: 'empty' }); + }); + + it('ensureTile is a no-op (same reference) when the tile exists', () => { + const map = ensureTile({}, 'tile-1', 'tab-1'); + expect(ensureTile(map, 'tile-1', 'tab-999')).toBe(map); + }); +}); + +describe('getActiveSlot', () => { + it('returns the active tab slot', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'b'); + expect(getActiveSlot(map, 'tile-1')).toEqual({ + kind: 'loading', + request: request('b'), + }); + }); + + it('returns empty for an unknown tile', () => { + expect(getActiveSlot({}, 'nope')).toEqual({ kind: 'empty' }); + }); +}); + +describe('setActiveTab', () => { + it('activates an existing tab', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); + const next = setActiveTab(map, 'tile-1', 'tab-b'); + expect(getActiveTabId(next, 'tile-1')).toBe('tab-b'); + }); + + it('is a no-op (same reference) for a missing tab or already-active tab', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); + expect(setActiveTab(map, 'tile-1', 'missing')).toBe(map); + expect(setActiveTab(map, 'tile-1', 'tab-a')).toBe(map); + }); +}); + +describe('addTab', () => { + it('appends an empty tab and activates it', () => { + const map = tileWithDocs('tile-1', ['a'], 'a'); + const next = addTab(map, 'tile-1', 'tab-new'); + expect(getTabs(next, 'tile-1').map((t) => t.tabId)).toEqual([ + 'tab-a', + 'tab-new', + ]); + expect(getActiveTabId(next, 'tile-1')).toBe('tab-new'); + expect(getActiveSlot(next, 'tile-1')).toEqual({ kind: 'empty' }); + }); + + it('creates the tile when it is missing', () => { + const next = addTab({}, 'tile-1', 'tab-1'); + expect(getActiveTabId(next, 'tile-1')).toBe('tab-1'); + }); +}); + +describe('setTabSlot', () => { + it('updates only the targeted tab', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); + const next = setTabSlot(map, 'tile-1', 'tab-b', { kind: 'empty' }); + expect(getTabs(next, 'tile-1')[0].slot).toEqual({ + kind: 'loading', + request: request('a'), + }); + expect(getTabs(next, 'tile-1')[1].slot).toEqual({ kind: 'empty' }); + }); + + it('is a no-op (same reference) for a missing tab', () => { + const map = tileWithDocs('tile-1', ['a'], 'a'); + expect(setTabSlot(map, 'tile-1', 'missing', { kind: 'empty' })).toBe(map); + }); +}); + +describe('findTabForRequest', () => { + it('finds a tab already showing the requested document', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); + expect(findTabForRequest(map, 'tile-1', request('b'))).toBe('tab-b'); + }); + + it('returns null when no tab matches', () => { + const map = tileWithDocs('tile-1', ['a'], 'a'); + expect(findTabForRequest(map, 'tile-1', request('z'))).toBeNull(); + }); + + it('distinguishes by provider', () => { + const map = tileWithDocs('tile-1', ['a'], 'a'); + expect(findTabForRequest(map, 'tile-1', request('a', 'other'))).toBeNull(); + }); + + it('ignores empty tabs', () => { + const map = addTab( + tileWithDocs('tile-1', ['a'], 'a'), + 'tile-1', + 'tab-blank', + ); + expect(findTabForRequest(map, 'tile-1', request('a'))).toBe('tab-a'); + }); +}); + +describe('closeTab', () => { + it('removes a non-active tab without changing the active tab', () => { + const map = tileWithDocs('tile-1', ['a', 'b', 'c'], 'a'); + const res = closeTab(map, 'tile-1', 'tab-b'); + expect(res.removedTabIds).toEqual(['tab-b']); + expect(res.tileEmptied).toBe(false); + expect(getTabs(res.map, 'tile-1').map((t) => t.tabId)).toEqual([ + 'tab-a', + 'tab-c', + ]); + expect(getActiveTabId(res.map, 'tile-1')).toBe('tab-a'); + }); + + it('activates the right neighbor when closing the active tab', () => { + const map = tileWithDocs('tile-1', ['a', 'b', 'c'], 'b'); + const res = closeTab(map, 'tile-1', 'tab-b'); + expect(getActiveTabId(res.map, 'tile-1')).toBe('tab-c'); + }); + + it('falls back to the left neighbor when closing the last (active) tab', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'b'); + const res = closeTab(map, 'tile-1', 'tab-b'); + expect(getActiveTabId(res.map, 'tile-1')).toBe('tab-a'); + }); + + it('empties the tile when closing its last tab', () => { + const map = tileWithDocs('tile-1', ['a'], 'a'); + const res = closeTab(map, 'tile-1', 'tab-a'); + expect(res.tileEmptied).toBe(true); + expect(res.removedTabIds).toEqual(['tab-a']); + expect(res.map['tile-1']).toBeUndefined(); + }); + + it('is a no-op for a missing tile or tab', () => { + const map = tileWithDocs('tile-1', ['a'], 'a'); + expect(closeTab(map, 'other', 'tab-a')).toEqual({ + map, + removedTabIds: [], + tileEmptied: false, + }); + expect(closeTab(map, 'tile-1', 'missing')).toEqual({ + map, + removedTabIds: [], + tileEmptied: false, + }); + }); +}); diff --git a/src/workspaces/tiling/tabsModel.ts b/src/workspaces/tiling/tabsModel.ts new file mode 100644 index 0000000..a5d9bb6 --- /dev/null +++ b/src/workspaces/tiling/tabsModel.ts @@ -0,0 +1,196 @@ +/** + * 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 } }; +} + +/** + * 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; + for (const t of tile.tabs) { + const slot = t.slot; + if ( + slot.kind !== 'empty' && + slot.request.providerId === request.providerId && + slot.request.entryId === request.entryId + ) { + return t.tabId; + } + } + return null; +} + +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, + }; +} diff --git a/src/workspaces/tiling/useTilingDocumentSlots.ts b/src/workspaces/tiling/useTilingDocumentSlots.ts index d9b33bf..b3c2e3b 100644 --- a/src/workspaces/tiling/useTilingDocumentSlots.ts +++ b/src/workspaces/tiling/useTilingDocumentSlots.ts @@ -1,14 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - emptyDocumentSlot, - type DocumentSlotState, -} from '../../documents/useDocumentSlot'; +import type { DocumentSlotState } from '../../documents/useDocumentSlot'; import { startDocumentOpen } from '../../documents/documentSlotOpen'; import type { DocumentHandle } from '../../documents/types'; import type { OpenDocRequest, WorkspaceProps } from '../workspace'; +import * as tabsModel from './tabsModel'; +import type { TileTab, TileTabsMap } from './tabsModel'; const INITIAL_TILE_ID = 'tile-1'; +const INITIAL_TAB_ID = 'tab-1'; export type UseTilingDocumentSlotsInput = { openRequestId: number; @@ -18,8 +18,13 @@ export type UseTilingDocumentSlotsInput = { }; /** - * Per-tile document slots owned by the tiling workspace. State lives here so + * 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({ openRequestId, @@ -27,17 +32,26 @@ export function useTilingDocumentSlots({ resolveProvider, activeTileId, }: UseTilingDocumentSlotsInput) { - const [tileSlots, setTileSlots] = useState>( - () => ({ [INITIAL_TILE_ID]: emptyDocumentSlot() }), - ); + const [tileTabs, setTileTabs] = useState(() => ({ + [INITIAL_TILE_ID]: tabsModel.createTile(INITIAL_TAB_ID), + })); + // 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()); const lastSeenIdRef = useRef(openRequestId); - const releaseHandleFor = useCallback((tileId: string) => { - const handle = handleRefs.current.get(tileId); + // Mints tab ids; starts past the seeded INITIAL_TAB_ID. + const nextTabId = useRef(2); + const makeTabId = useCallback(() => `tab-${nextTabId.current++}`, []); + + const releaseTab = useCallback((tabId: string) => { + const handle = handleRefs.current.get(tabId); if (handle) { handle.release(); - handleRefs.current.delete(tileId); + handleRefs.current.delete(tabId); } }, []); @@ -58,56 +72,103 @@ export function useTilingDocumentSlots({ const request = openRequest; const tileId = activeTileId; + // Open into the active tile's active tab. (Focus-or-append routing arrives + // in a later phase, once the tab strip exists.) + let map = tileTabsRef.current; + if (!map[tileId]) { + map = tabsModel.ensureTile(map, tileId, makeTabId()); + setTileTabs(map); + } + const tabId = tabsModel.getActiveTabId(map, tileId); + if (!tabId) return; + + const setSlot = (slot: DocumentSlotState) => + setTileTabs((prev) => tabsModel.setTabSlot(prev, tileId, tabId, slot)); + return startDocumentOpen(request, resolveProvider, { - onLoading: () => { - setTileSlots((prev) => ({ - ...prev, - [tileId]: { kind: 'loading', request }, - })); - }, + onLoading: () => setSlot({ kind: 'loading', request }), onOpen: (handle) => { - handleRefs.current.set(tileId, handle); - setTileSlots((prev) => ({ - ...prev, - [tileId]: { kind: 'open', request, handle }, - })); + handleRefs.current.set(tabId, handle); + setSlot({ kind: 'open', request, handle }); }, onError: (message) => { - handleRefs.current.delete(tileId); - setTileSlots((prev) => ({ - ...prev, - [tileId]: { kind: 'error', request, message }, - })); + handleRefs.current.delete(tabId); + setSlot({ kind: 'error', request, message }); }, takePreviousHandle: () => { - const prev = handleRefs.current.get(tileId) ?? null; - handleRefs.current.delete(tileId); + const prev = handleRefs.current.get(tabId) ?? null; + handleRefs.current.delete(tabId); return prev; }, - isStaleHandle: (handle) => handleRefs.current.get(tileId) !== handle, + isStaleHandle: (handle) => handleRefs.current.get(tabId) !== handle, }); - }, [openRequestId, openRequest, activeTileId, resolveProvider]); + }, [openRequestId, openRequest, activeTileId, resolveProvider, makeTabId]); - const ensureTile = useCallback((tileId: string) => { - setTileSlots((prev) => { - if (prev[tileId]) return prev; - return { ...prev, [tileId]: emptyDocumentSlot() }; - }); - }, []); + 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 => - tileSlots[tileId] ?? emptyDocumentSlot(), - [tileSlots], + tabsModel.getActiveSlot(tileTabs, tileId), + [tileTabs], ); - const closeSlot = useCallback( - (tileId: string) => { - releaseHandleFor(tileId); - setTileSlots((prev) => ({ ...prev, [tileId]: emptyDocumentSlot() })); + const getTabs = useCallback( + (tileId: string): readonly TileTab[] => tabsModel.getTabs(tileTabs, tileId), + [tileTabs], + ); + + const getActiveTabId = useCallback( + (tileId: string): string | null => + tabsModel.getActiveTabId(tileTabs, tileId), + [tileTabs], + ); + + const setActiveTab = useCallback((tileId: string, tabId: string) => { + setTileTabs((prev) => tabsModel.setActiveTab(prev, tileId, tabId)); + }, []); + + /** Open a new empty tab in a tile and activate it. Returns the new tab id. */ + const newTab = useCallback( + (tileId: string): string => { + 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 }; }, - [releaseHandleFor], + [releaseTab], ); - return { getSlot, ensureTile, closeSlot }; + return { + getSlot, + getTabs, + getActiveTabId, + ensureTile, + setActiveTab, + newTab, + closeTab, + }; } -- 2.51.2