Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
T
suzmii 2f8753f597 AGC 资源画布切换为 6 类资产分类加项目版本栏目(阶段二:投影轴与栏目表落地)
- 契约层把 ProjectResourceCanvasPosition.section 加宽为 PersistedProjectResourceCanvasSection 并删除旧 ProjectResourceCanvasSection 联合
- 七个分区类型消费点改用 ProjectResourceCanvasCategory:投影、布局模型、布局 Hook、分区高度、依赖图层与画布历史
- RESOURCE_CANVAS_SECTION_ORDER 改为 PROJECT_RESOURCE_CANVAS_SECTIONS,删除 RESOURCE_CANVAS_VISIBLE_SECTION_ORDER
- reconcileResourceCanvasLayout 接入读时归一化:旧栏目值按资源当前分区改写 section,坐标与手动标记原样保留
- 归并后的布局与原布局逐项不同,由既有协调路径判定 changed 并写回一次新分区,不引入迁移脚本
- 投影层五个资源入表点统一走 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类
- 新增 projectResourceDisplayKind 收敛 manifest 资产 subtype 即 kind 的显示类型口径,projectResourceTypeLabel 不再依赖旧栏目
- 卡片预览、编辑分流、媒体工具条、预览文案与画布历史快照改用显示类型与新分区,历史快照入栈时收窄到现行分区
- index.tsx 删除可见栏目导入与 visibleCategoryOrder,栏目标签由资源筛选唯一中文口径派生并补齐 7 栏
- index.tsx 补齐 7 栏默认视口与 7 个栏目图标(新增 LayoutGrid、Users、PackageOpen,移除已无用的 Code2)
- index.tsx 删除 canvasResources 的 code 过滤:只登记游戏代码的项目现在落在待归类栏目并正常显示卡片
- 新增只登记游戏代码项目的 UI 回归用例,覆盖栏目大纲、默认落地栏目与代码卡片渲染
- 测试夹具按新分区轴迁移,新增旧栏目 sidecar 读时归并用例与栏目顺序用例
- 共享测试 harness 的资源卡查询正则允许栏目标签自带空格(UI 交互)
- 同步 PRD 与共享记忆决策记录的栏目口径描述
2026-09-10 20:41:19 +08:00

