fix(资源画布): 拖拽写入不再触发其它自动卡重派生补位

- useProjectResourceCanvasLayout 新增 AutomaticPositionPolicy(rederive / preserve),取代原先裸布尔 rederiveAutomaticPositions 传参
- 拖拽(manual 意图)写链固定用 preserve:不再丢弃其它自动坐标,其余自动卡原地不动,也不再补位到被拖走的空位
- 资源集合与拓扑身份签名变化仍按 PRD 用 rederive 重派生,关系图就绪后的深度修正不受影响
- 新增 automaticCoordinatesMatch(忽略数组顺序的坐标比较):手动写入后只在坐标真的变化或有新资源需要落位时才继续排一次资源同步,避免只由排序归一触发的第二次 CAS 写入再次把其它卡补位
- 写入后的 updated / conflict 分支统一按 writePolicy 计算 reconciledAfterWrite,并据此判定是否需要资源同步
- rebuildOptimisticLayout 改为接收策略参数,手动写入后的会话布局同样保持其它自动卡不动
- 新增用例:拖走一张后其余自动卡在所有写入里坐标逐项不变、视图里也不补位;变异验证(把拖拽路径改回 rederive)后该断言失败
- 副作用(已确认接受):把卡正好丢在另一张卡上时不再自动挪开它,直到下一次资源集合或拓扑变化触发重派生
- 门禁:AGC 1178 passed / 4 skipped / 0 failed,共享组件 1385 passed,typecheck exit 0,编码检查 4374 通过
This commit is contained in:
2026-09-11 10:27:32 +08:00
parent 921d86ed44
commit 1da7dc62a0
2 changed files with 195 additions and 34 deletions
@@ -54,6 +54,28 @@ 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,
@@ -168,6 +190,33 @@ function positionsEqual(
);
}
/**
* 坐标内容比较(忽略数组顺序)。手动写入会把被拖的卡固定在用户放下的坐标,只由排序归一
* 带来的顺序变化不构成"还要再落盘一次"的变化;只有坐标真的动了、或有新资源需要落位时,
* 才继续排一次资源同步。手动卡被拖走后由它触发的补位正是从这条同步路径漏进来的。
*/
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) =>
@@ -179,10 +228,10 @@ function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
function reconcileLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
rederiveAutomaticPositions: boolean,
policy: AutomaticPositionPolicy,
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!rederiveAutomaticPositions) {
if (policy === 'preserve') {
const reconciled = reconcileResourceCanvasLayout(
source,
resources,
@@ -241,8 +290,12 @@ export function useProjectResourceCanvasLayout({
const fallback = useMemo(() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady || renderFallbackWhileBlocked
? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology)
.layout
? reconcileLayout(
empty,
resources,
automaticPositionPolicy(rederiveAutomaticPositions),
topology,
).layout
: empty;
}, [
initializationReady,
@@ -297,7 +350,7 @@ export function useProjectResourceCanvasLayout({
}, []);
const rebuildOptimisticLayout = useCallback(
(scopeEpoch: number) => {
(scopeEpoch: number, policy: AutomaticPositionPolicy) => {
const scope = scopeRef.current;
if (scope.epoch !== scopeEpoch) {
return;
@@ -305,7 +358,7 @@ export function useProjectResourceCanvasLayout({
let next = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
policy,
topologyRef.current,
).layout;
for (const intent of writeQueueRef.current) {
@@ -321,7 +374,7 @@ export function useProjectResourceCanvasLayout({
}
applyLayout(next);
},
[applyLayout, rederiveAutomaticPositions],
[applyLayout],
);
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
@@ -382,15 +435,19 @@ export function useProjectResourceCanvasLayout({
return;
}
const writePolicy =
intent.kind === 'manual'
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
: automaticPositionPolicy(rederiveAutomaticPositions);
const reconciled = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
writePolicy,
topologyRef.current,
);
if (intent.kind === 'resources' && !reconciled.changed) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
rebuildOptimisticLayout(scope.epoch, writePolicy);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
@@ -403,7 +460,7 @@ export function useProjectResourceCanvasLayout({
)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
rebuildOptimisticLayout(scope.epoch, writePolicy);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
@@ -421,7 +478,7 @@ export function useProjectResourceCanvasLayout({
if (!invoke) {
persistedLayoutRef.current = candidate;
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
rebuildOptimisticLayout(scope.epoch, writePolicy);
const hasQueuedWrite = writeQueueRef.current.some(
(queued) => queued.scopeEpoch === scope.epoch,
);
@@ -440,7 +497,7 @@ export function useProjectResourceCanvasLayout({
!layoutCoordinatesAreSafe(candidate)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
rebuildOptimisticLayout(scope.epoch, writePolicy);
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
}
@@ -485,19 +542,25 @@ export function useProjectResourceCanvasLayout({
}
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 (
reconcileLayout(
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
if (needsResourceSync) {
enqueueResourceSyncRef.current(currentScope.epoch);
}
} else {
@@ -511,12 +574,6 @@ export function useProjectResourceCanvasLayout({
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 =
@@ -534,7 +591,7 @@ export function useProjectResourceCanvasLayout({
setNotice('布局已在其他窗口更新');
}
}
rebuildOptimisticLayout(currentScope.epoch);
rebuildOptimisticLayout(currentScope.epoch, writePolicy);
})
.catch(() => {
const currentScope = scopeRef.current;
@@ -542,7 +599,7 @@ export function useProjectResourceCanvasLayout({
return;
}
removeWriteIntent(intent);
rebuildOptimisticLayout(currentScope.epoch);
rebuildOptimisticLayout(currentScope.epoch, writePolicy);
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
}
@@ -614,7 +671,7 @@ export function useProjectResourceCanvasLayout({
const initialFallback = reconcileLayout(
emptyLayout,
resourcesRef.current,
rederiveAutomaticPositions,
automaticPositionPolicy(rederiveAutomaticPositions),
topologyRef.current,
).layout;
persistedLayoutRef.current = initialFallback;
@@ -657,13 +714,16 @@ export function useProjectResourceCanvasLayout({
reconcileLayout(
loaded,
resourcesRef.current,
rederiveAutomaticPositions,
automaticPositionPolicy(rederiveAutomaticPositions),
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(epoch);
}
rebuildOptimisticLayout(epoch);
rebuildOptimisticLayout(
epoch,
automaticPositionPolicy(rederiveAutomaticPositions),
);
pumpWritesRef.current();
})
.catch(() => {
@@ -673,7 +733,10 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current = initialFallback;
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
rebuildOptimisticLayout(epoch);
rebuildOptimisticLayout(
epoch,
automaticPositionPolicy(rederiveAutomaticPositions),
);
setNotice('布局读取失败,已使用当前会话布局');
pumpWritesRef.current();
});
@@ -703,7 +766,7 @@ export function useProjectResourceCanvasLayout({
const reconciledCurrent = reconcileLayout(
layoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
automaticPositionPolicy(rederiveAutomaticPositions),
topologyRef.current,
);
applyLayout(reconciledCurrent.layout);
@@ -712,7 +775,7 @@ export function useProjectResourceCanvasLayout({
reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
automaticPositionPolicy(rederiveAutomaticPositions),
topologyRef.current,
).changed
) {
@@ -1079,4 +1079,102 @@ describe('useProjectResourceCanvasLayout', () => {
),
).toBe(true);
});
it('leaves other automatic cards in place when one card is dragged', async () => {
// 三张同深度自动卡按当前默认口径(层内 2 列网格)落盘:a(0,0) b(228,0) c(0,168)。
const resources = [
resource('resource-a'),
resource('resource-b'),
resource('resource-c'),
];
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 3, [
automaticPosition('resource-a', 0, 0),
automaticPosition('resource-b', 228, 0),
automaticPosition('resource-c', 0, 168),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
rederiveAutomaticPositions: true,
}),
);
await waitFor(() =>
expect(result.current.layout.positions).toHaveLength(3),
);
act(() =>
result.current.commitPosition('resource-a', 'document', 900, 640),
);
await waitFor(() =>
expect(
result.current.layout.positions.find(
({ resourceId }) => resourceId === 'resource-a',
),
).toMatchObject({ x: 900, y: 640, manuallyPlaced: true }),
);
// 拖动只改被拖的那张:其余自动卡在所有写入里坐标逐项不变,也不会补位到空出的格子。
expect(updates.length).toBeGreaterThan(0);
for (const write of updates) {
const written = new Map(
write.positions.map((candidate) => [candidate.resourceId, candidate]),
);
expect(written.size).toBe(3);
expect(written.get('resource-a')).toMatchObject({
x: 900,
y: 640,
manuallyPlaced: true,
});
expect(written.get('resource-b')).toMatchObject({
x: 228,
y: 0,
manuallyPlaced: false,
});
expect(written.get('resource-c')).toMatchObject({
x: 0,
y: 168,
manuallyPlaced: false,
});
}
expect(
result.current.layout.positions.find(
({ resourceId }) => resourceId === 'resource-b',
),
).toMatchObject({ x: 228, y: 0 });
expect(
result.current.layout.positions.find(
({ resourceId }) => resourceId === 'resource-c',
),
).toMatchObject({ x: 0, y: 168 });
});
});