532c6d51fd
## 背景 本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。 当前远端比较基线: - base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d` - head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472` - 相对 base:28 commits、33 files(`+3746 / -1333`) ## 主要改动 ### 资源管理分页自由画布 - 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。 - `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。 - 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。 - 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。 - 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。 - 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。 ### 图片持续精修与生成事务 - 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。 - 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。 - 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。 - 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。 - 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。 ### 合同与文档 - `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。 - 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。 - 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。 ## 验证 CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功: - [x] Repository checks - [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests) - [x] Backend tests - [x] Native shell tests 另有本地定向检查: - [x] `npm run agc:typecheck` - [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归 - [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试 - [x] `cargo fmt --check` - [x] `npm run check:encoding` - [x] `git diff --check` ## 评审 provenance - review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。 - review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。 - 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。 - 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。 ## 影响范围 - 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。 - 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。 - 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
826 lines
24 KiB
TypeScript
826 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import type {
|
|
ProjectResourceCanvasLayout,
|
|
ProjectResourceCanvasLayoutMode,
|
|
ProjectResourceCanvasSection,
|
|
UpdateProjectResourceCanvasLayoutResult,
|
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
import {
|
|
isSafeProjectResourceCanvasCoordinate,
|
|
isSafeProjectResourceCanvasLayoutRevision,
|
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
import {
|
|
createEmptyResourceCanvasLayout,
|
|
moveResourceCanvasPosition,
|
|
reconcileResourceCanvasLayout,
|
|
type ResourceCanvasItem,
|
|
type ResourceCanvasLayoutTopology,
|
|
} from './resourceCanvasLayoutModel';
|
|
|
|
type LayoutNotice =
|
|
| ''
|
|
| '布局已保存'
|
|
| '布局读取失败,已使用当前会话布局'
|
|
| '布局保存失败,已保留当前会话布局'
|
|
| '布局保存失败,已恢复上次布局'
|
|
| '布局已在其他窗口更新';
|
|
|
|
type LayoutScope = {
|
|
key: string;
|
|
epoch: number;
|
|
projectPath: string;
|
|
projectId: string;
|
|
mode: ProjectResourceCanvasLayoutMode;
|
|
};
|
|
|
|
type ManualLayoutWriteIntent = {
|
|
kind: 'manual';
|
|
scopeEpoch: number;
|
|
resourceId: string;
|
|
section: ProjectResourceCanvasSection;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
type ResourceLayoutWriteIntent = {
|
|
kind: 'resources';
|
|
scopeEpoch: number;
|
|
resourceSignature: string;
|
|
conflictRetries: number;
|
|
};
|
|
|
|
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
|
|
|
|
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
|
|
|
|
function createScopeKey(
|
|
projectPath: string,
|
|
projectId: string,
|
|
mode: ProjectResourceCanvasLayoutMode,
|
|
) {
|
|
return JSON.stringify([projectPath, projectId, mode]);
|
|
}
|
|
|
|
export function createResourceSignature(resources: ResourceCanvasItem[]) {
|
|
return resources
|
|
.map((resource) =>
|
|
JSON.stringify([
|
|
resource.id,
|
|
resource.category,
|
|
resource.subtype,
|
|
resource.label,
|
|
resource.dependencyDepth,
|
|
resource.mediaType,
|
|
resource.cardSize?.width ?? null,
|
|
resource.cardSize?.height ?? null,
|
|
]),
|
|
)
|
|
.sort()
|
|
.join('\n');
|
|
}
|
|
|
|
/**
|
|
* A bounded, identity-only input to dependency automatic placement. Display
|
|
* labels deliberately stay out: the resource list's coordination signature
|
|
* already covers them, while topology changes must be detectable even when
|
|
* every depth and resource count is unchanged.
|
|
*/
|
|
export function createResourceTopologySignature(
|
|
topology: ResourceCanvasLayoutTopology | undefined,
|
|
) {
|
|
if (!topology) {
|
|
return '';
|
|
}
|
|
const entries = [
|
|
...topology.referenceEdges
|
|
.map(
|
|
(edge) =>
|
|
`r:${JSON.stringify([edge.sourceResourceId, edge.targetResourceId])}`,
|
|
)
|
|
.sort(compareStableText),
|
|
...topology.taskFlows
|
|
.map(
|
|
(flow) =>
|
|
`f:${JSON.stringify([
|
|
[...new Set(flow.sourceResourceIds)].sort(compareStableText),
|
|
[...new Set(flow.targetResourceIds)].sort(compareStableText),
|
|
])}`,
|
|
)
|
|
.sort(compareStableText),
|
|
];
|
|
return stableTopologyDigest([...new Set(entries)].sort(compareStableText));
|
|
}
|
|
|
|
function compareStableText(left: string, right: string) {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* Keeps the sidecar coordination input fixed-size even for a large graph.
|
|
* The canonical entries retain all stable endpoint and flow-member identities
|
|
* while being digested; labels and browser geometry never participate.
|
|
*/
|
|
function stableTopologyDigest(entries: readonly string[]) {
|
|
let forward = 0x811c9dc5;
|
|
let reverse = 0x9e3779b9;
|
|
for (const entry of entries) {
|
|
for (let index = 0; index < entry.length; index += 1) {
|
|
forward = Math.imul(forward ^ entry.charCodeAt(index), 0x01000193);
|
|
reverse = Math.imul(
|
|
reverse ^ entry.charCodeAt(entry.length - index - 1),
|
|
0x85ebca6b,
|
|
);
|
|
}
|
|
forward = Math.imul(forward ^ 10, 0x01000193);
|
|
reverse = Math.imul(reverse ^ 10, 0x85ebca6b);
|
|
}
|
|
return `${(entries.length >>> 0).toString(16).padStart(8, '0')}:${(
|
|
forward >>> 0
|
|
)
|
|
.toString(16)
|
|
.padStart(8, '0')}:${(reverse >>> 0).toString(16).padStart(8, '0')}`;
|
|
}
|
|
|
|
function layoutMatchesScope(
|
|
layout: ProjectResourceCanvasLayout,
|
|
scope: LayoutScope,
|
|
) {
|
|
return layout.projectId === scope.projectId && layout.mode === scope.mode;
|
|
}
|
|
|
|
function positionsEqual(
|
|
left: ProjectResourceCanvasLayout['positions'],
|
|
right: ProjectResourceCanvasLayout['positions'],
|
|
) {
|
|
return (
|
|
left.length === right.length &&
|
|
left.every((position, index) => {
|
|
const other = right[index];
|
|
return (
|
|
other?.resourceId === position.resourceId &&
|
|
other.section === position.section &&
|
|
other.x === position.x &&
|
|
other.y === position.y &&
|
|
other.manuallyPlaced === position.manuallyPlaced
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
|
|
return layout.positions.every(
|
|
(position) =>
|
|
isSafeProjectResourceCanvasCoordinate(position.x) &&
|
|
isSafeProjectResourceCanvasCoordinate(position.y),
|
|
);
|
|
}
|
|
|
|
function reconcileLayout(
|
|
source: ProjectResourceCanvasLayout,
|
|
resources: ResourceCanvasItem[],
|
|
rederiveAutomaticPositions: boolean,
|
|
topology: ResourceCanvasLayoutTopology | undefined,
|
|
) {
|
|
if (!rederiveAutomaticPositions) {
|
|
const reconciled = reconcileResourceCanvasLayout(
|
|
source,
|
|
resources,
|
|
topology,
|
|
);
|
|
return layoutCoordinatesAreSafe(reconciled.layout)
|
|
? reconciled
|
|
: { layout: source, changed: false };
|
|
}
|
|
const manualSource = {
|
|
...source,
|
|
positions: source.positions.filter((position) => position.manuallyPlaced),
|
|
};
|
|
const reconciled = reconcileResourceCanvasLayout(
|
|
manualSource,
|
|
resources,
|
|
topology,
|
|
);
|
|
const result = {
|
|
layout: reconciled.layout,
|
|
changed: !positionsEqual(source.positions, reconciled.layout.positions),
|
|
};
|
|
return layoutCoordinatesAreSafe(result.layout)
|
|
? result
|
|
: { layout: source, changed: false };
|
|
}
|
|
|
|
export function useProjectResourceCanvasLayout({
|
|
projectPath,
|
|
projectId,
|
|
mode,
|
|
resources,
|
|
topology,
|
|
initializationReady = true,
|
|
renderFallbackWhileBlocked = false,
|
|
rederiveAutomaticPositions = false,
|
|
}: {
|
|
projectPath: string;
|
|
projectId: string;
|
|
mode: ProjectResourceCanvasLayoutMode;
|
|
resources: ResourceCanvasItem[];
|
|
topology?: ResourceCanvasLayoutTopology;
|
|
initializationReady?: boolean;
|
|
renderFallbackWhileBlocked?: boolean;
|
|
rederiveAutomaticPositions?: boolean;
|
|
}) {
|
|
const topologySignature = useMemo(
|
|
() => createResourceTopologySignature(topology),
|
|
[topology],
|
|
);
|
|
const resourceSignature = useMemo(
|
|
() => [createResourceSignature(resources), topologySignature].join('\n'),
|
|
[resources, topologySignature],
|
|
);
|
|
const scopeKey = createScopeKey(projectPath, projectId, mode);
|
|
const fallback = useMemo(() => {
|
|
const empty = createEmptyResourceCanvasLayout(projectId, mode);
|
|
return initializationReady || renderFallbackWhileBlocked
|
|
? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology)
|
|
.layout
|
|
: empty;
|
|
}, [
|
|
initializationReady,
|
|
mode,
|
|
projectId,
|
|
rederiveAutomaticPositions,
|
|
renderFallbackWhileBlocked,
|
|
resources,
|
|
topology,
|
|
]);
|
|
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
|
|
const [notice, setNotice] = useState<LayoutNotice>('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [readyScopeKey, setReadyScopeKey] = useState<string | null>(null);
|
|
const mountedRef = useRef(true);
|
|
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
|
|
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
|
|
const resourcesRef = useRef(resources);
|
|
const topologyRef = useRef(topology);
|
|
const resourceSignatureRef = useRef(resourceSignature);
|
|
const initializedScopeEpochRef = useRef<number | null>(null);
|
|
const scopeRef = useRef<LayoutScope>({
|
|
key: scopeKey,
|
|
epoch: 0,
|
|
projectPath,
|
|
projectId,
|
|
mode,
|
|
});
|
|
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
|
|
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
|
|
const redragRequiredScopeEpochRef = useRef<number | null>(null);
|
|
const pumpWritesRef = useRef<() => void>(() => undefined);
|
|
const enqueueResourceSyncRef = useRef<
|
|
(scopeEpoch: number, conflictRetries?: number) => void
|
|
>(() => undefined);
|
|
|
|
resourcesRef.current = resources;
|
|
topologyRef.current = topology;
|
|
resourceSignatureRef.current = resourceSignature;
|
|
|
|
const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => {
|
|
layoutRef.current = next;
|
|
if (mountedRef.current) {
|
|
setLayout(next);
|
|
}
|
|
}, []);
|
|
|
|
const removeWriteIntent = useCallback((intent: LayoutWriteIntent) => {
|
|
writeQueueRef.current = writeQueueRef.current.filter(
|
|
(queued) => queued !== intent,
|
|
);
|
|
}, []);
|
|
|
|
const rebuildOptimisticLayout = useCallback(
|
|
(scopeEpoch: number) => {
|
|
const scope = scopeRef.current;
|
|
if (scope.epoch !== scopeEpoch) {
|
|
return;
|
|
}
|
|
let next = reconcileLayout(
|
|
persistedLayoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
).layout;
|
|
for (const intent of writeQueueRef.current) {
|
|
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
|
|
next = moveResourceCanvasPosition(
|
|
next,
|
|
intent.resourceId,
|
|
intent.section,
|
|
intent.x,
|
|
intent.y,
|
|
);
|
|
}
|
|
}
|
|
applyLayout(next);
|
|
},
|
|
[applyLayout, rederiveAutomaticPositions],
|
|
);
|
|
|
|
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
|
|
const scope = scopeRef.current;
|
|
if (scope.epoch !== scopeEpoch) {
|
|
return;
|
|
}
|
|
const signature = resourceSignatureRef.current;
|
|
const queued = writeQueueRef.current.find(
|
|
(intent): intent is ResourceLayoutWriteIntent =>
|
|
intent.kind === 'resources' &&
|
|
intent.scopeEpoch === scopeEpoch &&
|
|
intent !== activeWriteIntentRef.current,
|
|
);
|
|
if (queued) {
|
|
if (queued.resourceSignature !== signature) {
|
|
queued.resourceSignature = signature;
|
|
queued.conflictRetries = 0;
|
|
} else {
|
|
queued.conflictRetries = Math.min(
|
|
queued.conflictRetries,
|
|
conflictRetries,
|
|
);
|
|
}
|
|
} else {
|
|
writeQueueRef.current.push({
|
|
kind: 'resources',
|
|
scopeEpoch,
|
|
resourceSignature: signature,
|
|
conflictRetries,
|
|
});
|
|
}
|
|
if (mountedRef.current) {
|
|
setSaving(true);
|
|
}
|
|
pumpWritesRef.current();
|
|
};
|
|
|
|
pumpWritesRef.current = () => {
|
|
if (activeWriteIntentRef.current) {
|
|
return;
|
|
}
|
|
const scope = scopeRef.current;
|
|
writeQueueRef.current = writeQueueRef.current.filter(
|
|
(intent) => intent.scopeEpoch === scope.epoch,
|
|
);
|
|
const intent = writeQueueRef.current[0];
|
|
if (!intent) {
|
|
if (mountedRef.current) {
|
|
setSaving(false);
|
|
}
|
|
return;
|
|
}
|
|
if (initializedScopeEpochRef.current !== scope.epoch) {
|
|
if (mountedRef.current) {
|
|
setSaving(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const reconciled = reconcileLayout(
|
|
persistedLayoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
);
|
|
if (intent.kind === 'resources' && !reconciled.changed) {
|
|
removeWriteIntent(intent);
|
|
rebuildOptimisticLayout(scope.epoch);
|
|
void Promise.resolve().then(() => pumpWritesRef.current());
|
|
return;
|
|
}
|
|
if (
|
|
intent.kind === 'manual' &&
|
|
!reconciled.layout.positions.some(
|
|
(position) =>
|
|
position.resourceId === intent.resourceId &&
|
|
position.section === intent.section,
|
|
)
|
|
) {
|
|
removeWriteIntent(intent);
|
|
rebuildOptimisticLayout(scope.epoch);
|
|
void Promise.resolve().then(() => pumpWritesRef.current());
|
|
return;
|
|
}
|
|
const candidate =
|
|
intent.kind === 'manual'
|
|
? moveResourceCanvasPosition(
|
|
reconciled.layout,
|
|
intent.resourceId,
|
|
intent.section,
|
|
intent.x,
|
|
intent.y,
|
|
)
|
|
: reconciled.layout;
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke) {
|
|
persistedLayoutRef.current = candidate;
|
|
removeWriteIntent(intent);
|
|
rebuildOptimisticLayout(scope.epoch);
|
|
const hasQueuedWrite = writeQueueRef.current.some(
|
|
(queued) => queued.scopeEpoch === scope.epoch,
|
|
);
|
|
if (mountedRef.current) {
|
|
setSaving(hasQueuedWrite);
|
|
}
|
|
if (hasQueuedWrite) {
|
|
void Promise.resolve().then(() => pumpWritesRef.current());
|
|
}
|
|
return;
|
|
}
|
|
|
|
const expectedRevision = persistedLayoutRef.current.revision;
|
|
if (
|
|
!isSafeProjectResourceCanvasLayoutRevision(expectedRevision) ||
|
|
!layoutCoordinatesAreSafe(candidate)
|
|
) {
|
|
removeWriteIntent(intent);
|
|
rebuildOptimisticLayout(scope.epoch);
|
|
if (intent.kind === 'manual') {
|
|
redragRequiredScopeEpochRef.current = null;
|
|
}
|
|
setNotice(
|
|
intent.kind === 'resources'
|
|
? '布局保存失败,已保留当前会话布局'
|
|
: '布局保存失败,已恢复上次布局',
|
|
);
|
|
void Promise.resolve().then(() => pumpWritesRef.current());
|
|
return;
|
|
}
|
|
|
|
activeWriteIntentRef.current = intent;
|
|
if (mountedRef.current) {
|
|
setSaving(true);
|
|
}
|
|
void invoke<UpdateProjectResourceCanvasLayoutResult>(
|
|
'update_local_project_resource_canvas_layout',
|
|
{
|
|
projectPath: scope.projectPath,
|
|
expectedProjectId: scope.projectId,
|
|
mode: scope.mode,
|
|
expectedRevision,
|
|
positions: candidate.positions,
|
|
},
|
|
)
|
|
.then((result) => {
|
|
const currentScope = scopeRef.current;
|
|
if (currentScope.epoch !== intent.scopeEpoch) {
|
|
return;
|
|
}
|
|
if (
|
|
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) ||
|
|
!layoutCoordinatesAreSafe(result.layout)
|
|
) {
|
|
throw new Error(
|
|
'layout response revision or coordinates are invalid',
|
|
);
|
|
}
|
|
if (!layoutMatchesScope(result.layout, currentScope)) {
|
|
throw new Error('layout response scope mismatch');
|
|
}
|
|
persistedLayoutRef.current = result.layout;
|
|
removeWriteIntent(intent);
|
|
if (result.status === 'updated') {
|
|
if (intent.kind === 'manual') {
|
|
redragRequiredScopeEpochRef.current = null;
|
|
setNotice('布局已保存');
|
|
}
|
|
if (
|
|
reconcileLayout(
|
|
result.layout,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
).changed
|
|
) {
|
|
enqueueResourceSyncRef.current(currentScope.epoch);
|
|
}
|
|
} else {
|
|
const discardedQueuedManualIntent = writeQueueRef.current.some(
|
|
(queued) =>
|
|
queued.scopeEpoch === currentScope.epoch &&
|
|
queued.kind === 'manual',
|
|
);
|
|
writeQueueRef.current = writeQueueRef.current.filter(
|
|
(queued) =>
|
|
queued.scopeEpoch !== currentScope.epoch ||
|
|
queued.kind !== 'manual',
|
|
);
|
|
const needsResourceSync = reconcileLayout(
|
|
result.layout,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
).changed;
|
|
const nextRetry =
|
|
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
|
|
const willRetryResourceSync =
|
|
needsResourceSync &&
|
|
nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES;
|
|
const redragRequired =
|
|
intent.kind === 'manual' || discardedQueuedManualIntent;
|
|
if (redragRequired) {
|
|
redragRequiredScopeEpochRef.current = currentScope.epoch;
|
|
}
|
|
if (willRetryResourceSync) {
|
|
enqueueResourceSyncRef.current(currentScope.epoch, nextRetry);
|
|
}
|
|
if (redragRequired || !willRetryResourceSync) {
|
|
setNotice('布局已在其他窗口更新');
|
|
}
|
|
}
|
|
rebuildOptimisticLayout(currentScope.epoch);
|
|
})
|
|
.catch(() => {
|
|
const currentScope = scopeRef.current;
|
|
if (currentScope.epoch !== intent.scopeEpoch) {
|
|
return;
|
|
}
|
|
removeWriteIntent(intent);
|
|
rebuildOptimisticLayout(currentScope.epoch);
|
|
if (intent.kind === 'manual') {
|
|
redragRequiredScopeEpochRef.current = null;
|
|
}
|
|
if (
|
|
intent.kind !== 'resources' ||
|
|
redragRequiredScopeEpochRef.current !== currentScope.epoch
|
|
) {
|
|
setNotice(
|
|
intent.kind === 'resources'
|
|
? '布局保存失败,已保留当前会话布局'
|
|
: '布局保存失败,已恢复上次布局',
|
|
);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (activeWriteIntentRef.current === intent) {
|
|
activeWriteIntentRef.current = null;
|
|
}
|
|
const currentScope = scopeRef.current;
|
|
if (mountedRef.current && currentScope.epoch === intent.scopeEpoch) {
|
|
setSaving(
|
|
writeQueueRef.current.some(
|
|
(queued) => queued.scopeEpoch === currentScope.epoch,
|
|
),
|
|
);
|
|
}
|
|
pumpWritesRef.current();
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
scopeRef.current = {
|
|
...scopeRef.current,
|
|
epoch: scopeRef.current.epoch + 1,
|
|
};
|
|
initializedScopeEpochRef.current = null;
|
|
writeQueueRef.current = [];
|
|
activeWriteIntentRef.current = null;
|
|
redragRequiredScopeEpochRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const epoch = scopeRef.current.epoch + 1;
|
|
const scope: LayoutScope = {
|
|
key: scopeKey,
|
|
epoch,
|
|
projectPath,
|
|
projectId,
|
|
mode,
|
|
};
|
|
scopeRef.current = scope;
|
|
initializedScopeEpochRef.current = null;
|
|
writeQueueRef.current = [];
|
|
activeWriteIntentRef.current = null;
|
|
redragRequiredScopeEpochRef.current = null;
|
|
setReadyScopeKey(null);
|
|
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
|
|
if (!initializationReady) {
|
|
persistedLayoutRef.current = emptyLayout;
|
|
applyLayout(emptyLayout);
|
|
setNotice('');
|
|
setSaving(false);
|
|
return undefined;
|
|
}
|
|
const initialFallback = reconcileLayout(
|
|
emptyLayout,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
).layout;
|
|
persistedLayoutRef.current = initialFallback;
|
|
applyLayout(initialFallback);
|
|
setNotice('');
|
|
setSaving(false);
|
|
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke) {
|
|
initializedScopeEpochRef.current = epoch;
|
|
setReadyScopeKey(scopeKey);
|
|
pumpWritesRef.current();
|
|
return undefined;
|
|
}
|
|
|
|
let cancelled = false;
|
|
void invoke<ProjectResourceCanvasLayout>(
|
|
'read_local_project_resource_canvas_layout',
|
|
{ projectPath, mode },
|
|
)
|
|
.then((loaded) => {
|
|
if (cancelled || scopeRef.current.epoch !== epoch) {
|
|
return;
|
|
}
|
|
if (
|
|
!isSafeProjectResourceCanvasLayoutRevision(loaded.revision) ||
|
|
!layoutCoordinatesAreSafe(loaded)
|
|
) {
|
|
throw new Error(
|
|
'layout response revision or coordinates are invalid',
|
|
);
|
|
}
|
|
if (!layoutMatchesScope(loaded, scope)) {
|
|
return;
|
|
}
|
|
persistedLayoutRef.current = loaded;
|
|
initializedScopeEpochRef.current = epoch;
|
|
setReadyScopeKey(scopeKey);
|
|
if (
|
|
reconcileLayout(
|
|
loaded,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
).changed
|
|
) {
|
|
enqueueResourceSyncRef.current(epoch);
|
|
}
|
|
rebuildOptimisticLayout(epoch);
|
|
pumpWritesRef.current();
|
|
})
|
|
.catch(() => {
|
|
if (cancelled || scopeRef.current.epoch !== epoch) {
|
|
return;
|
|
}
|
|
persistedLayoutRef.current = initialFallback;
|
|
initializedScopeEpochRef.current = epoch;
|
|
setReadyScopeKey(scopeKey);
|
|
rebuildOptimisticLayout(epoch);
|
|
setNotice('布局读取失败,已使用当前会话布局');
|
|
pumpWritesRef.current();
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
applyLayout,
|
|
initializationReady,
|
|
mode,
|
|
projectId,
|
|
projectPath,
|
|
rebuildOptimisticLayout,
|
|
rederiveAutomaticPositions,
|
|
scopeKey,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const scope = scopeRef.current;
|
|
if (
|
|
scope.key !== scopeKey ||
|
|
!initializationReady ||
|
|
initializedScopeEpochRef.current !== scope.epoch
|
|
) {
|
|
return;
|
|
}
|
|
const reconciledCurrent = reconcileLayout(
|
|
layoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
);
|
|
applyLayout(reconciledCurrent.layout);
|
|
if (
|
|
window.__TAURI__?.core?.invoke &&
|
|
reconcileLayout(
|
|
persistedLayoutRef.current,
|
|
resourcesRef.current,
|
|
rederiveAutomaticPositions,
|
|
topologyRef.current,
|
|
).changed
|
|
) {
|
|
enqueueResourceSyncRef.current(scope.epoch);
|
|
}
|
|
}, [
|
|
applyLayout,
|
|
initializationReady,
|
|
rederiveAutomaticPositions,
|
|
resourceSignature,
|
|
scopeKey,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!notice) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
notice === '布局已在其他窗口更新' &&
|
|
redragRequiredScopeEpochRef.current === scopeRef.current.epoch
|
|
) {
|
|
return undefined;
|
|
}
|
|
const timeout = window.setTimeout(() => setNotice(''), 2400);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [notice]);
|
|
|
|
const commitPosition = useCallback(
|
|
(
|
|
resourceId: string,
|
|
section: ProjectResourceCanvasSection,
|
|
x: number,
|
|
y: number,
|
|
) => {
|
|
const scope = scopeRef.current;
|
|
if (
|
|
scope.key !== scopeKey ||
|
|
!initializationReady ||
|
|
initializedScopeEpochRef.current !== scope.epoch
|
|
) {
|
|
return;
|
|
}
|
|
const queuedIntent = writeQueueRef.current.find(
|
|
(intent): intent is ManualLayoutWriteIntent =>
|
|
intent.kind === 'manual' &&
|
|
intent.scopeEpoch === scope.epoch &&
|
|
intent.resourceId === resourceId &&
|
|
intent.section === section &&
|
|
intent !== activeWriteIntentRef.current,
|
|
);
|
|
if (queuedIntent) {
|
|
queuedIntent.x = x;
|
|
queuedIntent.y = y;
|
|
} else {
|
|
writeQueueRef.current.push({
|
|
kind: 'manual',
|
|
scopeEpoch: scope.epoch,
|
|
resourceId,
|
|
section,
|
|
x,
|
|
y,
|
|
});
|
|
}
|
|
applyLayout(
|
|
moveResourceCanvasPosition(
|
|
layoutRef.current,
|
|
resourceId,
|
|
section,
|
|
x,
|
|
y,
|
|
),
|
|
);
|
|
setSaving(true);
|
|
pumpWritesRef.current();
|
|
},
|
|
[applyLayout, initializationReady, scopeKey],
|
|
);
|
|
|
|
const scopeMatches =
|
|
initializationReady &&
|
|
scopeRef.current.key === scopeKey &&
|
|
layout.projectId === projectId &&
|
|
layout.mode === mode;
|
|
const ready = scopeMatches && readyScopeKey === scopeKey;
|
|
const allResourcesPositioned = resources.every((resource) =>
|
|
layout.positions.some(
|
|
(position) =>
|
|
position.resourceId === resource.id &&
|
|
position.section === resource.category,
|
|
),
|
|
);
|
|
const settled =
|
|
ready &&
|
|
!saving &&
|
|
activeWriteIntentRef.current === null &&
|
|
!writeQueueRef.current.some(
|
|
(intent) => intent.scopeEpoch === scopeRef.current.epoch,
|
|
) &&
|
|
allResourcesPositioned;
|
|
|
|
return {
|
|
layout: ready ? layout : fallback,
|
|
notice,
|
|
saving,
|
|
ready,
|
|
settled,
|
|
scopeIdentity: scopeKey,
|
|
commitPosition,
|
|
};
|
|
}
|