layouts-internals
GitHub解析布局系统底层实现,涵盖ILayoutStorage接口、IndexedDB存储结构、NamespacedLayoutStorage命名空间封装及WriteThroughCache缓存机制,用于深入理解布局数据的CRUD操作与同步逻辑。
Trigger Scenarios
Install
npx skills add lichtblick-suite/lichtblick --skill layouts-internals -g -y
SKILL.md
Frontmatter
{
"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 layouts
migrateUnnamespacedLayouts?(namespace: string): Promise<void>;
// Convert local layouts to personal layouts on login
importLayouts(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 intolayout.idvia 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 provider layer, 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 no
REMOVE_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)
Version History
- cab9317 Current 2026-07-24 12:16


