mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
feat(tabs): COD-359 add owner-scoped tab layouts
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
Reference in New Issue
Block a user