2026/9/14 1:54:25

Claudian Collab 弹窗层设计解析:瞬态表面注册表、幂等变更意图与快照一致性围栏

Claudian Collab 弹窗层设计解析:瞬态表面注册表、幂等变更意图与快照一致性围栏 Claudian Collab 弹窗层设计解析瞬态表面注册表、幂等变更意图与快照一致性围栏【免费下载链接】claudianAn Obsidian plugin that embeds Claude Code/Codex as an AI collaborator in your vault项目地址: https://gitcode.com/GitHub_Trending/cl/claudian本篇技术文章基于 Claudian 仓库中的 Collab Modals 架构文档系统讲解这个 Obsidian 插件协同Collab功能模块中弹窗层的三层核心设计由CollabTransientSurfaceRegistry实现的插件生命周期级瞬态表面注册、ProjectManagementModal中基于快照校验与变更意图Mutation Intent的权限操作一致性机制以及 Create/Join/Reconnect 弹窗的防重复提交与断点续传Resume模式。读完本文你将能够理解一个长生命周期 UI 组件如何安全地管理异步操作、幂等重试与关闭时的级联清理并在 tests/unit/features/collab/modals/CollabTransientSurfaceRegistry.test.ts 等测试中找到每一处设计约定的验证点。一、瞬态生命周期CollabTransientSurfaceRegistry架构文档的第一个章节规定CollabTransientSurfaceRegistry.ts 是 Create、Join、Reconnect 与项目管理这类“瞬态表面”Transient Surface的组合层composition-owned插件生命周期注册表。插件被实时禁用live disable或卸载unload时必须在 Collab 服务拆解teardown之前关闭并中止所有已注册的弹窗。1.1 注册表的完整实现整个注册表实现仅 33 行接口面极小export interface CollabTransientSurface { close(): void; open(): void; } export type CollabTransientSurfaceFactory ( onClosed: () void, ) CollabTransientSurface; export class CollabTransientSurfaceRegistry { private readonly surfaces new SetCollabTransientSurface(); open(factory: CollabTransientSurfaceFactory): void { let surface: CollabTransientSurface | null null; const onClosed (): void { if (surface) this.surfaces.delete(surface); }; surface factory(onClosed); this.surfaces.add(surface); try { surface.open(); } catch (error) { this.surfaces.delete(surface); throw error; } } closeAll(): void { const surfaces Array.from(this.surfaces); this.surfaces.clear(); for (const surface of surfaces) surface.close(); } }参见 CollabTransientSurfaceRegistry.ts#L10-L33从源码结构看三个关键行为值得注意工厂注入onClosed回调注册表通过CollabTransientSurfaceFactory把“自我注销”能力交给表面surface自身——弹窗自然关闭时调用onClosed从Set中移除自己。这避免了注册表轮询弹窗存活状态的脆弱做法。open()抛错即回滚open在try/catch中执行一旦抛错立即delete并重新抛出保证注册表状态与 DOM 实际状态一致。closeAll()先清空再关闭先把Set拷贝到数组并清空再逐个close()。这样即使某个close()内部重新触发注册表操作也不会产生遍历中的重入问题。1.2 注册表在组合层的位置从 src/main.ts 的调用点可以印证文档中“插件生命周期注册表”的定位第 668 行附近插件把transientSurfaces: this.collabTransientSurfaces传给 CollabPanel第 457 行附近卸载路径调用this.collabTransientSurfaces.closeAll()第 1342 行附近功能被实时禁用时执行if (!enabled) this.collabTransientSurfaces.closeAll();。CollabPanel.ts#L761-L762 中面板把打开弹窗的动作委托给注册表if (this.options.transientSurfaces) { this.options.transientSurfaces.open(factory); }这意味着面板本身不持有弹窗实例而是依赖注册表在 disable/unload 时兜底关闭——与文档“Live disable or unload closes and aborts every registered modal before Collab service teardown”的约定一一对应。1.3 异步启动前的生命周期再校验文档第二节要求异步弹窗启动必须在每个await之后、打开 UI 之前重新校验所捕获的 Collab 生命周期revalidates the captured Collab lifecycle after every await before opening UI。每个弹窗还要保留自己的操作准入operation admission、AbortController、关闭行为与陈旧完成围栏stale-completion fence——“没有操作可以更新或重新打开一个已关闭的表面”。这一约定在各弹窗源码中普遍落地例如 CreateProjectModal.ts 的runCreate()const result await this.port.createProject({ ... }, { signal: this.abortController.signal }); if (this.abortController.signal.aborted) return; // 陈旧完成围栏以及 JoinProjectModal.ts#L94-L115 额外引入的代际围栏generation fence每次runJoin()递增operationGenerationonClose()也递增它异步结果返回后先if (!this.isCurrent(generation)) return;——即使AbortController信号尚未传播关闭后的旧结果也会被代际号拦截。1.4 单元测试对注册表行为的固化CollabTransientSurfaceRegistry.test.ts 用假端口fake ports验证了文档要求的行为registry.open(onClosed { closeFirst onClosed; return first; }); registry.open(() second); closeFirst(); registry.closeAll(); expect(first.close).not.toHaveBeenCalled(); // 自然关闭的表面不再被 closeAll 触碰 expect(second.close).toHaveBeenCalledTimes(1); registry.closeAll(); expect(second.close).toHaveBeenCalledTimes(1); // 第二次 closeAll 幂等无副作用它锁定了两点契约自然关闭通过onClosed自我注销的表面不会被重复关闭closeAll()本身是幂等的。二、项目管理弹窗ProjectManagementModal 的快照与权限一致性文档的“Project Management”章节是全部规定中最密集的部分对应实现为 ProjectManagementModal.ts约 1059 行。2.1 打开即渲染本地 Host 控件不做在线预检文档规定ProjectManagementModal无需在线协调预检即可打开并始终为 Host 所有的项目暴露本地 LAN Host 控件成员身份/列表展示所用的成员快照member snapshot是后续才获取的确认焦点与访问操作的 retry 也由它负责“LAN Host、Invite、Leave、Retire 保持在同一个稳定 footer 中Host 启动成功后就地重试快照retries the snapshot in place”。源码在 ProjectManagementModal.ts#L157-L197 的renderShell()中印证了这一顺序shell 先无条件渲染 LAN Host 区与动作容器只有当authorityKind lan且hostInstallationStatus ! not-host时才挂载LanHostSection随后onOpen()中的void this.loadMembers()才发起首次快照读取。LAN Host 区成功启动后的回调onStatusChanged在status running时重新void this.loadMembers()——正是“就地重试快照”的实现。成员读取走一条“最新任务通道”latest-task lane见 ProjectManagementModal.ts#L213-L255private async loadMembers(): Promisevoid { // Presentation reads own one latest-task lane: a superseding read cancels // the earlier authority read. Mutations retain application-owned admission. const task this.readTasks.start(); ... const result await this.port.readSnapshot(this.options.project.id, { signal: task.signal }); if (!this.isReadCurrent(task)) return; if (result.status ! success) { this.renderLoadFailure(); return; } if (result.value.source ! online || result.value.stale) { this.renderLoadFailure(); return; } if (result.value.syncState.status ! synchronized) { this.renderLoadFailure(); return; }注意这里对快照的三重准入读取必须成功、来源必须是online且不stale、syncState.status必须是synchronized不满足任一条件就渲染加载失败 Retry而不是拿陈旧数据渲染权限界面。2.2 变更意图Mutation Intent与幂等重试文档的核心规定之一是Manager 的提权、降权、移除与责任让渡responsibility-offer操作在弹窗显式 Retry 期间必须保持同一条变更意图one mutation intent不变。只有在成功、用户取消、身份变化或弹窗关闭时才清除它否则“丢失的响应将无法到达权威方的精确幂等重放”a lost response cannot reach the authoritys exact idempotent replay。提权确认还固定了该操作是“创建 offer”还是“完成 offer”包括精确的 offer ID只要 Retry 仍然可用快照刷新就不得改变该操作。实现分两部分1意图存储MutationIntentStore.ts 是一个极小的身份敏感缓存intent(key: Key, input: unknown): string { const identity JSON.stringify(input); const current this.intents.get(key); if (current?.identity identity) return current.id; // 同一操作 → 同一 intentId nextMutationIntent 1; const intent { id: mutation${Date.now().toString(36)}_${nextMutationIntent.toString(36)}, identity }; this.intents.set(key, intent); return intent.id; }identity是操作参数的 JSON 序列化。只要 Retry 时操作参数完全一致同一项目、同一目标成员、同一 offer 语义就复现同一个intentId权威端authority凭它做幂等重放。clear(key, intentId)还带了 ID 匹配校验防止误清被新意图替换后的记录。2弹窗侧的冻结与清理时机ProjectManagementModal.ts 中mutationIntents的写入与清理精确对应文档列出的四种时机提权确认键confirmationWorkflowKey()第 827-834 行把complete-promotion分支的 offer ID 编进键里return confirmation.operation.kind complete-promotion ? ${confirmation.kind}:${confirmation.member.id}:${confirmation.operation.kind}:${confirmation.operation.managerResponsibilityOfferId} : ${confirmation.kind}:${confirmation.member.id}:${confirmation.operation.kind};这正是“确认冻结了操作是创建 offer 还是完成 offer包括精确 offer ID”的代码化表达。confirmAccessAction()第 873 行起操作成功且带mutationIntent时clear(kind, intentId)——成功才清除失败路径不 clear下一次 Retry 通过confirmationIntent()再次取得同一个intentId。用户取消走discardConfirmationIntent()调discard(kind)身份变化currentMemberId切换见loadMembers()第 240-247 行与onClose()均调mutationIntents.clearAll()。promote分支的意图身份特意包含operation对象本身第 845-850 行所以“创建 offer”与“完成指定 offer”即便目标成员相同也会得到不同身份符合文档“快照刷新不得改变 Retry 可用的操作”的要求。2.3 权限边界与文案约束文档还给出了几条权限与文案层面的硬性规定源码中均有对应落点Manager 的邀请/管理权限独立于 Host 能力。renderProjectActions()第 511 行起中“创建邀请”按钮仅当isManager当前成员role manager且active出现Host 专属动作如 transfer host则由currentMemberId hostMemberId判定二者互不推导。移除文案不得暗示远程删除remove 确认区渲染t(collab.access.removedFilesRetained)“被移除成员的文件仍保留”demote 确认区渲染demoteHostUnchanged降权不影响 Host 身份。Leave 文案区分“可见文件保留”与“协同 Git 历史移除”leave 确认区渲染leaveCleanupWarning并给出keep-files/delete-files单选renderCleanupChoices()第 567 行起默认选中keep-files——对应文档“Leave 为所有角色默认提供 Keep local filesDelete local files 为破坏性替代项”。责任要求来自权威投影authority projection绝不来自缓存的成员列表推断Manager 离开时是否必须指定继任者不是由 UI 数成员得出的而是由权威端以manager-responsibility-pending错误码回推——confirmAccessAction()第 928-940 行收到该错误后把confirmation.managerSuccessorRequired置为true并渲染继任者选择区确认按钮在“manager-leaveoffer 尚未被目标 acknowledged”时保持禁用。2.4 可见性规则offer 只对源与目标可见Accept Host 只在目标自己的行文档最后两条规定Manager 责任 offer 仅对源和目标可见目标通过同步确认 Manager 责任因此不提供“Accept Manager”按钮Accept Host 只出现在被选中目标自己的行上且“接受是进展progress而非完成completion”Retire 只对已同步的 Manager 可用并说明协同与仅 Git 历史对所有人终结。renderMember()第 299 行起与renderIncomingResponsibilityActions()第 457 行起精确实现了这套可见性矩阵提权 offerpurpose manager-promotion只在发起 Manager 自己查看目标行时渲染三态offered→ 禁用的“pending”按钮、acknowledged→ 可点击的complete-promotion按钮、无 offer →make-manager按钮其他成员看不到任何 promotion 控件。取消 offer 的按钮只渲染在源成员自己的行managerOffer?.sourceManagerMemberId member.id而member.id currentMemberId分支才调用renderIncomingResponsibilityActions。accept-host-transfer/decline-host-transfer按钮只在hostTransfer?.targetMemberId member.id且phase offered且处于目标自己行时渲染——即“Accept Host 只出现在选中目标自己的行上”。Retire 按钮在renderProjectActions()中由isManager门控而isManager来自已校验为 online/非 stale/synchronized 的实时快照与“Retire 仅对已同步 Manager 可用”一致确认区渲染retireWarning文案说明终结影响。另外两处生命周期细节onOpen()订阅port.subscribe一旦本项目lifecycle retired立即this.close()第 124-129 行——Retire 成功后弹窗自行终结onClose()会级联关闭 ProjectInvitationModal 与 HostDiagnosticsModal 并destroy()LanHostSection落实文档“Project Management 在关闭时关闭任一子表面使其操作不能比已注册的父级活得更久”。三、LAN Host 控件LanHostSection 与诊断弹窗3.1 只渲染本地 Host 能力文档规定LanHostSection.ts只渲染项目管理内的本地 Host 能力并拥有 in-flight start/stop 的呈现围栏不得推断 Manager 权限不得暴露邀请或成员资格动作。从源码结构看LanHostSectionPort只Pick了claimLegacyHostInstallation | startHost | stopHost三个端口——接口面上根本没有邀请/成员动作可暴露。它的呈现状态机围绕hostInstallationStatus与hostStatus两个维度hostInstallationStatus not-host直接移除 root 元素整块消失第 72-75 行hosted-elsewhere只渲染“Hosted elsewhere”徽标不给任何操作按钮第 82-88 行hostStatus为stopped / starting / running / stopping / needs-attention时渲染状态按钮starting/stopping期间按钮禁用in-flight 围栏第 131-160 行。3.2 启动失败不回滚持久项目文档关键规定“Host 创建持久化 auto-start 意图并立即启动 LAN hosting。监听器启动失败永不回滚持久化的 ProjectProject Management 保持可用可重试并查看脱敏诊断。”runAction()第 166-242 行体现了“失败只改呈现、不改持久状态”的语义} else { this.project { ...this.project, hostStatus: needs-attention }; this.errorAction action; // 记住失败的是 start 还是 stop供 Retry 复用 this.lastError error in result ? result.error.toJSON() : null; this.errorText action start ? t(collab.host.startFailed) : t(collab.host.stopFailed); }失败后hostStatus进入needs-attention按钮文案切换为 Retrydata-actionretry-host而重试的action直接取errorAction——同一失败动作可被原地重试这正是“successful Host start retries the snapshot in place”在 Host 维度的对应。此外legacy-unbound安装状态在 start 前会先弹确认并调用claimLegacyHostInstallation第 173-206 行且确认取消、已销毁、信号中止或代际不匹配时全部安全返回。3.3 脱敏诊断HostDiagnosticsModalHostDiagnosticsModal.ts 负责文档所称“redacted Host diagnostics presentation and copy”。它把LanHostDiagnostics含projectId、status、可选的error与projectName序列化为 JSONpre展示并提供复制按钮serialize()第 57-62 行只输出这四个字段错误对象来自result.error.toJSON()——即由错误类型自己决定对外暴露的脱敏字段UI 层不参与拼凑原始错误细节。四、Create、Join 与 Reconnect空目录原则与断点续传4.1 项目创建只收两个字段文档规定项目创建是 empty-only 的。CreateProjectModal.ts 只收集项目名称与初始 Member 显示名“永不发现、预览、选择、复制或总结 Vault 文件”。源码中CreateProjectPort仅Pick了createProject | resumeSetup两个端口提交体也只有this.port.createProject({ memberDisplayName: this.memberNameInput?.value.trim() ?? , name: this.projectNameInput?.value.trim() ?? , }, { signal: this.abortController.signal });没有任何 Vault 文件路径入参——从接口面上杜绝了文件读取的可能。4.2 防重复提交、关闭中止、陈旧忽略与输入保留文档对 Create/Join/Reconnect 的统一要求“防止重复提交、关闭时中止活动工作、忽略陈旧完成、保留重试输入。持久进展结果暴露既有的 Resume setup 操作而不是轮换操作意图rotating operation intent。”各弹窗的落地方式高度一致以 JoinProjectModal.ts 为例约定实现手段防重复提交runJoin()首行if (!this.joinButton \|\| this.joinButton.disabled) return;随后立即disabled true关闭中止onClose()调abortController.abort()且operationGeneration 1忽略陈旧完成if (!this.isCurrent(generation)) return; 对signal.aborted的检查保留重试输入表单 DOM 不清空onClose前失败只renderStatus(..., true)并恢复按钮可用ReconnectProjectModal.ts 遵循同样的模式。4.3 recovery-required 与 Resume不换意图续既有操作createProject/joinProject的返回可能是success、失败或recovery-required。后者的处理是文档“expose the existing Resume setup operation instead of rotating operation intent”的直接体现CreateProjectModal.ts#L115-L156if (result.status recovery-required) { this.renderResume(result.operationId); // 渲染 Resume 按钮捕获既有 operationId return; } ... const result await this.port.resumeSetup({ operationId }, { signal: this.abortController.signal });也就是说当权威端判定“该操作已在别处留下持久进展”时UI 不再构造一次新的创建/加入操作那会产生第二个操作意图而是拿返回的operationId调resumeSetup续上既有的 setup 操作。这与第二章的 MutationIntentStore 形成互补前者是权威端持久操作的续传后者是权威端幂等重放的客户端身份保持。五、验证矩阵用假端口覆盖的测试面文档“Verification”章节列出了必须用**假端口fake ports**验证的行为清单重复准入duplicate admission关闭时取消cancellation on close陈旧完成stale completion需要恢复的 Resumerecovery-required Resume本地 Host start/stop 围栏fencing快照重试snapshot retry责任可见性responsibility visibilityLeave 清理选择Leave cleanup choiceRetire 同步门控Retire synchronization gating。仓库中 tests/unit/features/collab/modals/CollabTransientSurfaceRegistry.test.ts 已经覆盖了注册表层面的关闭/幂等契约其余行为对应到上文的ProjectManagementModal、LanHostSection与三个流程弹窗的单元测试。从测试组织看tests/unit/features/collab/modals/及tests/unit/features/collab/目录这些约定均以可独立运行的单测形式固化假端口替换CollabFeaturePort的各方法使上述每一条 UI 一致性规则都可以不依赖真实网络/权威端进行验证。六、小结三条不变量贯穿 Collab 弹窗层综合 AGENTS.md 的规定与上述源码印证Collab 弹窗层可以用三条不变量概括表面不永生任何瞬态弹窗要么被注册表统一closeAll()兜底要么自然关闭时自我注销父弹窗关闭必级联关闭子表面每个操作在await之后都受AbortController 代际围栏双重拦截已关闭的表面不可被旧响应更新或重开。意图不漂移同一操作在 Retry 期间保持同一intentId客户端MutationIntentStore或同一operationId权威端resumeSetup成功、取消、身份变化或关闭才清除提权确认连 offer 的创建/完成语义与 offer ID 一并冻结。权限不臆断权限与责任要求一律来自经 online/非 stale/synchronized 三重校验的权威投影快照或权威端返回的错误码如last-manager-required、manager-responsibility-pending、host-transfer-pendingUI 绝不从缓存成员列表推断文案上严格区分文件保留与协同 Git 历史的移除、远程移除与本地清理。这三条不变量共同保证在一个“权威端可能在任意时刻返回失败、超时或要求恢复”的局域网协同场景中弹窗层的每一次点击都映射到恰好一条可重放的操作意图且任何 UI 状态都不会领先于权威投影。【免费下载链接】claudianAn Obsidian plugin that embeds Claude Code/Codex as an AI collaborator in your vault项目地址: https://gitcode.com/GitHub_Trending/cl/claudian创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考