826 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ProjectResourceCanvasCategory,
ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
isSafeProjectResourceCanvasCoordinate,
isSafeProjectResourceCanvasLayoutRevision,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
} from './resourceCanvasLayoutModel';
type LayoutNotice =
| ''
| '布局已保存'
| '布局读取失败,已使用当前会话布局'
| '布局保存失败,已保留当前会话布局'
| '布局保存失败,已恢复上次布局'
| '布局已在其他窗口更新';
type LayoutScope = {
key: string;
epoch: number;
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
};
type ManualLayoutWriteIntent = {
kind: 'manual';
scopeEpoch: number;
resourceId: string;
section: ProjectResourceCanvasCategory;
x: number;
y: number;
};
type ResourceLayoutWriteIntent = {
kind: 'resources';
scopeEpoch: number;
resourceSignature: string;
conflictRetries: number;
};
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
function createScopeKey(
projectPath: string,
projectId: string,
mode: ProjectResourceCanvasLayoutMode,
) {
return JSON.stringify([projectPath, projectId, mode]);
}
export function createResourceSignature(resources: ResourceCanvasItem[]) {
return resources
.map((resource) =>
JSON.stringify([
resource.id,
resource.category,
resource.subtype,
resource.label,
resource.dependencyDepth,
resource.mediaType,
resource.cardSize?.width ?? null,
resource.cardSize?.height ?? null,
]),
)
.sort()
.join('\n');
}
/**
* A bounded, identity-only input to dependency automatic placement. Display
* labels deliberately stay out: the resource list's coordination signature
* already covers them, while topology changes must be detectable even when
* every depth and resource count is unchanged.
*/
export function createResourceTopologySignature(
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!topology) {
return '';
}
const entries = [
...topology.referenceEdges
.map(
(edge) =>
`r:${JSON.stringify([edge.sourceResourceId, edge.targetResourceId])}`,
)
.sort(compareStableText),
...topology.taskFlows
.map(
(flow) =>
`f:${JSON.stringify([
[...new Set(flow.sourceResourceIds)].sort(compareStableText),
[...new Set(flow.targetResourceIds)].sort(compareStableText),
])}`,
)
.sort(compareStableText),
];
return stableTopologyDigest([...new Set(entries)].sort(compareStableText));
}
function compareStableText(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Keeps the sidecar coordination input fixed-size even for a large graph.
* The canonical entries retain all stable endpoint and flow-member identities
* while being digested; labels and browser geometry never participate.
*/
function stableTopologyDigest(entries: readonly string[]) {
let forward = 0x811c9dc5;
let reverse = 0x9e3779b9;
for (const entry of entries) {
for (let index = 0; index < entry.length; index += 1) {
forward = Math.imul(forward ^ entry.charCodeAt(index), 0x01000193);
reverse = Math.imul(
reverse ^ entry.charCodeAt(entry.length - index - 1),
0x85ebca6b,
);
}
forward = Math.imul(forward ^ 10, 0x01000193);
reverse = Math.imul(reverse ^ 10, 0x85ebca6b);
}
return `${(entries.length >>> 0).toString(16).padStart(8, '0')}:${(
forward >>> 0
)
.toString(16)
.padStart(8, '0')}:${(reverse >>> 0).toString(16).padStart(8, '0')}`;
}
function layoutMatchesScope(
layout: ProjectResourceCanvasLayout,
scope: LayoutScope,
) {
return layout.projectId === scope.projectId && layout.mode === scope.mode;
}
function positionsEqual(
left: ProjectResourceCanvasLayout['positions'],
right: ProjectResourceCanvasLayout['positions'],
) {
return (
left.length === right.length &&
left.every((position, index) => {
const other = right[index];
return (
other?.resourceId === position.resourceId &&
other.section === position.section &&
other.x === position.x &&
other.y === position.y &&
other.manuallyPlaced === position.manuallyPlaced
);
})
);
}
function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
return layout.positions.every(
(position) =>
isSafeProjectResourceCanvasCoordinate(position.x) &&
isSafeProjectResourceCanvasCoordinate(position.y),
);
}
function reconcileLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
rederiveAutomaticPositions: boolean,
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!rederiveAutomaticPositions) {
const reconciled = reconcileResourceCanvasLayout(
source,
resources,
topology,
);
return layoutCoordinatesAreSafe(reconciled.layout)
? reconciled
: { layout: source, changed: false };
}
const manualSource = {
...source,
positions: source.positions.filter((position) => position.manuallyPlaced),
};
const reconciled = reconcileResourceCanvasLayout(
manualSource,
resources,
topology,
);
const result = {
layout: reconciled.layout,
changed: !positionsEqual(source.positions, reconciled.layout.positions),
};
return layoutCoordinatesAreSafe(result.layout)
? result
: { layout: source, changed: false };
}
export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources,
topology,
initializationReady = true,
renderFallbackWhileBlocked = false,
rederiveAutomaticPositions = false,
}: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
topology?: ResourceCanvasLayoutTopology;
initializationReady?: boolean;
renderFallbackWhileBlocked?: boolean;
rederiveAutomaticPositions?: boolean;
}) {
const topologySignature = useMemo(
() => createResourceTopologySignature(topology),
[topology],
);
const resourceSignature = useMemo(
() => [createResourceSignature(resources), topologySignature].join('\n'),
[resources, topologySignature],
);
const scopeKey = createScopeKey(projectPath, projectId, mode);
const fallback = useMemo(() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady || renderFallbackWhileBlocked
? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology)
.layout
: empty;
}, [
initializationReady,
mode,
projectId,
rederiveAutomaticPositions,
renderFallbackWhileBlocked,
resources,
topology,
]);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
const [saving, setSaving] = useState(false);
const [readyScopeKey, setReadyScopeKey] = useState<string | null>(null);
const mountedRef = useRef(true);
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const resourcesRef = useRef(resources);
const topologyRef = useRef(topology);
const resourceSignatureRef = useRef(resourceSignature);
const initializedScopeEpochRef = useRef<number | null>(null);
const scopeRef = useRef<LayoutScope>({
key: scopeKey,
epoch: 0,
projectPath,
projectId,
mode,
});
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
const redragRequiredScopeEpochRef = useRef<number | null>(null);
const pumpWritesRef = useRef<() => void>(() => undefined);
const enqueueResourceSyncRef = useRef<
(scopeEpoch: number, conflictRetries?: number) => void
>(() => undefined);
resourcesRef.current = resources;
topologyRef.current = topology;
resourceSignatureRef.current = resourceSignature;
const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => {
layoutRef.current = next;
if (mountedRef.current) {
setLayout(next);
}
}, []);
const removeWriteIntent = useCallback((intent: LayoutWriteIntent) => {
writeQueueRef.current = writeQueueRef.current.filter(
(queued) => queued !== intent,
);
}, []);
const rebuildOptimisticLayout = useCallback(
(scopeEpoch: number) => {
const scope = scopeRef.current;
if (scope.epoch !== scopeEpoch) {
return;
}
let next = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).layout;
for (const intent of writeQueueRef.current) {
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
next = moveResourceCanvasPosition(
next,
intent.resourceId,
intent.section,
intent.x,
intent.y,
);
}
}
applyLayout(next);
},
[applyLayout, rederiveAutomaticPositions],
);
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
const scope = scopeRef.current;
if (scope.epoch !== scopeEpoch) {
return;
}
const signature = resourceSignatureRef.current;
const queued = writeQueueRef.current.find(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scopeEpoch &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
if (queued.resourceSignature !== signature) {
queued.resourceSignature = signature;
queued.conflictRetries = 0;
} else {
queued.conflictRetries = Math.min(
queued.conflictRetries,
conflictRetries,
);
}
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch,
resourceSignature: signature,
conflictRetries,
});
}
if (mountedRef.current) {
setSaving(true);
}
pumpWritesRef.current();
};
pumpWritesRef.current = () => {
if (activeWriteIntentRef.current) {
return;
}
const scope = scopeRef.current;
writeQueueRef.current = writeQueueRef.current.filter(
(intent) => intent.scopeEpoch === scope.epoch,
);
const intent = writeQueueRef.current[0];
if (!intent) {
if (mountedRef.current) {
setSaving(false);
}
return;
}
if (initializedScopeEpochRef.current !== scope.epoch) {
if (mountedRef.current) {
setSaving(true);
}
return;
}
const reconciled = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
);
if (intent.kind === 'resources' && !reconciled.changed) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
if (
intent.kind === 'manual' &&
!reconciled.layout.positions.some(
(position) =>
position.resourceId === intent.resourceId &&
position.section === intent.section,
)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
const candidate =
intent.kind === 'manual'
? moveResourceCanvasPosition(
reconciled.layout,
intent.resourceId,
intent.section,
intent.x,
intent.y,
)
: reconciled.layout;
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
persistedLayoutRef.current = candidate;
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
const hasQueuedWrite = writeQueueRef.current.some(
(queued) => queued.scopeEpoch === scope.epoch,
);
if (mountedRef.current) {
setSaving(hasQueuedWrite);
}
if (hasQueuedWrite) {
void Promise.resolve().then(() => pumpWritesRef.current());
}
return;
}
const expectedRevision = persistedLayoutRef.current.revision;
if (
!isSafeProjectResourceCanvasLayoutRevision(expectedRevision) ||
!layoutCoordinatesAreSafe(candidate)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
}
setNotice(
intent.kind === 'resources'
? '布局保存失败,已保留当前会话布局'
: '布局保存失败,已恢复上次布局',
);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
activeWriteIntentRef.current = intent;
if (mountedRef.current) {
setSaving(true);
}
void invoke<UpdateProjectResourceCanvasLayoutResult>(
'update_local_project_resource_canvas_layout',
{
projectPath: scope.projectPath,
expectedProjectId: scope.projectId,
mode: scope.mode,
expectedRevision,
positions: candidate.positions,
},
)
.then((result) => {
const currentScope = scopeRef.current;
if (currentScope.epoch !== intent.scopeEpoch) {
return;
}
if (
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) ||
!layoutCoordinatesAreSafe(result.layout)
) {
throw new Error(
'layout response revision or coordinates are invalid',
);
}
if (!layoutMatchesScope(result.layout, currentScope)) {
throw new Error('layout response scope mismatch');
}
persistedLayoutRef.current = result.layout;
removeWriteIntent(intent);
if (result.status === 'updated') {
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
setNotice('布局已保存');
}
if (
reconcileLayout(
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(currentScope.epoch);
}
} else {
const discardedQueuedManualIntent = writeQueueRef.current.some(
(queued) =>
queued.scopeEpoch === currentScope.epoch &&
queued.kind === 'manual',
);
writeQueueRef.current = writeQueueRef.current.filter(
(queued) =>
queued.scopeEpoch !== currentScope.epoch ||
queued.kind !== 'manual',
);
const needsResourceSync = reconcileLayout(
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed;
const nextRetry =
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
const willRetryResourceSync =
needsResourceSync &&
nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES;
const redragRequired =
intent.kind === 'manual' || discardedQueuedManualIntent;
if (redragRequired) {
redragRequiredScopeEpochRef.current = currentScope.epoch;
}
if (willRetryResourceSync) {
enqueueResourceSyncRef.current(currentScope.epoch, nextRetry);
}
if (redragRequired || !willRetryResourceSync) {
setNotice('布局已在其他窗口更新');
}
}
rebuildOptimisticLayout(currentScope.epoch);
})
.catch(() => {
const currentScope = scopeRef.current;
if (currentScope.epoch !== intent.scopeEpoch) {
return;
}
removeWriteIntent(intent);
rebuildOptimisticLayout(currentScope.epoch);
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
}
if (
intent.kind !== 'resources' ||
redragRequiredScopeEpochRef.current !== currentScope.epoch
) {
setNotice(
intent.kind === 'resources'
? '布局保存失败,已保留当前会话布局'
: '布局保存失败,已恢复上次布局',
);
}
})
.finally(() => {
if (activeWriteIntentRef.current === intent) {
activeWriteIntentRef.current = null;
}
const currentScope = scopeRef.current;
if (mountedRef.current && currentScope.epoch === intent.scopeEpoch) {
setSaving(
writeQueueRef.current.some(
(queued) => queued.scopeEpoch === currentScope.epoch,
),
);
}
pumpWritesRef.current();
});
};
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
scopeRef.current = {
...scopeRef.current,
epoch: scopeRef.current.epoch + 1,
};
initializedScopeEpochRef.current = null;
writeQueueRef.current = [];
activeWriteIntentRef.current = null;
redragRequiredScopeEpochRef.current = null;
};
}, []);
useEffect(() => {
const epoch = scopeRef.current.epoch + 1;
const scope: LayoutScope = {
key: scopeKey,
epoch,
projectPath,
projectId,
mode,
};
scopeRef.current = scope;
initializedScopeEpochRef.current = null;
writeQueueRef.current = [];
activeWriteIntentRef.current = null;
redragRequiredScopeEpochRef.current = null;
setReadyScopeKey(null);
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
if (!initializationReady) {
persistedLayoutRef.current = emptyLayout;
applyLayout(emptyLayout);
setNotice('');
setSaving(false);
return undefined;
}
const initialFallback = reconcileLayout(
emptyLayout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).layout;
persistedLayoutRef.current = initialFallback;
applyLayout(initialFallback);
setNotice('');
setSaving(false);
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
pumpWritesRef.current();
return undefined;
}
let cancelled = false;
void invoke<ProjectResourceCanvasLayout>(
'read_local_project_resource_canvas_layout',
{ projectPath, mode },
)
.then((loaded) => {
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
if (
!isSafeProjectResourceCanvasLayoutRevision(loaded.revision) ||
!layoutCoordinatesAreSafe(loaded)
) {
throw new Error(
'layout response revision or coordinates are invalid',
);
}
if (!layoutMatchesScope(loaded, scope)) {
return;
}
persistedLayoutRef.current = loaded;
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
if (
reconcileLayout(
loaded,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(epoch);
}
rebuildOptimisticLayout(epoch);
pumpWritesRef.current();
})
.catch(() => {
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
persistedLayoutRef.current = initialFallback;
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
rebuildOptimisticLayout(epoch);
setNotice('布局读取失败,已使用当前会话布局');
pumpWritesRef.current();
});
return () => {
cancelled = true;
};
}, [
applyLayout,
initializationReady,
mode,
projectId,
projectPath,
rebuildOptimisticLayout,
rederiveAutomaticPositions,
scopeKey,
]);
useEffect(() => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const reconciledCurrent = reconcileLayout(
layoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
);
applyLayout(reconciledCurrent.layout);
if (
window.__TAURI__?.core?.invoke &&
reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(scope.epoch);
}
}, [
applyLayout,
initializationReady,
rederiveAutomaticPositions,
resourceSignature,
scopeKey,
]);
useEffect(() => {
if (!notice) {
return undefined;
}
if (
notice === '布局已在其他窗口更新' &&
redragRequiredScopeEpochRef.current === scopeRef.current.epoch
) {
return undefined;
}
const timeout = window.setTimeout(() => setNotice(''), 2400);
return () => window.clearTimeout(timeout);
}, [notice]);
const commitPosition = useCallback(
(
resourceId: string,
section: ProjectResourceCanvasCategory,
x: number,
y: number,
) => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const queuedIntent = writeQueueRef.current.find(
(intent): intent is ManualLayoutWriteIntent =>
intent.kind === 'manual' &&
intent.scopeEpoch === scope.epoch &&
intent.resourceId === resourceId &&
intent.section === section &&
intent !== activeWriteIntentRef.current,
);
if (queuedIntent) {
queuedIntent.x = x;
queuedIntent.y = y;
} else {
writeQueueRef.current.push({
kind: 'manual',
scopeEpoch: scope.epoch,
resourceId,
section,
x,
y,
});
}
applyLayout(
moveResourceCanvasPosition(
layoutRef.current,
resourceId,
section,
x,
y,
),
);
setSaving(true);
pumpWritesRef.current();
},
[applyLayout, initializationReady, scopeKey],
);
const scopeMatches =
initializationReady &&
scopeRef.current.key === scopeKey &&
layout.projectId === projectId &&
layout.mode === mode;
const ready = scopeMatches && readyScopeKey === scopeKey;
const allResourcesPositioned = resources.every((resource) =>
layout.positions.some(
(position) =>
position.resourceId === resource.id &&
position.section === resource.category,
),
);
const settled =
ready &&
!saving &&
activeWriteIntentRef.current === null &&
!writeQueueRef.current.some(
(intent) => intent.scopeEpoch === scopeRef.current.epoch,
) &&
allResourcesPositioned;
return {
layout: ready ? layout : fallback,
notice,
saving,
ready,
settled,
scopeIdentity: scopeKey,
commitPosition,
};
}