Skill v1.0.1
currentAutomated scan100/100+1 new
version: "1.0.1" name: "layouts-internals" description: "Deep layout system implementation knowledge: ILayoutStorage contracts, IndexedDB schema, sync operation computation, mutex-locked LayoutManager, conflict resolution, WriteThroughLayoutCache, NamespacedLayoutStorage, and CurrentLayoutProvider reducers."
Layouts Internals Skill
ILayoutStorage Interface
Defined in packages/suite-base/src/services/ILayoutStorage.ts. Every CRUD method takes a namespace argument — there are no sync-time methods like getLastSyncTime.
export interface ILayoutStorage {list(namespace: string): Promise<readonly Layout[]>;get(namespace: string, id: LayoutID): Promise<Layout | undefined>;put(namespace: string, layout: Layout): Promise<Layout>;delete(namespace: string, id: LayoutID): Promise<void>;// Optional one-time migration of pre-namespace local layoutsmigrateUnnamespacedLayouts?(namespace: string): Promise<void>;// Convert local layouts to personal layouts on loginimportLayouts(params: { fromNamespace: string; toNamespace: string }): Promise<void>;}
The Layout type tracks baseline (last explicit save), working (unsaved edits, or undefined), and syncInfo (remote status). LayoutSyncStatus is "new" | "updated" | "tracked" | "locally-deleted" | "remotely-deleted".
IdbLayoutStorage (IndexedDB Detail)
packages/suite-base/src/IdbLayoutStorage.ts:
// DB name: `${KEY_WORKSPACE_PREFIX}lichtblick-layouts` (version 1)// Object store: "layouts"// keyPath: ["namespace", "layout.id"] (composite primary key)// index "namespace": keyPath "namespace" (non-unique)interface LayoutsDB extends DBSchema {layouts: {key: [namespace: string, id: LayoutID];value: { namespace: string; layout: Layout };indexes: { namespace: string };};}
list()usesgetAllFromIndex("layouts", "namespace", namespace)- Every read passes the record through
migrateLayout()before returning - The stored value wraps the layout:
{ namespace, layout }— the primary key reaches into
layout.id via the "layout.id" keyPath segment
NamespacedLayoutStorage
packages/suite-base/src/services/LayoutManager/NamespacedLayoutStorage.ts — wraps an ILayoutStorage and binds a namespace so callers omit it. It is not itself an ILayoutStorage (its methods drop the namespace argument). The constructor kicks off an async migration/import:
export class NamespacedLayoutStorage {#migration: Promise<void>;constructor(private storage: ILayoutStorage,private namespace: string,opts: { migrateUnnamespacedLayouts: boolean; importFromNamespace: string | undefined },) {// runs migrateUnnamespacedLayouts?() and/or importLayouts() once}async list(): Promise<readonly Layout[]> { await this.#migration; return this.storage.list(this.namespace); }async get(id: LayoutID) { await this.#migration; return this.storage.get(this.namespace, id); }async put(layout: Layout) { await this.#migration; return this.storage.put(this.namespace, layout); }async delete(id: LayoutID) { await this.#migration; return this.storage.delete(this.namespace, id); }}
WriteThroughLayoutCache
packages/suite-base/src/services/LayoutManager/WriteThroughLayoutCache.ts — an ILayoutStorage that calls the underlying list() once per namespace (via LazilyInitialized) and serves subsequent reads from an in-memory Map, writing through to the inner storage on put/delete. Assumes nothing else mutates the underlying storage.
export default class WriteThroughLayoutCache implements ILayoutStorage {#cacheByNamespace = new Map<string, LazilyInitialized<Map<string, Layout>>>();constructor(private storage: ILayoutStorage) {}async put(namespace: string, layout: Layout): Promise<Layout> {const result = await this.storage.put(namespace, layout);(await this.#getOrCreateCache(namespace).get()).set(result.id, result);return result;}// get/list/delete read/write the per-namespace cache map}
LayoutManager (Sync Orchestrator)
packages/suite-base/src/services/LayoutManager/LayoutManager.ts.
Mutex Pattern
All local storage access is wrapped in a MutexLocked (from @lichtblick/den/async) so multi-step operations are atomic. A single in-flight sync is tracked by currentSync?: Promise<void>.
class LayoutManager {private local: MutexLocked<NamespacedLayoutStorage>;private currentSync?: Promise<void>;async getLayouts(): Promise<readonly Layout[]> {return await this.local.runExclusive(async (local) => await local.list());}}
⚠️LayoutManagerdoes not implement exponential backoff / jitter /#baseInterval/#maxInterval. Sync scheduling (and any retry/online-trigger behavior) lives in the providerlayer, not inLayoutManager. Do not assume a built-in backoff timer here.
computeLayoutSyncOperations() (Detail)
packages/suite-base/src/services/LayoutManager/utils/computeLayoutSyncOperations.ts. The real SyncOperation is a tagged union carrying a local boolean and the operation type:
export type SyncOperation =| { local: true; type: "add-to-cache"; remoteLayout: RemoteLayout }| { local: true; type: "delete-local"; localLayout: Layout }| { local: true; type: "mark-deleted"; localLayout: Layout }| { local: false; type: "delete-remote"; localLayout: Layout }| { local: false; type: "upload-new"; localLayout: Layout }| { local: false; type: "upload-updated"; localLayout: Layout }| {local: true;type: "update-baseline";localLayout: Layout & { syncInfo: NonNullable<Layout["syncInfo"]> };remoteLayout: RemoteLayout;};
⚠️ There is no"upload" | "download" | "conflict"union, nolayoutIdfield, and nosyncStatus/baseline.savedAtcomparison as shown previously. Operations are keyed bylocalLayout/remoteLayout, and thelocalflag indicates whether the op mutates local(cache) or remote storage.
The function iterates local layouts (matching against a remoteLayoutsById map) and then any remaining remote-only layouts, pushing the appropriate operation for each.
CurrentLayoutProvider Reducers
packages/suite-base/src/providers/CurrentLayoutProvider/reducers.ts. The actual action type values handled are:
CHANGE_PANEL_LAYOUT, SAVE_PANEL_CONFIGS, SAVE_FULL_PANEL_CONFIG, CREATE_TAB_PANEL, OVERWRITE_GLOBAL_DATA, SET_GLOBAL_DATA, SET_USER_NODES, SET_PLAYBACK_CONFIG, CLOSE_PANEL, SPLIT_PANEL, SWAP_PANEL, MOVE_TAB, ADD_PANEL, DROP_PANEL, START_DRAG, END_DRAG.
⚠️ There are noREMOVE_PANEL,MOVE_PANEL, orUPDATE_PANEL_CONFIGactions. Panel removal isCLOSE_PANEL; config writes go throughSAVE_PANEL_CONFIGS/SAVE_FULL_PANEL_CONFIG.
Panel Tree Operations (examples)
ADD_PANEL — find insertion point → add new leaf to the mosaic tree.
CLOSE_PANEL — remove leaf → if parent collapses to a single child, hoist it.
SPLIT_PANEL:
Replace leaf with { direction, first: existingLeaf, second: newLeaf, splitPercentage: 50 }
Config Update Pattern (SAVE_PANEL_CONFIGS)
case "SAVE_PANEL_CONFIGS":// merges each { id, config } entry into state's configById,// optionally via a per-panel override function
DesktopLayoutLoader
packages/suite-desktop/src/renderer/services/DesktopLayoutLoader.ts — namespace = "local". Reads layouts from the desktop file system through the preload storageBridge (list / get / put / delete), not a desktopBridge.fetchLayouts() call.
Common Issues
- Sync conflicts: User edits while offline → both sides changed → manual resolution needed
- Mutex deadlock: If sync operation throws without releasing → next sync hangs (mitigated by timeout)
- IndexedDB quota: Large layouts with many panels → check quota before save
- Baseline drift: If baseline gets corrupted → all syncs show as conflicts (reset baseline)