mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
Review fixes for "place new, child and web tabs into groups". - Spawned tabs follow their parent only with the new synced setting spawnedTabsFollowParent (App Settings > Appearance > Tabs, off by default). TabLayoutService.sessionCreated() reads it fresh through a childrenFollowParent dep: off, a spawned session lands after its parent and is marked manual in the same write; on, it follows until moved by hand. The row menu shows the placement note and "Follow parent again" only when on. "New session" in a group menu is unaffected. zh-CN label and a Settings-Reference row. - applyLegacySessionRank marks a child manual only when the requested order splits it from its parent's block. A PUT of the current order, or one that moves a family together, leaves every stored placement alone. - The browser never echoes a create or delete through PUT /api/session-order, with or without groups: the layout service already re-projects the order and broadcasts it, and syncSessionOrder adopts it. - The placement note in the row menu is plain text described by the menu, not a focusable menu item. - A tab that first appears inside a collapsed group briefly marks that group's header. - i18n skips .tab-name, so tab names are never translated. - Tests: legacy-order cases for an unchanged order, a family reorder and a split; service cases for the setting and for order PUTs; the scheduled registration test now exercises register/undo; the grouped-create test binds port 0; one route case runs through the real layout service.
138 lines
5.2 KiB
TypeScript
138 lines
5.2 KiB
TypeScript
/**
|
|
* @fileoverview Pure compatibility translation between legacy session order and owner tab layouts.
|
|
*/
|
|
|
|
import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js';
|
|
import {
|
|
normalizeTabLayout,
|
|
validateTabLayout,
|
|
type TabLayout,
|
|
type TabRef,
|
|
type TabRefMetadata,
|
|
} from './tab-layout.js';
|
|
|
|
export interface OwnerOrderProjection {
|
|
owner: string;
|
|
ownedIds: readonly string[];
|
|
order: readonly string[];
|
|
}
|
|
|
|
export function applyLegacySessionRank(
|
|
input: TabLayout,
|
|
requestedOrder: readonly string[],
|
|
metadata: readonly TabRefMetadata[]
|
|
): TabLayout {
|
|
const layout = validateTabLayout(input);
|
|
const requestedRank = new Map(normalizeSessionOrder(requestedOrder).map((id, index) => [id, index]));
|
|
const sessionMetadata = new Map<string, TabRefMetadata>();
|
|
for (const item of metadata) {
|
|
if (item.kind !== 'session' || !item.ownerValid || !item.visible || sessionMetadata.has(item.id)) continue;
|
|
sessionMetadata.set(item.id, item);
|
|
}
|
|
|
|
const isRanked = (ref: TabRef): boolean =>
|
|
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
|
|
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
|
|
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
|
let rankedIndex = 0;
|
|
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
|
|
};
|
|
|
|
const ranked: TabLayout = {
|
|
...layout,
|
|
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
|
|
ungrouped: rankContainer(layout.ungrouped),
|
|
};
|
|
const manual = childrenSplitFromParent(ranked, sessionMetadata);
|
|
const pin = (ref: TabRef): TabRef =>
|
|
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
|
|
const transformed: TabLayout = {
|
|
...ranked,
|
|
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
|
|
ungrouped: ranked.ungrouped.map(pin),
|
|
};
|
|
return normalizeTabLayout(transformed, metadata);
|
|
}
|
|
|
|
/**
|
|
* The following children (no `manual` placement, an owner-valid parent) that a
|
|
* ranked layout separates from their parent's block. A parent's block is the
|
|
* parent and, right after it in the same container, its following descendants;
|
|
* sibling order inside the block is free, and a web tab the ranking left in a
|
|
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
|
|
* a PUT of the current order, or one that moves a family together, leaves every
|
|
* stored placement alone. Pinning one child shrinks its parent's block, so the
|
|
* check repeats until nothing more is pinned.
|
|
*/
|
|
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
|
|
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
|
|
const following = new Map<string, string>();
|
|
for (const refs of containers) {
|
|
for (const ref of refs) {
|
|
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
|
|
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
|
|
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
|
|
}
|
|
}
|
|
const descendsFrom = (id: string, ancestorId: string): boolean => {
|
|
let current = following.get(id);
|
|
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
|
|
if (current === ancestorId) return true;
|
|
current = following.get(current);
|
|
}
|
|
return false;
|
|
};
|
|
const manual = new Set<string>();
|
|
for (let changed = true; changed; ) {
|
|
changed = false;
|
|
for (const refs of containers) {
|
|
const index = new Map<string, number>();
|
|
refs.forEach((ref, at) => {
|
|
if (ref.kind === 'session') index.set(ref.id, at);
|
|
});
|
|
refs.forEach((ref, at) => {
|
|
if (ref.kind !== 'session') return;
|
|
const parentId = following.get(ref.id);
|
|
if (parentId === undefined) return;
|
|
const parentAt = index.get(parentId);
|
|
const inBlock =
|
|
parentAt !== undefined &&
|
|
parentAt < at &&
|
|
refs
|
|
.slice(parentAt + 1, at)
|
|
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
|
|
if (inBlock) return;
|
|
following.delete(ref.id);
|
|
manual.add(ref.id);
|
|
changed = true;
|
|
});
|
|
}
|
|
}
|
|
return manual;
|
|
}
|
|
|
|
export function recomposeGlobalSessionOrder(
|
|
current: readonly string[],
|
|
projections: readonly OwnerOrderProjection[],
|
|
preferred?: readonly string[]
|
|
): string[] {
|
|
let result = mergeSessionOrder([...(preferred ?? current)], [...current]);
|
|
for (const projection of projections) {
|
|
const ownedIds = normalizeSessionOrder(projection.ownedIds);
|
|
const owned = new Set(ownedIds);
|
|
const canonical = normalizeSessionOrder(projection.order).filter((id) => owned.has(id));
|
|
const canonicalSet = new Set(canonical);
|
|
for (const id of ownedIds) {
|
|
if (canonicalSet.has(id)) continue;
|
|
canonicalSet.add(id);
|
|
canonical.push(id);
|
|
}
|
|
|
|
let canonicalIndex = 0;
|
|
const recomposed = result.map((id) => (owned.has(id) ? canonical[canonicalIndex++] : id));
|
|
recomposed.push(...canonical.slice(canonicalIndex));
|
|
result = normalizeSessionOrder(recomposed);
|
|
}
|
|
return result;
|
|
}
|