e15456ea14
- readReport 增加 droppedMissingResource(资源已不在 manifest)/ droppedSectionMismatch(分区与资源分类不匹配)/ dropped(两者之和),判据仍是 normalizeResourceCanvasPosition,未改 resourceCanvasLayoutModel 的丢弃语义。 - 文案补上跳过条数与两类原因:归并与丢弃同时发生时用「另有 N 条坐标无法对齐已跳过(…)」;只有丢弃时用「打开项目时有 N 条坐标无法对齐已跳过(…)」。 - 提示条暴露 data-resource-canvas-layout-normalized / -dropped / -dropped-missing-resource / -dropped-section-mismatch,供排障直接读 DOM;完全没丢时四个值都是 0。 - 读盘触发条件放宽为「有归并或有丢弃」,避免只有丢弃时什么都不提示。 - 断言:读盘统计用例覆盖三种情况分离;文案用例覆盖四形态(含真机 112 / 66 / 16 口径);appSurface 用例断言四个 data-* 与完整文案。 - 变异验证:把 dropped 计数改成恒 0 → 统计用例、读盘报告用例、提示条用例三处变红;去掉 data-resource-canvas-layout-dropped → 提示条用例变红。
1007 lines
31 KiB
TypeScript
1007 lines
31 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';
|
||
import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping';
|
||
|
||
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;
|
||
|
||
/**
|
||
* 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标并按当前资源与拓扑重算,
|
||
* PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑
|
||
* 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标。
|
||
*/
|
||
type AutomaticPositionPolicy = 'rederive' | 'preserve';
|
||
|
||
function automaticPositionPolicy(
|
||
rederiveAutomaticPositions: boolean,
|
||
): AutomaticPositionPolicy {
|
||
return rederiveAutomaticPositions ? 'rederive' : 'preserve';
|
||
}
|
||
|
||
/**
|
||
* 拖拽是一次纯手动写入:PRD 给自动重派生的触发条件是「关系图就绪 / 深度 / 拓扑签名
|
||
* 变化」,没有要求「用户拖动本身」触发。所以拖拽写链固定用 `preserve`,不让其它自动卡
|
||
* 补位到被拖走的空位。代价:把卡正好丢在另一张卡上时不再自动挪开它,直到下一次资源
|
||
* 集合或拓扑变化再次触发重派生。
|
||
*/
|
||
const MANUAL_WRITE_AUTOMATIC_POSITION_POLICY: AutomaticPositionPolicy =
|
||
'preserve';
|
||
|
||
function createScopeKey(
|
||
projectPath: string,
|
||
projectId: string,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
) {
|
||
return JSON.stringify([projectPath, projectId, mode]);
|
||
}
|
||
|
||
/** 项目作用域身份(不含排序模式):两个排序模式的提示要归并成同一次。 */
|
||
function createProjectScopeKey(projectPath: string, projectId: string) {
|
||
return JSON.stringify([projectPath, projectId]);
|
||
}
|
||
|
||
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 automaticCoordinatesMatch(
|
||
left: ProjectResourceCanvasLayout,
|
||
right: ProjectResourceCanvasLayout,
|
||
) {
|
||
if (left.positions.length !== right.positions.length) {
|
||
return false;
|
||
}
|
||
const rightByResourceId = new Map(
|
||
right.positions.map((position) => [position.resourceId, position]),
|
||
);
|
||
return left.positions.every((position) => {
|
||
const other = rightByResourceId.get(position.resourceId);
|
||
return (
|
||
other !== undefined &&
|
||
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),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 读盘后按现行分区归并 / 丢弃坐标的只读统计。
|
||
*
|
||
* 读时归并(旧 `art` / `code` 分区 → 资源当前分区)会让协调路径判定 `changed`,
|
||
* 于是第一次打开项目就会把整份布局写回一次 sidecar;无法归并的坐标则被丢弃,
|
||
* 并且这次丢弃同样由那次写回固化。改前这两件事都完全静默,所以这里把它们数出来,
|
||
* 交给调用方给用户一次可见反馈。
|
||
*/
|
||
export type ProjectResourceCanvasLayoutReadReport = {
|
||
/** 项目作用域身份(不含排序模式):同一个项目的两份 sidecar 只提示一次。 */
|
||
projectScopeKey: string;
|
||
mode: ProjectResourceCanvasLayoutMode;
|
||
/** 旧栏目坐标被读时归并到资源当前分区的条数。 */
|
||
normalizedSections: number;
|
||
/** 因 `resourceId` 在当前资源集合里查不到而被丢弃的坐标条数。 */
|
||
droppedMissingResource: number;
|
||
/** 因持久化分区与资源当前分类不匹配而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。 */
|
||
droppedSectionMismatch: number;
|
||
/** 上面两项之和:读盘时被丢弃的坐标总数。 */
|
||
dropped: number;
|
||
};
|
||
|
||
export type ProjectResourceCanvasLayoutReadCounts = Omit<
|
||
ProjectResourceCanvasLayoutReadReport,
|
||
'projectScopeKey' | 'mode'
|
||
>;
|
||
|
||
/**
|
||
* 读盘统计。判据直接复用 `normalizeResourceCanvasPosition`——也就是
|
||
* `reconcileResourceCanvasLayout` 丢失坐标用的同一个函数,所以三种计数与真正落盘的
|
||
* 结果逐条一致;这里只统计,判定与写回语义都不动。
|
||
*/
|
||
export function inspectProjectResourceCanvasLayoutRead(
|
||
layout: ProjectResourceCanvasLayout,
|
||
resources: ResourceCanvasItem[],
|
||
): ProjectResourceCanvasLayoutReadCounts {
|
||
const categoryByResourceId = new Map(
|
||
resources.map((resource) => [resource.id, resource.category]),
|
||
);
|
||
let normalizedSections = 0;
|
||
let droppedMissingResource = 0;
|
||
let droppedSectionMismatch = 0;
|
||
for (const position of layout.positions) {
|
||
const normalized = normalizeResourceCanvasPosition(
|
||
position,
|
||
categoryByResourceId,
|
||
);
|
||
if (normalized) {
|
||
if (normalized.changed) {
|
||
normalizedSections += 1;
|
||
}
|
||
continue;
|
||
}
|
||
if (categoryByResourceId.has(position.resourceId)) {
|
||
droppedSectionMismatch += 1;
|
||
} else {
|
||
droppedMissingResource += 1;
|
||
}
|
||
}
|
||
return {
|
||
normalizedSections,
|
||
droppedMissingResource,
|
||
droppedSectionMismatch,
|
||
dropped: droppedMissingResource + droppedSectionMismatch,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 读盘统计的用户可见文案。只描述已经发生的事:归并会当场写回一次 sidecar、坐标位置不变;
|
||
* 无法归并的坐标会被跳过。这里不写长期规则,也不承诺后续行为。
|
||
*/
|
||
export function describeProjectResourceCanvasLayoutRead(
|
||
report: ProjectResourceCanvasLayoutReadCounts,
|
||
): string {
|
||
const sentences: string[] = [];
|
||
if (report.normalizedSections > 0) {
|
||
sentences.push(
|
||
`已把 ${report.normalizedSections} 条旧分区坐标对齐到新分区并写回,坐标位置未变`,
|
||
);
|
||
}
|
||
if (report.dropped > 0) {
|
||
const reasons: string[] = [];
|
||
if (report.droppedMissingResource > 0) {
|
||
reasons.push(`${report.droppedMissingResource} 条资源已不在项目中`);
|
||
}
|
||
if (report.droppedSectionMismatch > 0) {
|
||
reasons.push(`${report.droppedSectionMismatch} 条分区与资源分类不匹配`);
|
||
}
|
||
sentences.push(
|
||
`${report.normalizedSections > 0 ? '另有 ' : '打开项目时有 '}${report.dropped} 条坐标无法对齐已跳过(${reasons.join(',')})`,
|
||
);
|
||
}
|
||
return sentences.join(';');
|
||
}
|
||
|
||
function reconcileLayout(
|
||
source: ProjectResourceCanvasLayout,
|
||
resources: ResourceCanvasItem[],
|
||
policy: AutomaticPositionPolicy,
|
||
topology: ResourceCanvasLayoutTopology | undefined,
|
||
) {
|
||
if (policy === 'preserve') {
|
||
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,
|
||
automaticPositionPolicy(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 [readReport, setReadReport] =
|
||
useState<ProjectResourceCanvasLayoutReadReport | null>(null);
|
||
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, policy: AutomaticPositionPolicy) => {
|
||
const scope = scopeRef.current;
|
||
if (scope.epoch !== scopeEpoch) {
|
||
return;
|
||
}
|
||
let next = reconcileLayout(
|
||
persistedLayoutRef.current,
|
||
resourcesRef.current,
|
||
policy,
|
||
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],
|
||
);
|
||
|
||
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 writePolicy =
|
||
intent.kind === 'manual'
|
||
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
|
||
: automaticPositionPolicy(rederiveAutomaticPositions);
|
||
const reconciled = reconcileLayout(
|
||
persistedLayoutRef.current,
|
||
resourcesRef.current,
|
||
writePolicy,
|
||
topologyRef.current,
|
||
);
|
||
if (intent.kind === 'resources' && !reconciled.changed) {
|
||
removeWriteIntent(intent);
|
||
rebuildOptimisticLayout(scope.epoch, writePolicy);
|
||
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, writePolicy);
|
||
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, writePolicy);
|
||
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, writePolicy);
|
||
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);
|
||
const reconciledAfterWrite = reconcileLayout(
|
||
result.layout,
|
||
resourcesRef.current,
|
||
writePolicy,
|
||
topologyRef.current,
|
||
);
|
||
const needsResourceSync =
|
||
intent.kind === 'manual'
|
||
? !automaticCoordinatesMatch(
|
||
result.layout,
|
||
reconciledAfterWrite.layout,
|
||
)
|
||
: reconciledAfterWrite.changed;
|
||
if (result.status === 'updated') {
|
||
if (intent.kind === 'manual') {
|
||
redragRequiredScopeEpochRef.current = null;
|
||
setNotice('布局已保存');
|
||
}
|
||
if (needsResourceSync) {
|
||
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 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, writePolicy);
|
||
})
|
||
.catch(() => {
|
||
const currentScope = scopeRef.current;
|
||
if (currentScope.epoch !== intent.scopeEpoch) {
|
||
return;
|
||
}
|
||
removeWriteIntent(intent);
|
||
rebuildOptimisticLayout(currentScope.epoch, writePolicy);
|
||
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);
|
||
setReadReport(null);
|
||
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
|
||
if (!initializationReady) {
|
||
persistedLayoutRef.current = emptyLayout;
|
||
applyLayout(emptyLayout);
|
||
setNotice('');
|
||
setSaving(false);
|
||
return undefined;
|
||
}
|
||
const initialFallback = reconcileLayout(
|
||
emptyLayout,
|
||
resourcesRef.current,
|
||
automaticPositionPolicy(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);
|
||
const readCounts = inspectProjectResourceCanvasLayoutRead(
|
||
loaded,
|
||
resourcesRef.current,
|
||
);
|
||
if (
|
||
reconcileLayout(
|
||
loaded,
|
||
resourcesRef.current,
|
||
automaticPositionPolicy(rederiveAutomaticPositions),
|
||
topologyRef.current,
|
||
).changed
|
||
) {
|
||
enqueueResourceSyncRef.current(epoch);
|
||
// 有归并或丢弃才会有这次写回;单纯补新资源落位的写回不报读时统计,
|
||
// 否则提示会声称「已对齐旧分区」而实际上一条都没对齐。
|
||
if (readCounts.normalizedSections > 0 || readCounts.dropped > 0) {
|
||
setReadReport({
|
||
...readCounts,
|
||
projectScopeKey: createProjectScopeKey(projectPath, projectId),
|
||
mode,
|
||
});
|
||
}
|
||
}
|
||
rebuildOptimisticLayout(
|
||
epoch,
|
||
automaticPositionPolicy(rederiveAutomaticPositions),
|
||
);
|
||
pumpWritesRef.current();
|
||
})
|
||
.catch(() => {
|
||
if (cancelled || scopeRef.current.epoch !== epoch) {
|
||
return;
|
||
}
|
||
persistedLayoutRef.current = initialFallback;
|
||
initializedScopeEpochRef.current = epoch;
|
||
setReadyScopeKey(scopeKey);
|
||
rebuildOptimisticLayout(
|
||
epoch,
|
||
automaticPositionPolicy(rederiveAutomaticPositions),
|
||
);
|
||
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,
|
||
automaticPositionPolicy(rederiveAutomaticPositions),
|
||
topologyRef.current,
|
||
);
|
||
applyLayout(reconciledCurrent.layout);
|
||
if (
|
||
window.__TAURI__?.core?.invoke &&
|
||
reconcileLayout(
|
||
persistedLayoutRef.current,
|
||
resourcesRef.current,
|
||
automaticPositionPolicy(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,
|
||
readReport,
|
||
scopeIdentity: scopeKey,
|
||
commitPosition,
|
||
};
|
||
}
|