2f8753f597
- 契约层把 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 与共享记忆决策记录的栏目口径描述
826 lines
24 KiB
TypeScript
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,
|
|
};
|
|
}
|