Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
T
lhk229 70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
删除策划agent v2,与退役功能解耦 (#355)
Reviewed-on: #355
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-20 14:49:57 +08:00

1349 lines
48 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 {
applyResourceCanvasPositionWrites,
createEmptyResourceCanvasLayout,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
type ResourceCanvasPositionWrite,
} 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;
};
/**
* 一笔手动坐标写入:多选拖动、撤销/重做恢复都只排**一笔**,`positions` 里的每一张卡各自
* 带自己的 `section` 与 `manuallyPlaced`。队列按 `resourceId + section` 合并同一张卡的重复
* 写入,语义仍是"最后落点赢",不会因为一次多选拖动就生成 N 笔 CAS。
*/
type ManualLayoutWriteIntent = {
kind: 'manual';
scopeEpoch: number;
positions: ResourceCanvasPositionWrite[];
};
/**
* 自动坐标重派生请求。两种重算**各占一个队列槽**(按 `kind` 区分):显式整理是用户动作、
* 「关系图首次就绪」是系统动作,后者不得就地改写前者已经按下、还没落盘的那一笔。
*
* - `automatic`:只丢自动坐标,手动卡原地不动(关系图首次就绪那一次)。
* - `organize`:用户显式「整理画布」。目标栏目内的坐标**连手动一起丢**再重算,结果一律是
* 自动坐标;`sections` 为 `null` 表示「所有资源」页=当前项目全部可展示素材。
* 保存仍走同一条写队列与 CAS,只是重算前的取舍不同。
*/
type LayoutRederiveRequest =
| { kind: 'automatic' }
| {
kind: 'organize';
sections: readonly ProjectResourceCanvasCategory[] | null;
};
type ResourceLayoutWriteIntent = {
kind: 'resources';
scopeEpoch: number;
resourceSignature: string;
conflictRetries: number;
/**
* 显式「整理画布」/「关系图首次就绪」写意图:这一笔写回按 `rederive` 策略丢坐标重算。
*
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
* 只补新卡、不动既有坐标。
*/
rederive: LayoutRederiveRequest | null;
};
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';
/**
* 一次「整理画布」的目标范围键:`null` 是「所有资源」页=全部栏目,否则是有序栏目集合。
*
* 队列按这个键判断"是不是同一次整理",而不是按 `sections` 的数组身份——调用方每次渲染都会
* 传一份新数组。**不同范围必须是两笔独立意图**:栏目 A 与栏目 B 是两次用户动作,各对应一次
* 撤销、各一笔 CAS;用后一次覆盖前一次会把前一栏的整理从落盘里抹掉(画面上它已经被乐观视图
* 重排过,磁盘上却是"只整理了后一栏")。同范围才就地合并,合并时只刷新签名与重试计数,
* 保持它在队列里的位置,顺序语义不变。
*/
function organizeSectionsKey(
sections: readonly ProjectResourceCanvasCategory[] | null,
) {
return sections === null
? 'all'
: JSON.stringify([...new Set(sections)].sort());
}
/**
* 这一按可以就地合并的那一笔待处理整理:**必须在队尾**,且作用范围相同。
*
* "只管队尾"不是省事,是本条语义的必需:队列是 FIFO,合并进更早的同范围整理,这一按就落到
* 它**之后**排进来、还没落盘的写入之前——`整理A → 手动移动A → 再整理A` 会复用第一次那个槽位,
* 最终落盘的是中间那次手动坐标,用户最后按下的整理被静默吞掉。落在队尾时合并没有这个风险
* (后面没有更新的意图),所以连续按同一栏仍然只写一笔 CAS。
*/
function queuedOrganizeIntentAtTail(
queue: readonly LayoutWriteIntent[],
activeIntent: LayoutWriteIntent | null,
scopeEpoch: number,
sectionsKey: string,
): ResourceLayoutWriteIntent | null {
const tail = queue[queue.length - 1];
if (!tail || tail === activeIntent) {
return null;
}
if (tail.kind !== 'resources') {
return null;
}
if (tail.scopeEpoch !== scopeEpoch) {
return null;
}
const rederive = tail.rederive;
if (!rederive || rederive.kind !== 'organize') {
return null;
}
return organizeSectionsKey(rederive.sections) === sectionsKey ? tail : null;
}
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,
/**
* 「整理画布」的目标栏目集合。给了(含 `null` = 全部栏目)就在重算前把这些栏目里的坐标
* **连手动标记一起丢掉**,重算结果一律落成自动坐标;没给的栏目**逐值不动、连自动坐标也不
* 重排**(详见下面的保留判据)。判定按 `normalizeResourceCanvasPosition` 归并后的现行分区
* 做,磁盘上仍是旧栏目取值的坐标不会因为 `section` 字面量不同而漏出整理范围。
*/
organizeSections:
readonly ProjectResourceCanvasCategory[] | null | undefined = undefined,
) {
// `undefined` = 不是整理;`null` = 整理全部栏目;否则按集合判定。
const organizedSections =
organizeSections === undefined
? undefined
: organizeSections === null
? null
: new Set(organizeSections);
const categoryByResourceId = new Map(
resources.map((resource) => [resource.id, resource.category]),
);
const dropPosition = (
position: ProjectResourceCanvasLayout['positions'][number],
) => {
if (organizedSections === undefined) {
return false;
}
const normalized = normalizeResourceCanvasPosition(
position,
categoryByResourceId,
);
if (!normalized) {
return false;
}
return (
organizedSections === null ||
organizedSections.has(normalized.position.section)
);
};
if (policy === 'preserve' && organizeSections === undefined) {
const reconciled = reconcileResourceCanvasLayout(
source,
resources,
topology,
);
return layoutCoordinatesAreSafe(reconciled.layout)
? reconciled
: { layout: source, changed: false };
}
/**
* 保留判据按「这一次重算的范围」分两种口径:
* - **整理**(`organizeSections` 已给):只丢目标栏目的坐标,栏外坐标逐值保留,**包括自动
* 坐标**。自动坐标不是「重算一次必然一样」的派生值:真实项目里它可能来自更早版本的排布,
* 或素材被删、被拖走后留下的既有位置;顺手把它们丢给重算,就等于「整理当前栏目」时把别的
* 栏目重新规整成规范槽位,与「其他栏目不变」相反。
* - **全局重算**(`rederive`:关系图首次就绪那一次):丢全部自动坐标、手动卡原地不动,
* 由重算把自动槽位补齐。
*/
const keepsPosition = (
position: ProjectResourceCanvasLayout['positions'][number],
) =>
organizeSections !== undefined
? !dropPosition(position)
: policy === 'preserve' || position.manuallyPlaced;
const nextSource = {
...source,
positions: source.positions.filter(keepsPosition),
};
const reconciled = reconcileResourceCanvasLayout(
nextSource,
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,
organizeSections?: readonly ProjectResourceCanvasCategory[] | null,
) => {
const scope = scopeRef.current;
if (scope.epoch !== scopeEpoch) {
return null;
}
let next = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
policy,
topologyRef.current,
organizeSections,
).layout;
for (const intent of writeQueueRef.current) {
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
next = applyResourceCanvasPositionWrites(next, intent.positions);
}
}
applyLayout(next);
return next;
},
[applyLayout],
);
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
const scope = scopeRef.current;
if (scope.epoch !== scopeEpoch) {
return;
}
const signature = resourceSignatureRef.current;
/**
* 队列里可能同时排着两笔重算(显式整理与关系图首次就绪各占一个槽):资源签名是作用域级
* 的事实,排队中的每一笔都要跟上,否则先落盘的那笔会把后一笔的签名留成过期值、凭空多排
* 一次资源同步。
*/
const queued = writeQueueRef.current.filter(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scopeEpoch &&
intent !== activeWriteIntentRef.current,
);
if (queued.length > 0) {
for (const intent of queued) {
if (intent.resourceSignature !== signature) {
intent.resourceSignature = signature;
intent.conflictRetries = 0;
} else {
intent.conflictRetries = Math.min(
intent.conflictRetries,
conflictRetries,
);
}
}
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch,
resourceSignature: signature,
conflictRetries,
rederive: null,
});
}
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 organizeSections =
intent.kind === 'resources' && intent.rederive?.kind === 'organize'
? intent.rederive.sections
: undefined;
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,
organizeSections,
);
/**
* 坐标真的没变就不落盘:`changed` 门对整理/自动同步是老口径,这一条补给它的是手动
* 一笔(多选拖动或撤销恢复)。判据含 `manuallyPlaced`——撤销整理恢复的正是"坐标相同、
* 但必须从自动标记回到手动标记"的那一批卡,那种情况必须照写。
*/
const manualCandidate =
intent.kind === 'manual'
? applyResourceCanvasPositionWrites(reconciled.layout, intent.positions)
: null;
const manualWritesAnything =
manualCandidate !== null &&
!automaticCoordinatesMatch(reconciled.layout, manualCandidate);
if (
(intent.kind === 'resources' && !reconciled.changed) ||
(intent.kind === 'manual' && !manualWritesAnything)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
const candidate = manualCandidate ?? reconciled.layout;
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
persistedLayoutRef.current = candidate;
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch, writePolicy, organizeSections);
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, organizeSections);
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,
organizeSections,
);
})
.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,
organizeSections,
);
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]);
/**
* 手动坐标的批量写入口:多选拖动与撤销/重做都只调用一次,整批卡排成**一笔** CAS。
*
* 队列里同一张卡(`resourceId + section`)的重复写入就地覆盖——最后落点赢,同一次操作
* 里的中间态不需要逐笔排队;在途那笔之后的新写入仍排在它后面(沿用既有顺序语义)。
* 乐观视图按同一批写入立即更新,失败/冲突仍走既有 `notice` 与恢复分支。
*/
const commitPositions = useCallback(
(writes: readonly ResourceCanvasPositionWrite[]) => {
const scope = scopeRef.current;
if (writes.length === 0) {
return;
}
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 !== activeWriteIntentRef.current,
);
if (queuedIntent) {
mergeResourceCanvasPositionWrites(queuedIntent.positions, writes);
} else {
writeQueueRef.current.push({
kind: 'manual',
scopeEpoch: scope.epoch,
positions: [...writes],
});
}
applyLayout(applyResourceCanvasPositionWrites(layoutRef.current, writes));
setSaving(true);
pumpWritesRef.current();
},
[applyLayout, initializationReady, scopeKey],
);
/** 单卡手动落点的既有入口:等价于一笔只含这张卡、且标记手动摆放的批量写入。 */
const commitPosition = useCallback(
(
resourceId: string,
section: ProjectResourceCanvasCategory,
x: number,
y: number,
) => commitPositions([{ resourceId, section, x, y, manuallyPlaced: true }]),
[commitPositions],
);
/**
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
* 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?.kind === 'automatic' &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
queued.conflictRetries = 0;
queued.rederive = { kind: 'automatic' };
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch: scope.epoch,
resourceSignature: resourceSignatureRef.current,
conflictRetries: 0,
rederive: { kind: 'automatic' },
});
}
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
rebuildOptimisticLayout(scope.epoch, 'rederive');
setSaving(true);
pumpWritesRef.current();
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
/**
* 显式「整理画布」:重排给定栏目内的**全部**素材(含手动摆放过的卡),结果一律落成自动
* 坐标;`sections` 为 `null` 表示「所有资源」页——当前项目全部可展示素材。
*
* 与自动同步、关系图首次就绪共用同一条写队列与 CAS 写链,只是重算前的取舍不同;可见反馈
* 继续由既有 `notice` / `saving` 承担。历史快照与撤销由调用方(工作台)在按下时先记一笔,
* 本 hook 不新增业务状态。
*
* 排队粒度是**作用范围**:不同 `sections` 各占一个队列槽、按按压顺序先后落盘(栏目 A 的
* 整理不会被随后按下的栏目 B 改写掉),同一个 `sections` 才就地合并成一笔。
*
* 返回 `false` 表示这一按在画面上什么都不会变(已经整齐、也没有需要转成自动的手动标记):
* 既不排队写、也不给调用方记历史的理由——否则"连按两下"会压进一条什么都不会做的撤销点,
* 用户按一次撤销看起来毫无反应。
*/
const organizeNow = useCallback(
(sections: readonly ProjectResourceCanvasCategory[] | null) => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return false;
}
if (sections !== null && sections.length === 0) {
return false;
}
/**
* 「这一按到底会不会改变布局」按落盘口径判,而不是看乐观视图:
*
* - 主判据:整理结果 vs 已落盘布局(含手动标记)。与落盘那一步同源,两处判据不再打架。
* - 补判据:目标栏目里还有排队中/在途的手动落点。这些落点会先落盘、随后被这次整理
* 覆盖掉(整理丢的就是目标栏目的**全部**坐标,含手动),所以它本身就是一次真实变化。
* 少了这一条,「刚拖完就按整理」会判成"什么都没变":用户的点击被静默吞掉,接下来
* 落盘的手动结果反客为主。基准取乐观视图同样不行——那里叠着这批落点,等于自己抵消。
*/
const organized = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
'rederive',
topologyRef.current,
sections,
);
const hasPendingManualDropInScope = writeQueueRef.current.some(
(intent) =>
intent.kind === 'manual' &&
intent.scopeEpoch === scope.epoch &&
intent.positions.some(
(write) => sections === null || sections.includes(write.section),
),
);
if (!organized.changed && !hasPendingManualDropInScope) {
return false;
}
// 乐观视图按同一套重算落到画布上;排队中的手动落点仍在它之后落盘。
if (!rebuildOptimisticLayout(scope.epoch, 'rederive', sections)) {
return false;
}
/**
* 只在**队尾且同作用范围**时才就地合并(判据与理由见 `queuedOrganizeIntentAtTail`):
* 不同栏目各占一个队列槽、按按压顺序先后落盘,谁也不覆盖谁;命中时只刷新签名与重试
* 计数,不重排队列,先按下的那一笔仍然先落盘。
*/
const queued = queuedOrganizeIntentAtTail(
writeQueueRef.current,
activeWriteIntentRef.current,
scope.epoch,
organizeSectionsKey(sections),
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
queued.conflictRetries = 0;
queued.rederive = { kind: 'organize', sections };
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch: scope.epoch,
resourceSignature: resourceSignatureRef.current,
conflictRetries: 0,
rederive: { kind: 'organize', sections },
});
}
// 乐观视图已经在上面的重算里落到画布上(筛选、可见性都不参与,重算的是全量资源)。
setSaving(true);
pumpWritesRef.current();
return true;
},
[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,
commitPositions,
rederiveNow,
organizeNow,
};
}
/**
* 合并同一张卡的重复手动写入:`resourceId + section` 是队列内的合并键,后来的写入覆盖先前
* 的坐标与手动标记。不同卡各自保留,因此一次多选拖动始终只是**一笔**待写。
*/
function mergeResourceCanvasPositionWrites(
target: ResourceCanvasPositionWrite[],
writes: readonly ResourceCanvasPositionWrite[],
) {
for (const write of writes) {
const index = target.findIndex(
(entry) =>
entry.resourceId === write.resourceId &&
entry.section === write.section,
);
if (index >= 0) {
target[index] = write;
} else {
target.push(write);
}
}
}