Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
T
suzmii d02d0ed7a4 AGC 资源画布:新素材不再自动重排,改为显式「整理画布」并在生成后自动聚焦新素材
- useProjectResourceCanvasLayout:资源写意图新增 rederive 标记,显式整理那一笔按 rederive
  策略重算自动坐标(手动坐标原样保留),自动同步仍是 preserve 只补新卡
- useProjectResourceCanvasLayout:新增 rederiveNow(),复用既有写队列 / sidecar 写回链路,
  只在坐标确有变化时落盘,成功复用既有「布局已保存」提示
- index.tsx:两个排序模式的 rederiveAutomaticPositions 都固定 false,画布不再因为资源
  协调签名变化(新增素材、改标签、改分类、拓扑重建)整体重排
- index.tsx:依赖侧「关系图首次就绪」改为按项目作用域的一次性 flag,等该侧 sidecar ready
  后调用一次 rederiveNow(),之后一律只补新卡(切排序 tab 不重新武装)
- index.tsx:依赖/类型 tab 组新增「整理画布」按钮,作为整张画布重排的唯一显式入口
- index.tsx:新增 manifest.assets 新增 id 检测(seenManifestAssetIdsRef),把新卡交给既有
  pendingResourceFocusRef + advanceFocusGeneration() 裁决链聚焦;首次打开 / 切项目只登记
  基线不聚焦,重命名不改 id 天然不触发,已有同目标意图时不重复挂
- 新增 tests/resourceCanvasManualLayout.test.tsx(8 条):新素材入库后既有自动坐标逐值不变、
  依赖侧首次就绪后不再重排、新素材自动聚焦、被搜索挡住走既有「清除搜索并定位」、
  「整理画布」重算并保留手动坐标、已经整齐时再按一次不写盘、首次打开与切项目不聚焦
2026-09-14 17:07:55 +08:00

1099 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { normalizeDiagnosticText } from '../../services/errorReporting';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
} from './resourceCanvasLayoutModel';
import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping';
/**
* 布局落盘 / 读盘失败时保留一份**脱敏后的原因**。
*
* 这两个 `.catch` 此前写成 `() => { ... }` —— 没有 message 参数,失败原因被整个吞掉:
* 用户只看到"布局保存失败",排障端也拿不到为什么(revision 越界?IPC 拒绝?权限?)。
* 这里把 `error.message` 压成一行并复用既有的 `normalizeDiagnosticText`(路径 / token /
* URL / 长 id 一律打码),再截断,避免多行堆栈糊进日志;日志由客户端已安装的
* webview→application.log 桥带进诊断日志,遇到"某个窗口保存一直失败"能直接查。
*/
function describeResourceCanvasLayoutFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return normalizeDiagnosticText(message.replace(/\s+/gu, ' ').trim()).slice(
0,
400,
);
}
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;
/**
* 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。
*
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
* 只补新卡、不动既有坐标。
*/
rederive: boolean;
};
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
/**
* 自动坐标策略。`rederive` 丢掉全部自动坐标、按当前资源与拓扑重算(手动坐标原样保留);
* 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次
* `rederiveNow()`。`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;
/**
* 因分区取值**确实无从归并**而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。
*
* 只有两种取值会落到这里:无法识别的分区值,以及旧栏目归并目标的集合不含资源当前分类
* (如旧 `code` 遇到已归为 `document` 的资源)。**现行栏目值与资源当前分类不一致不再丢弃**:
* 那种情况下 `section` 被归并到资源当前分类、x / y 原样保留,计入 `normalizedSections`。
*/
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,
rederive: false,
});
}
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
: intent.rederive
? 'rederive'
: 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('布局已保存');
} else if (intent.rederive) {
// 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。
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((error: unknown) => {
const currentScope = scopeRef.current;
if (currentScope.epoch !== intent.scopeEpoch) {
return;
}
// 原因先落一行脱敏日志:下面那条提示在部分分支里是刻意不显示的,
// 若不记日志,这种"用户看不到、排障也看不到"的失败就完全消失。
console.warn(
`[resource-canvas-layout] 布局保存失败:${describeResourceCanvasLayoutFailure(error)}`,
);
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((error: unknown) => {
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
// 读盘失败同样是"只报一句、不报原因",这里保留脱敏后的一行原因。
console.warn(
`[resource-canvas-layout] 布局读取失败:${describeResourceCanvasLayoutFailure(error)}`,
);
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],
);
/**
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
* sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。
*
* 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动
* 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的
* `notice` / `saving` 状态位承担,不另造一套状态。
*/
const rederiveNow = useCallback(() => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const queued = writeQueueRef.current.find(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scope.epoch &&
intent.rederive &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
queued.conflictRetries = 0;
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch: scope.epoch,
resourceSignature: resourceSignatureRef.current,
conflictRetries: 0,
rederive: true,
});
}
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
rebuildOptimisticLayout(scope.epoch, 'rederive');
setSaving(true);
pumpWritesRef.current();
}, [initializationReady, rebuildOptimisticLayout, 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,
rederiveNow,
};
}