Files
Agent-System/web/src/lib/blockSavePolicy.ts
T
weichenandCursor 9b43bd6a73 fix(web): bind editor saves to the opened doc and real PATCH
Drop test-only ETag helpers. App now calls saveControlledBlockEdit,
which PATCHes once on the bound editor docId or fail-closes. Resume
hydrate after a blocked first load, and refuse silent doc switches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 20:00:47 +00:00

86 lines
2.9 KiB
TypeScript

import type { DocumentBlock } from '../types/block';
export const STALE_BLOCK_SAVE_MESSAGE =
'保存失败:当前正文块身份已随后台刷新失效,编辑器仍保留你的修改。请勿关闭以免丢稿;刷新对照后重新打开该块再保存。';
export const HEAD_CONFLICT_SAVE_MESSAGE =
'保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。';
export const EDITOR_DOC_MISMATCH_MESSAGE =
'保存失败:当前编辑会话不属于正在查看的文档。编辑器仍保留你的修改,请先回到原文档再保存。';
export const DOC_SWITCH_WHILE_EDITING_MESSAGE =
'文档切换已取消:编辑器仍保留未保存修改。请先保存或关闭编辑器,再切换文档。';
export type BlockEditorSession = {
docId: string;
block: DocumentBlock;
};
export type SaveTargetDecision =
| { ok: true; index: number }
| { ok: false; keepEditor: true; reason: 'block_not_found'; message: string };
export type DocSwitchDecision =
| { allow: true }
| { allow: false; keepEditor: true; message: string };
/**
* Resolve the PATCH target strictly by block id.
* Matching another node by identical markdown would write the wrong slot.
*/
export function resolveSaveTargetBlock(
children: ReadonlyArray<{ id: string; content?: string }>,
updated: { id: string; content?: string },
): SaveTargetDecision {
const index = children.findIndex((child) => child.id === updated.id);
if (index === -1) {
return {
ok: false,
keepEditor: true,
reason: 'block_not_found',
message: STALE_BLOCK_SAVE_MESSAGE,
};
}
return { ok: true, index };
}
export function decideEditorDocBinding(input: {
editorDocId?: string | null;
activeDocId: string;
}): { ok: true } | { ok: false; keepEditor: true; reason: 'doc_mismatch'; message: string } {
if (!input.editorDocId || input.editorDocId !== input.activeDocId) {
return {
ok: false,
keepEditor: true,
reason: 'doc_mismatch',
message: EDITOR_DOC_MISMATCH_MESSAGE,
};
}
return { ok: true };
}
/** SlimTabBar is usually under the modal, but handleSelectDoc also comes from chat/drawer/effects. */
export function decideActiveDocSwitch(input: {
editorDocId?: string | null;
nextDocId: string;
}): DocSwitchDecision {
if (input.editorDocId && input.editorDocId !== input.nextDocId) {
return { allow: false, keepEditor: true, message: DOC_SWITCH_WHILE_EDITING_MESSAGE };
}
return { allow: true };
}
export function interpretBlockSaveError(err: { code?: string; message?: string }): {
keepEditor: true;
conflict: string;
} {
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
return { keepEditor: true, conflict: HEAD_CONFLICT_SAVE_MESSAGE };
}
return {
keepEditor: true,
conflict: `保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`,
};
}