Files
Codeman/src/tab-layout.ts
T

548 lines
21 KiB
TypeScript

/**
* @fileoverview Framework-independent tab layout model.
*
* Callers provide owner-scoped session/webview metadata. This module deliberately
* has no dependency on session runtime, persistence, routes, or browser state.
*/
export const MAX_TAB_GROUPS = 32;
export const MAX_TAB_GROUP_NAME_LENGTH = 60;
export const MAX_TAB_REFS = 512;
export type TabRefKind = 'session' | 'webview';
export interface TabRef {
kind: TabRefKind;
id: string;
placement?: 'manual';
}
export interface TabGroup {
id: string;
name: string;
refs: TabRef[];
}
export interface TabLayout {
version: number;
groups: TabGroup[];
ungrouped: TabRef[];
updatedAt: string;
}
/** Owner and lineage facts supplied by the server or browser integration. */
export interface TabRefMetadata {
kind: TabRefKind;
id: string;
/** False for missing, foreign-owned, or otherwise invalid refs. */
ownerValid: boolean;
/** False when the owner is not permitted to see/store this ref. */
visible: boolean;
/** Stable creation/sibling order. Ties fall back to kind and id. */
order: number;
/** Session-only lineage hint. Ignored for webviews. */
parentSessionId?: string;
}
export interface TabMoveTarget {
/** Null denotes the real ungrouped container. */
groupId: string | null;
/** Zero-based insertion index after removing the moved block. */
index: number;
}
export interface CreateTabGroupInput {
id: string;
name: string;
index?: number;
}
export interface VisibleTabProjectionOptions {
liveSessionIds: ReadonlySet<string>;
openWebviewIds: ReadonlySet<string>;
collapsedGroupIds?: ReadonlySet<string>;
highlighted?: TabRef;
}
export class TabLayoutValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'TabLayoutValidationError';
}
}
const keyOf = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new TabLayoutValidationError(`${label} must be an object`);
}
}
function parseNonEmptyString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new TabLayoutValidationError(`${label} must be a non-empty string`);
}
return value;
}
function parseName(value: unknown, label: string): string {
if (typeof value !== 'string') throw new TabLayoutValidationError(`${label} must be a string`);
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > MAX_TAB_GROUP_NAME_LENGTH) {
throw new TabLayoutValidationError(`${label} must be 1-${MAX_TAB_GROUP_NAME_LENGTH} trimmed characters`);
}
return trimmed;
}
function parseRef(value: unknown, label: string): TabRef {
assertRecord(value, label);
if (value.kind !== 'session' && value.kind !== 'webview') {
throw new TabLayoutValidationError(`${label}.kind must be session or webview`);
}
const id = parseNonEmptyString(value.id, `${label}.id`);
if (value.placement !== undefined && value.placement !== 'manual') {
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
}
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
}
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
assertRecord(input, 'layout');
if (!Number.isSafeInteger(input.version) || (input.version as number) < 0) {
throw new TabLayoutValidationError('layout.version must be a non-negative safe integer');
}
if (!Array.isArray(input.groups)) throw new TabLayoutValidationError('layout.groups must be an array');
if (input.groups.length > MAX_TAB_GROUPS) {
throw new TabLayoutValidationError(`layout.groups cannot exceed ${MAX_TAB_GROUPS}`);
}
if (!Array.isArray(input.ungrouped)) throw new TabLayoutValidationError('layout.ungrouped must be an array');
const updatedAt = parseNonEmptyString(input.updatedAt, 'layout.updatedAt');
const groupIds = new Set<string>();
const refKeys = new Set<string>();
let refCount = input.ungrouped.length;
const parseStoredRef = (entry: unknown, label: string): TabRef => {
const ref = parseRef(entry, label);
const key = keyOf(ref);
if (!repairDuplicates && refKeys.has(key)) {
throw new TabLayoutValidationError(`duplicate ref: ${ref.kind}:${ref.id}`);
}
refKeys.add(key);
return ref;
};
const groups = input.groups.map((rawGroup, groupIndex): TabGroup => {
const label = `layout.groups[${groupIndex}]`;
assertRecord(rawGroup, label);
const id = parseNonEmptyString(rawGroup.id, `${label}.id`);
if (groupIds.has(id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`);
groupIds.add(id);
if (!Array.isArray(rawGroup.refs)) throw new TabLayoutValidationError(`${label}.refs must be an array`);
refCount += rawGroup.refs.length;
return {
id,
name: parseName(rawGroup.name, `${label}.name`),
refs: rawGroup.refs.map((entry, refIndex) => parseStoredRef(entry, `${label}.refs[${refIndex}]`)),
};
});
if (refCount > MAX_TAB_REFS) {
throw new TabLayoutValidationError(`layout cannot contain more than ${MAX_TAB_REFS} refs`);
}
return {
version: input.version as number,
groups,
ungrouped: input.ungrouped.map((entry, index) => parseStoredRef(entry, `layout.ungrouped[${index}]`)),
updatedAt,
};
}
/** Validate and defensively clone a layout. Group names are normalized by trimming. */
export function validateTabLayout(input: unknown): TabLayout {
return parseTabLayout(input, false);
}
function validMetadata(metadata: readonly TabRefMetadata[]): TabRefMetadata[] {
const byKey = new Map<string, TabRefMetadata>();
for (const item of metadata) {
if ((item.kind !== 'session' && item.kind !== 'webview') || typeof item.id !== 'string' || item.id.length === 0) {
throw new TabLayoutValidationError('metadata contains an invalid ref identity');
}
if (!Number.isFinite(item.order)) throw new TabLayoutValidationError(`metadata order is invalid for ${item.id}`);
if (!item.ownerValid || !item.visible) continue;
const key = keyOf(item);
if (!byKey.has(key)) byKey.set(key, { ...item });
}
const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
const result = [...byKey.values()].sort(
(a, b) => a.order - b.order || compareText(a.kind, b.kind) || compareText(a.id, b.id)
);
if (result.length > MAX_TAB_REFS) {
throw new TabLayoutValidationError(`owner layout cannot exceed ${MAX_TAB_REFS} refs`);
}
return result;
}
interface LocatedRef {
ref: TabRef;
container: string | null;
position: number;
}
function locations(layout: TabLayout): LocatedRef[] {
const result: LocatedRef[] = [];
let position = 0;
for (const group of layout.groups) {
for (const ref of group.refs) result.push({ ref, container: group.id, position: position++ });
}
for (const ref of layout.ungrouped) result.push({ ref, container: null, position: position++ });
return result;
}
function withContainers(layout: TabLayout, refsByContainer: ReadonlyMap<string | null, TabRef[]>): TabLayout {
return {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: [...(refsByContainer.get(group.id) ?? [])] })),
ungrouped: [...(refsByContainer.get(null) ?? [])],
};
}
/**
* Reconcile a layout against owner-valid metadata and session lineage.
* First stored occurrence wins; missing valid refs append to ungrouped.
*/
export function normalizeTabLayout(input: TabLayout, metadata: readonly TabRefMetadata[]): TabLayout {
const layout = parseTabLayout(input, true);
const valid = validMetadata(metadata);
const metadataByKey = new Map(valid.map((item) => [keyOf(item), item]));
const knownMetadataKeys = new Set(metadata.map((item) => keyOf(item)));
const seen = new Set<string>();
const dedupedByContainer = new Map<string | null, TabRef[]>();
for (const group of layout.groups) dedupedByContainer.set(group.id, []);
dedupedByContainer.set(null, []);
for (const located of locations(layout)) {
const key = keyOf(located.ref);
// Missing metadata is unknown rather than invalid (for example, during
// restoration). Preserve it until an explicit invalid/deletion fact arrives.
if ((knownMetadataKeys.has(key) && !metadataByKey.has(key)) || seen.has(key)) continue;
seen.add(key);
dedupedByContainer.get(located.container)!.push({ ...located.ref });
}
for (const item of valid) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
dedupedByContainer.get(null)!.push({ kind: item.kind, id: item.id });
}
if (seen.size > MAX_TAB_REFS) {
throw new TabLayoutValidationError(`normalized layout cannot exceed ${MAX_TAB_REFS} refs`);
}
let working = withContainers(layout, dedupedByContainer);
const located = locations(working);
const refByKey = new Map(located.map((item) => [keyOf(item.ref), item.ref]));
const sessionById = new Map(valid.filter((item) => item.kind === 'session').map((item) => [item.id, item]));
const manualCycleEdges = new Set<string>();
const state = new Map<string, 'visiting' | 'done'>();
const visit = (id: string): void => {
if (state.get(id) === 'done') return;
state.set(id, 'visiting');
const item = sessionById.get(id);
const stored = refByKey.get(keyOf({ kind: 'session', id }));
if (item?.parentSessionId && stored?.placement !== 'manual') {
const parent = sessionById.get(item.parentSessionId);
const parentStored = refByKey.get(keyOf({ kind: 'session', id: item.parentSessionId }));
if (parent && parentStored) {
if (state.get(parent.id) === 'visiting') manualCycleEdges.add(id);
else visit(parent.id);
}
}
state.set(id, 'done');
};
for (const item of located)
if (item.ref.kind === 'session' && state.get(item.ref.id) === undefined) visit(item.ref.id);
if (manualCycleEdges.size > 0) {
working = {
...working,
groups: working.groups.map((group) => ({
...group,
refs: group.refs.map((ref) =>
ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref
),
})),
ungrouped: working.ungrouped.map((ref) =>
ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref
),
};
}
const ordered = locations(working);
const updatedRefByKey = new Map(ordered.map((item) => [keyOf(item.ref), item.ref]));
const parentOf = new Map<string, string>();
const children = new Map<string, string[]>();
for (const item of ordered) {
if (item.ref.kind !== 'session' || item.ref.placement === 'manual') continue;
const info = sessionById.get(item.ref.id);
const parentId = info?.parentSessionId;
if (!parentId || !sessionById.has(parentId) || !updatedRefByKey.has(keyOf({ kind: 'session', id: parentId })))
continue;
parentOf.set(item.ref.id, parentId);
const siblings = children.get(parentId) ?? [];
siblings.push(item.ref.id);
children.set(parentId, siblings);
}
const emitted = new Set<string>();
const output = new Map<string | null, TabRef[]>();
for (const group of working.groups) output.set(group.id, []);
output.set(null, []);
const emitSubtree = (root: TabRef, container: string | null): void => {
const rootKey = keyOf(root);
if (emitted.has(rootKey)) return;
emitted.add(rootKey);
output.get(container)!.push({ ...root });
if (root.kind !== 'session') return;
for (const childId of children.get(root.id) ?? []) {
const child = updatedRefByKey.get(keyOf({ kind: 'session', id: childId }));
if (child) emitSubtree(child, container);
}
};
for (const item of ordered) {
if (item.ref.kind === 'session' && parentOf.has(item.ref.id)) continue;
emitSubtree(item.ref, item.container);
}
return withContainers(working, output);
}
function cloneForEdit(input: TabLayout): TabLayout {
return validateTabLayout(input);
}
function boundedIndex(index: number, length: number, label: string): number {
if (!Number.isSafeInteger(index) || index < 0 || index > length) {
throw new TabLayoutValidationError(`${label} index must be between 0 and ${length}`);
}
return index;
}
export function createGroup(input: TabLayout, group: CreateTabGroupInput): TabLayout {
const layout = cloneForEdit(input);
if (layout.groups.length >= MAX_TAB_GROUPS)
throw new TabLayoutValidationError(`cannot exceed ${MAX_TAB_GROUPS} groups`);
const id = parseNonEmptyString(group.id, 'group.id');
if (layout.groups.some((entry) => entry.id === id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`);
const index = boundedIndex(group.index ?? layout.groups.length, layout.groups.length, 'group');
const groups = [...layout.groups];
groups.splice(index, 0, { id, name: parseName(group.name, 'group.name'), refs: [] });
return { ...layout, groups };
}
export function renameGroup(input: TabLayout, groupId: string, name: string): TabLayout {
const layout = cloneForEdit(input);
if (!layout.groups.some((group) => group.id === groupId))
throw new TabLayoutValidationError(`unknown group: ${groupId}`);
return {
...layout,
groups: layout.groups.map((group) =>
group.id === groupId ? { ...group, name: parseName(name, 'group.name') } : group
),
};
}
export function deleteGroup(input: TabLayout, groupId: string): TabLayout {
const layout = cloneForEdit(input);
const group = layout.groups.find((entry) => entry.id === groupId);
if (!group) throw new TabLayoutValidationError(`unknown group: ${groupId}`);
return {
...layout,
groups: layout.groups.filter((entry) => entry.id !== groupId),
ungrouped: [...layout.ungrouped, ...group.refs.map((ref) => ({ ...ref }))],
};
}
export function reorderGroup(input: TabLayout, groupId: string, index: number): TabLayout {
const layout = cloneForEdit(input);
const from = layout.groups.findIndex((group) => group.id === groupId);
if (from < 0) throw new TabLayoutValidationError(`unknown group: ${groupId}`);
const groups = [...layout.groups];
const [group] = groups.splice(from, 1);
groups.splice(boundedIndex(index, groups.length, 'group'), 0, group);
return { ...layout, groups };
}
function mapRef(input: TabLayout, target: TabRef, transform: (ref: TabRef) => TabRef): TabLayout {
const layout = cloneForEdit(input);
let found = false;
const apply = (ref: TabRef): TabRef => {
if (keyOf(ref) !== keyOf(target)) return ref;
found = true;
return transform(ref);
};
const result = {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: group.refs.map(apply) })),
ungrouped: layout.ungrouped.map(apply),
};
if (!found) throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
return result;
}
export function setManualPlacement(input: TabLayout, target: TabRef, manual: boolean): TabLayout {
if (!manual) {
throw new TabLayoutValidationError('manual placement can only be cleared through followParent');
}
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
}
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
const normalized = normalizeTabLayout(input, metadata);
if (target.kind !== 'session') {
throw new TabLayoutValidationError('only a session ref can follow a parent');
}
const valid = validMetadata(metadata);
const targetMetadata = valid.find((item) => item.kind === 'session' && item.id === target.id);
if (!targetMetadata?.parentSessionId) {
throw new TabLayoutValidationError(`session has no owner-valid parent: ${target.id}`);
}
const parentMetadata = valid.find((item) => item.kind === 'session' && item.id === targetMetadata.parentSessionId);
if (!parentMetadata) {
throw new TabLayoutValidationError(`session parent is not owner-valid: ${targetMetadata.parentSessionId}`);
}
const storedKeys = new Set(locations(normalized).map((item) => keyOf(item.ref)));
if (!storedKeys.has(keyOf(target))) {
throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
}
const parentRef: TabRef = { kind: 'session', id: targetMetadata.parentSessionId };
if (!storedKeys.has(keyOf(parentRef))) {
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
}
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
return normalizeTabLayout(cleared, metadata);
}
function descendantKeys(root: TabRef, layout: TabLayout, metadata: readonly TabRefMetadata[]): Set<string> {
const valid = validMetadata(metadata);
const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref]));
const children = new Map<string, string[]>();
for (const item of valid) {
if (item.kind !== 'session' || !item.parentSessionId) continue;
const child = stored.get(keyOf(item));
if (!child || child.placement === 'manual' || !stored.has(keyOf({ kind: 'session', id: item.parentSessionId })))
continue;
const siblings = children.get(item.parentSessionId) ?? [];
siblings.push(item.id);
children.set(item.parentSessionId, siblings);
}
const result = new Set<string>();
const add = (ref: TabRef): void => {
const key = keyOf(ref);
if (result.has(key)) return;
result.add(key);
if (ref.kind !== 'session') return;
for (const childId of children.get(ref.id) ?? []) add({ kind: 'session', id: childId });
};
add(root);
return result;
}
export function moveRef(
input: TabLayout,
target: TabRef,
destination: TabMoveTarget,
metadata: readonly TabRefMetadata[]
): TabLayout {
let layout = normalizeTabLayout(input, metadata);
const targetKey = keyOf(target);
if (!locations(layout).some((item) => keyOf(item.ref) === targetKey)) {
throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
}
if (destination.groupId !== null && !layout.groups.some((group) => group.id === destination.groupId)) {
throw new TabLayoutValidationError(`unknown group: ${destination.groupId}`);
}
const blockKeys = descendantKeys(target, layout, metadata);
const block = locations(layout)
.filter((item) => blockKeys.has(keyOf(item.ref)))
.map((item) => ({ ...item.ref }));
const metadataItem = validMetadata(metadata).find((item) => keyOf(item) === targetKey);
if (target.kind === 'session' && metadataItem?.parentSessionId) block[0] = { ...block[0], placement: 'manual' };
const remaining = new Map<string | null, TabRef[]>();
for (const group of layout.groups)
remaining.set(
group.id,
group.refs.filter((ref) => !blockKeys.has(keyOf(ref)))
);
remaining.set(
null,
layout.ungrouped.filter((ref) => !blockKeys.has(keyOf(ref)))
);
const destinationRefs = remaining.get(destination.groupId)!;
const index = boundedIndex(destination.index, destinationRefs.length, 'destination');
destinationRefs.splice(index, 0, ...block);
layout = withContainers(layout, remaining);
return normalizeTabLayout(layout, metadata);
}
/**
* Remove explicitly deleted session parents and pin their direct inherited
* children at their current stored positions so a later reused ID cannot adopt them.
*/
export function materializeOrphans(
input: TabLayout,
removedParentIds: readonly string[],
metadata: readonly TabRefMetadata[]
): TabLayout {
const layout = cloneForEdit(input);
const removed = new Set(removedParentIds);
const directChildren = new Set(
validMetadata(metadata)
.filter((item) => item.kind === 'session' && item.parentSessionId && removed.has(item.parentSessionId))
.map((item) => item.id)
);
const transform = (refs: readonly TabRef[]): TabRef[] =>
refs
.filter((ref) => ref.kind !== 'session' || !removed.has(ref.id))
.map((ref) =>
ref.kind === 'session' && directChildren.has(ref.id) && ref.placement !== 'manual'
? { ...ref, placement: 'manual' }
: { ...ref }
);
return {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: transform(group.refs) })),
ungrouped: transform(layout.ungrouped),
};
}
/** Session-only compatibility order; collapse and webviews do not affect it. */
export function flattenOwnerSessionOrder(input: TabLayout): string[] {
return locations(validateTabLayout(input))
.map((item) => item.ref)
.filter((ref): ref is TabRef & { kind: 'session' } => ref.kind === 'session')
.map((ref) => ref.id);
}
/** Locally renderable order used by tab painting and Alt-number consumers. */
export function flattenVisibleRefs(input: TabLayout, options: VisibleTabProjectionOptions): TabRef[] {
const layout = validateTabLayout(input);
const collapsed = options.collapsedGroupIds ?? new Set<string>();
const renderable = (ref: TabRef): boolean =>
ref.kind === 'session' ? options.liveSessionIds.has(ref.id) : options.openWebviewIds.has(ref.id);
const highlightedKey = options.highlighted ? keyOf(options.highlighted) : undefined;
const result: TabRef[] = [];
for (const group of layout.groups) {
for (const ref of group.refs) {
if (!renderable(ref)) continue;
if (collapsed.has(group.id) && keyOf(ref) !== highlightedKey) continue;
result.push({ ...ref });
}
}
for (const ref of layout.ungrouped) if (renderable(ref)) result.push({ ...ref });
return result;
}