- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
本文以next-compose仓库中的 DIFF-248 规格(248-vault-conflict-session.md)为核心,讲解 JSON 工具与随手记在遇到 Vault 外部文件冲突时,为什么 Compose 的remember状态不够用、冲突状态如何迁移到JsonSession/QuickNoteSession中持久持有,以及「重新加载 / 另存副本 / 保留编辑」三个处理动作在源码里的完整实现。读完本文,你将掌握这套冲突会话态的设计动机、状态生命周期边界、对话框交互规则与对应的验证手段。
背景:Vault 外部冲突是怎么被判定出来的
DIFF-248 讨论的对象是「Vault 外部冲突对话框」,要理解会话态改造的价值,先要看清冲突判定本身。在 next-compose 中,JSON 片段库(jsonVault)与随手记笔记库(NoteVault)都支持把文件直接放在本地目录里,Git 快照、MCP、外部编辑器都可能改动这些文件,因此必须处理「用户正在编辑时,磁盘上的文件被别人改了」的场景。
冲突判定引擎 VaultConflictEngine
判定逻辑集中在 VaultConflictEngine.kt 中,这是一个纯 Kotlin 对象,不依赖 Compose,便于在无窗口单测中复现(其设计背景见 DIFF-026 规格):
VaultChangeKind枚举定义了外部变化的五种分类:Ignored(忽略)、Reload(静默重载)、Conflict(冲突)、Deleted(文件被外部删除)、TreeChanged(仅树变化);VaultConflictState是冲突发生时携带的快照数据:relativePath(相对 Vault 路径)、editorText(编辑器当前内容)、diskText(磁盘最新内容,删除时为null)、deleted(是否外部删除);- 判定以 UTF-8 内容的 SHA-256 为同一性依据(
sha256Text),而不是 mtime,因为 mtime 无法唯一判断内容是否相同; MAX_HASH_BYTES = 8 * 1024 * 1024:超过 8 MiB 的文件只比较 size 指纹,避免对超大文件做全量哈希。
核心的decide方法(VaultConflictEngine.kt L41-L61)决策顺序如下:
- 变化路径不是当前打开的文件 →
TreeChanged(只刷树,不碰编辑器); - 磁盘文件已不存在 → 编辑器内容与上次保存一致则
Deleted,否则Conflict(本地有未保存编辑,但文件被删了); - 磁盘哈希等于
expectedOwnHash(本次是自己写盘产生的回显)→Ignored; - 磁盘内容 == 编辑器内容 →
Ignored; - 编辑器内容 == 上次保存内容(本地没改)→
Reload(直接用磁盘版本静默重载,不打扰用户); - 磁盘内容 == 上次保存内容(外部没改)→
Ignored; - 以上都不满足(本地脏且磁盘也偏离了保存基线)→
Conflict。
保存方向另有护栏:canOverwrite(L63-L66)要求磁盘内容必须等于上次保存基线或等于编辑器内容,否则拒绝写盘——即「保存前再次读盘,外部版本与基准和编辑器都不同则拒绝覆盖」。副本命名规则在conflictCopyName(L68-L77)中实现:把原文件名改成*.local-{epochMs}.*,例如a/b.json→a/b.local-1757900000000.json。路径过滤由shouldIgnore负责(L30-L39):忽略点文件、.tmp、.git/,随手记库额外忽略attachments/。
冲突进入会话的两个入口
以 JSON 工具为例,外部变化最终汇聚到 JsonVaultSession.kt 的两个函数:
jsonVaultApplyExternalChange(L70-L111):磁盘监视回调时调用。编辑器干净时走jsonVaultReloadOpenFileIfClean直接重载;脏时调用VaultConflictEngine.decide,返回Conflict就通过onConflict回调产出一个VaultConflictState;saveJsonVault(L303-L327):用户保存时先readOrNull再canOverwrite,不通过则同样回调onConflict并返回失败,杜绝后写覆盖。
随手记侧的quickNoteApplyExternalChange(QuickNoteVaultContext.kt L244-L287)逻辑对称,只是比较前会用NoteFrontmatter.parse剥离 frontmatter、只比较正文。监视器VaultRevisionMonitor以 400ms 间隔做内容哈希轮询(VaultRevisionMonitor.kt L15-L18),检测到的变化经SwingUtilities.invokeLater切到 EDT 后调用上述入口。
问题:remember 状态的组成生命周期陷阱
DIFF-248 的规格原文非常简短,它只声明了三条行为契约(248-vault-conflict-session.md):
JsonSession.vaultConflict、QuickNoteSession.vaultConflict持有VaultConflictState,替代 Composeremember;- 分离工具窗口时冲突对话框与状态栏横幅不随主窗占位销毁而丢失;
- 主窗切页不自动清空冲突(与 Electron 会话语义一致);用户须在对话框内处理或关闭。
这三条背后是 Compose 桌面端一个具体的状态管理问题:在改造前,「是否存在待处理冲突」如果存放在JsonScreen/QuickNoteScreen组合体内部的remember中,其生命周期绑定的是该 composable 的 composition。而 next-compose 的工具体系存在两类场景会摧毁或重建 composition:
- 分离工具窗口(detach):工具面板可以从主窗拖出成为独立窗口。此时主窗中该工具的占位组合会被销毁,再合并回来时重新组合,
remember的值随之蒸发——用户正面对着一个未解决的磁盘冲突,对话框和状态栏横幅却凭空消失; - composition 反复重建:主题切换、布局重组等都可能触发局部重组,把本应跨页面保留的业务状态当成「UI 临时状态」丢掉。
而「有没有冲突」并不是 UI 临时状态:它对应磁盘上真实存在的双版本分叉,必须由用户显式裁决。把它放在remember里,语义上就错了。
DIFF-248:冲突状态上移到 Session 会话对象
会话字段与声明位置
改造后,VaultConflictState的两个宿主在 SessionManager.kt 中声明:
JsonSession:L154 声明var vaultConflict: VaultConflictState? = null,与gitDialogOpen、vaultDeleteConfirmPath、vaultContextMenuPath、vaultTreeExpanded、vaultTreeScrollOffset等字段并列,同属「Vault 作用域」的状态族;QuickNoteSession:L1739 声明同样的字段。
JsonSession与QuickNoteSession实例由SessionManager以缓存对象形式持有,生命周期与 App 会话一致,远长于任何一次 composable 组合。因此当JsonScreen在主窗被拆掉、在分离窗口重建、或用户切到别的工具页再切回来时,session.vaultConflict始终保留原值,VaultConflictDialog会依据它再次渲染出来——这正是规格中「分离窗口不丢失」「主窗切页不自动清空」两条契约的实现基础。
清空边界:什么时候才允许清掉冲突
规格第三条「用户须在对话框内处理或关闭」意味着清空只允许发生在两类场合,源码里对应两个明确的位置:
- 用户在对话框内完成处理:三个动作的收尾都会把
session.vaultConflict = null,见后文「三个处理动作」一节; - 会话整体被外部重置:两个 Session 都提供了
resetVaultScopedOverlays()(JsonSession在 SessionManager.kt L159-L170,QuickNoteSession在 L1757-L1765),一次性清掉 Git 对话框、冲突状态、删除确认、右键菜单、路径选择器等全部 Vault 作用域弹层。它的调用方是reloadJsonSessionInPlace/reloadQuickNoteSessionInPlace(L2511-L2525),即备份恢复或 SQLite 会话库被外部替换后、在既有会话实例上从tool_sessions重新装载的路径——此时整个工作区已被换掉,旧冲突自然作废。Vault 根目录变更的场景则由 VaultRootChange.kt 中的dismissVaultScopedOverlays()扩展函数走同一清理逻辑。
需要注意的细节是:vaultConflict故意没有纳入snapshot()/restore()的持久化快照。JsonSessionSnapshot、QuickNoteSessionSnapshot只保存编辑内容、查找替换、树展开选择等常规字段,冲突状态是「运行期待裁决」状态,不落库。这与会话重装载时先resetVaultScopedOverlays()再restore(snapshot)的顺序相配合,避免把一个基于旧磁盘版本的冲突快照恢复到新的数据环境里。
冲突对话框:显隐规则与三个处理动作
对话框 UI 与显示规则
会话态通过 VaultConflictOverlay.kt 的两个入口接入两个工具:JsonVaultConflictOverlay(L12-L43)与QuickNoteVaultConflictOverlay(L45-L71),都是session.vaultConflict?.let { ... }的声明式渲染:会话字段非空即显示,为 null 即消失,不再有独立于会话的本地开关。
对话框本体 VaultConflictDialog.kt 为 640x420 的遮罩弹层,从上到下依次是:标题、冲突文件相对路径(warning 色)、说明行、unified diff 预览区(等宽字体,可滚动)、底部按钮行。显隐与权重规则统一收敛在 VaultConflictPresentation.kt:
- 外部删除(
deleted = true)时:不显示「重新加载」按钮(showReloadAction返回 false),主操作是「另存副本」(saveCopyProminent为 true);diff 预览区不显示,只展示删除提示文案(hintDeleted); - 外部改写时:主操作是「重新加载」(
reloadProminent为 true),预览区展示DiffEngine.compare(editorText, diskText)生成的 unified diff,截断到 4000 字符(buildUnifiedPreview的maxChars参数);diff 为空时显示「无差异」占位文案; - 「保留编辑」按钮恒在,并且整个遮罩的
onDismiss也映射到onKeep,即点击空白处等同于保留编辑。
三个动作的会话副作用
动作实现集中在 VaultConflictActions.kt,JSON 与随手记各一套,模式一致,都遵循「先落盘/读盘 → 更新会话基线 → 清vaultConflict→ 写 notice/error」的顺序:
重新加载(Reload):applyJsonVaultConflictReload(L15-L33)。非删除场景下在 EDT 上用container.jsonVault.read(relativePath)把磁盘内容写回编辑器(recordUndo = false),然后同步savedText,让编辑器进入「干净」状态,最后清冲突、提示「已重载」。删除场景下则把currentFile清空、把当前编辑器内容提升为新的savedText基线,避免用户编辑内容在基线上变成「脏」。
另存副本(Save Copy):applyJsonVaultConflictSaveCopy(L35-L62)。用VaultConflictEngine.conflictCopyName生成*.local-{epoch}.*副本名并写入编辑内容,成功后调用monitor?.rebaselineAfterLocalCrud()让监视器把自己这次写盘当作「自写」忽略(避免副本文件触发下一轮监视误报);删除场景下副本直接成为新打开的文件,改写场景下原路径恢复为磁盘版本。失败时只写session.notice,不清冲突状态——用户还可以重试。注意nowMillis是显式参数(默认System.currentTimeMillis()),这让单测可以注入确定性时间戳断言副本文件名。
保留编辑(Keep):applyJsonVaultConflictKeep/applyQuickNoteVaultConflictKeep(L64-L66、L111-L113)只清session.vaultConflict,不触碰编辑器与磁盘。此后若用户直接 Ctrl+S,saveJsonVault会再次canOverwrite失败并重新弹出对话框——「保留」只是暂时关闭提示,不解除保存护栏。
会话态如何改变屏幕行为:autosave 守卫与状态栏横幅
冲突从remember上移到会话字段后,JsonScreen的若干组合逻辑改为直接读取session.vaultConflict(JsonScreen.kt):
- 250ms 空闲自动保存守卫(L228-L238):
LaunchedEffect的 key 里包含session.vaultConflict,且首行if (session.vaultConflict != null) return@LaunchedEffect——存在未裁决冲突时,禁止空闲自动保存把本地脏内容压到分叉的磁盘上;同时该 key 的参与保证冲突状态一变(出现或清除),自动保存逻辑立即重新求值; - 状态栏横幅(L466):
session.vaultConflict != null时在状态栏追加vault.conflict.banner文案(warning 色)。横幅与对话框读同一个会话字段,所以对话框即使因组合抖动未渲染,横幅也明确告诉用户「有未处理冲突」,两者不会互相矛盾; - 冲突回调的注入方式:
JsonScreen中所有可能产出冲突的调用点(监视器回调、保存、Git flush、打开文件、导入、自动保存等)都统一传{ session.vaultConflict = it }作为onConflict,与前述 JsonVaultConflictSessionTest.kt 中直接对jsonVaultApplyExternalChange/saveJsonVault的断言形成「入口产出 → 会话持有 → UI 渲染」的完整链条。
验证:会话级单测与产品证据
DIFF-248 规定的验证命令是./gradlew :composeApp:desktopTest --offline。围绕该改造,仓库中的关键测试资产包括:
- JsonVaultConflictSessionTest.kt:在临时目录里构造
open.json,先用SwingUtilities.invokeAndWait把编辑器改成脏内容,再用container.jsonVault.write模拟外部改写,断言jsonVaultApplyExternalChange回调出的VaultConflictState字段完整(relativePath、editorText、diskText、deleted),以及saveJsonVault在磁盘偏离基线时保存失败且回调冲突; - QuickNoteVaultConflictSessionTest.kt:随手记的对称路径,覆盖 frontmatter/正文变更触发
Conflict; - JsonVaultMonitorConflictTest.kt、QuickNoteVaultMonitorConflictTest.kt:验证真实
VaultRevisionMonitor轮询路径也能经invokeLater走到同一回调(wiring 层证据,见 DIFF-439); - 保存副本/保留编辑的具体流转由 JsonVaultConflictSaveCopyFlowTest.kt、JsonVaultConflictReloadKeepFlowTest.kt 等覆盖;产品窗口级的手工步骤记录在 docs/evidence/2026-09-17-vault-conflict-product-window/results.md。
从源码结构看,这套测试之所以可行,正是因为判定引擎(VaultConflictEngine)、会话(JsonSession)与动作(VaultConflictActions)都不绑定 Compose 组合环境,仅编辑器文本设置需要切到 EDT——这正是 DIFF-248 把状态上移为普通会话字段带来的附带收益:冲突链路可以在无窗口单测里被逐段钉死。
小结
DIFF-248 的改动量很小(两个会话字段 + 一处 overlay 渲染条件),但它修正的是桌面 Compose 应用里一类常见的状态归属错误:把「必须跨组合存活、且对应真实磁盘事实」的业务状态放在remember里。改造后的语义边界清晰——冲突状态随会话生、随用户裁决灭、随整个工作区重置而失效;对话框、状态栏横幅、自动保存守卫三者读同一个session.vaultConflict字段,主窗切页、窗口分离都不会丢失未裁决的冲突,同时saveJsonVault的canOverwrite护栏保证即使用户点了「保留编辑」,磁盘分叉也不会被静默覆盖。这一设计与 Electron 版本的会话语义对齐(参见 DIFF-026 中「与 Electron 比:dirty 时不再静默忽略外部更改;保存会被拦住直到用户选择」的行为差异说明),是 next-compose 逐条对齐并改进旧版行为的典型样例。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool Next-Compose 中的 Vault 树滚动位置会话态:DIFF-251 设计与实现解析
MooTool Next Compose 中的 Vault 树滚动位置会话态:DIFF 251 设计与实现解析 导读 本篇文章围绕 MooTool 下一代桌面实
桌面应用开发工具Learn Harness Engineering 第三讲:让代码仓库成为 AI Agent 的唯一事实来源(System of Record)
Learn Harness Engineering 第三讲:让代码仓库成为 AI Agent 的唯一事实来源(System of Record) 导读 :本讲解
桌面应用开发工具MooTool next-compose:Vault Git 面板的会话级持久化设计与实现(DIFF-246)
MooTool next compose:Vault Git 面板的会话级持久化设计与实现(DIFF 246) 本文基于 MooTool 的 next comp
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考