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>
86 lines
2.9 KiB
TypeScript
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 || '请求未成功'}。编辑器仍保留当前修改,请重试。`,
|
|
};
|
|
}
|