- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
本篇技术指南围绕 MooTool Compose(Kotlin/Compose Desktop)客户端 JSON 工具的DIFF-370 变更展开,讲解 JSON Vault 250ms 空闲自动保存在失败时如何从"静默失败"改为写入状态栏notice的完整实现链路。读者将掌握:该变更的动机(与随手记、Vault 保存反馈的一致性对齐)、jsonVaultIdleAutosaveAttempt辅助函数的源码级实现、JsonScreen中LaunchedEffect空闲保存协程的接线方式、底层冲突判定逻辑(VaultConflictEngine.canOverwrite),以及对应的桌面测试用例与离线验收命令。
背景:空闲自动保存的"静默失败"问题
MooTool 的 Compose Desktop 客户端(位于仓库next-compose/目录,独立 Gradle Wrapper 工程)中,JSON 工具(内部编号F04)与随手记(F01)都实现了"编辑停顿 250ms 后自动落盘"的能力,对齐 Electron 版本JsonVaultPanel.persistSelectedOnIdle/QuickNoteTool.persistSnapshotOnIdle的行为。
在 DIFF-370 之前,两条空闲自动保存链路存在一处明显不对称:
- 随手记:250ms idle 自动保存失败时,
quickNoteSaveCurrent会把失败原因写入session.error(见 DIFF-199:随手记 250ms 空闲自动保存),状态栏可感知失败; - JSON:
JsonScreen中的同类型LaunchedEffect虽然同样调用saveJsonVault(..., showToast = false),但失败时完全静默——既没有 toast,也不写notice。
这与 DIFF-363:Vault prepare / saveIfNeeded 失败统一状态栏反馈 确立的"保存失败必须反映到状态栏"原则不一致。DIFF-363 已经让prepareJsonVaultContext在saveJsonVault失败时写入session.notice,而空闲自动保存这个高频后台路径还遗留了静默缺口。
从源码结构看,该缺陷的影响面主要在两类场景:一是编辑内容与磁盘版本产生外部冲突时自动保存被VaultConflictEngine拦截;二是写盘 IO 异常(如文件被占用、权限不足)。无论哪种,用户都可能以为内容已保存,实则失败被无声吞掉。
行为变更:新增jsonVaultIdleAutosaveAttempt辅助函数
DIFF-370 的核心变更非常聚焦:在 JSON Vault 会话模块中新增内部辅助函数jsonVaultIdleAutosaveAttempt,行为契约如下:
- 成功:返回
true; - 失败:向
session.notice写入失败文案,并返回false。
实现位于 JsonVaultSession.kt:
/** 250ms 空闲自动保存;失败写入 `session.notice`(对齐随手记 idle `error` 与 DIFF-363)。 */ internal fun jsonVaultIdleAutosaveAttempt( container: AppContainer, session: JsonSession, monitor: VaultRevisionMonitor?, onConflict: (VaultConflictState) -> Unit, ): Boolean = saveJsonVault(container, session, monitor, showToast = false, onConflict = onConflict) .onFailure { session.notice = it.message ?: container.t("json.notice.failed") } .isSuccess可以对照随手记侧新增的对称实现quickNoteIdleAutosaveAttempt(位于 QuickNoteVaultContext.kt),两条路径现在保持了行为契约上的对齐——差异仅在于 JSON 侧写入session.notice、随手记侧写入session.error。
几个值得注意的实现细节:
showToast = false:空闲自动保存是高频静默动作,成功时不应弹出 toast 打断用户,只在失败时通过状态栏notice反馈;onFailure文案优先级:优先取异常自身的message(更具体),为空时才回退到 i18n 键json.notice.failed(简体中文文案为"处理失败",见 Translator.kt);- 返回布尔值:调用方可以根据返回值决定是否需要额外刷新 UI。
接线:JsonScreen空闲保存协程的改动
JsonScreen中原有的空闲保存LaunchedEffect改用上述辅助函数。改动后的完整协程逻辑位于 JsonScreen.kt:
LaunchedEffect(session.editor.revision, session.currentFile, session.vaultConflict) { if (session.vaultConflict != null) return@LaunchedEffect val file = session.currentFile if (file.isBlank()) return@LaunchedEffect if (session.editor.text == session.savedText) return@LaunchedEffect delay(250) if (file != session.currentFile) return@LaunchedEffect if (session.editor.text == session.savedText) return@LaunchedEffect jsonVaultIdleAutosaveAttempt(container, session, monitor) { session.vaultConflict = it } refresh() }这个协程的守卫条件与行为可以拆解为五层:
| 阶段 | 守卫 / 动作 | 作用 |
|---|---|---|
| 入口 | session.vaultConflict != null直接返回 | 冲突对话框打开期间不自动保存 |
| 前置检查 | currentFile为空则返回 | 无打开文件(Vault 未选中)时不触发 |
| 前置检查 | editor.text == savedText则返回 | 编辑器干净(无脏数据)时不触发 |
| 防抖 | delay(250) | 等待 250ms 编辑停顿,天然防抖 |
| 二次校验 | 文件已切换 / 已变干净则返回 | 防抖期间状态变化则放弃本次保存 |
| 执行 | 调用jsonVaultIdleAutosaveAttempt | 成功或失败均写session.notice/ 触发onConflict |
与 DIFF-370 同时落地的关键差异点在于协程末尾的refresh():无论jsonVaultIdleAutosaveAttempt返回成功还是失败,都会无条件调用refresh()触发 UI 重绘(sessionManager.bump()+persistJson()),因此失败写入的session.notice能立即在状态栏呈现。这一"始终刷新"的设计后来被随手记侧借鉴,见 DIFF-373:随手记空闲自动保存失败刷新 UI(随手记失败路径原本只bump不刷新,DIFF-373 补上了该缺口)。
顺带一提,JSON 侧该机制最早成型于 DIFF-197:JSON Vault 空闲自动保存与树选中,当时的约束就是"冲突态不自动保存、saveJsonVault(..., showToast = false)",DIFF-370 是在此基础上补齐失败反馈。
底层原理:saveJsonVault与冲突判定
理解jsonVaultIdleAutosaveAttempt的失败语义,需要先看它包装的saveJsonVault(同样位于 JsonVaultSession.kt)。其核心流程为:
internal fun saveJsonVault( container: AppContainer, session: JsonSession, monitor: VaultRevisionMonitor?, showToast: Boolean = true, onConflict: (VaultConflictState) -> Unit, ): Result<Unit> { val name = session.currentFile if (name.isBlank()) { return Result.failure(IllegalStateException(container.t("json.vault.saveNoSelection"))) } val disk = runCatching { container.jsonVault.readOrNull(name) }.getOrNull() if (!VaultConflictEngine.canOverwrite(session.savedText, disk, session.editor.text)) { onConflict(VaultConflictState(name, session.editor.text, disk, disk == null)) return Result.failure(IllegalStateException(container.t("vault.conflict.blocked"))) } return runCatching { container.jsonVault.write(name, session.editor.text) monitor.noteOwnWriteJsonVaultFile(container.jsonVault, name) session.currentFile = name session.savedText = session.editor.text container.recordVaultActivity(VaultGitCheckpointMessages.UPDATE_JSON_SNIPPET, json = true) if (showToast) container.toastSuccess(container.t("json.vault.saved")) } }三个Result.failure出口对应三类失败原因:
- 未选择文件:
currentFile为空 → 文案json.vault.saveNoSelection; - 外部冲突:
VaultConflictEngine.canOverwrite判定不允许覆盖 → 同时回调onConflict(...)打开冲突状态、文案vault.conflict.blocked(简体中文:"磁盘文件已从外部更改,已阻止覆盖。",见 Translator.kt); - 写盘异常:
jsonVault.write抛出的 IO 异常,由runCatching捕获。
其中冲突判定逻辑来自 VaultConflictEngine.kt:
fun canOverwrite(savedText: String, diskText: String?, editorText: String): Boolean { if (diskText == null) return false return diskText == savedText || diskText == editorText }即:仅当磁盘文件存在,且磁盘内容等于"上次保存的快照"(说明外部未改动)或等于"当前编辑器内容"(说明自身刚写盘)时才允许覆盖。任何第三方外部改写(磁盘内容与 savedText、editorText 都不同)都会拦截自动保存——这正是空闲自动保存最容易触发的失败场景。
值得注意的是,saveJsonVault失败路径不会把失败信息写进session.notice,只是返回Result.failure。这正是 DIFF-370 引入jsonVaultIdleAutosaveAttempt的意义:由该辅助函数统一承担"失败 →session.notice"的转换,而saveJsonVault本身保持通用性(手动保存路径由 jsonVaultSaveFromUserAction 自行处理notice与成功 toast)。
测试验证:JsonVaultIdleAutosaveTest
DIFF-370 配套的桌面测试位于 JsonVaultIdleAutosaveTest.kt,共两个用例,分别覆盖"冲突 notice"与"成功落盘"两条路径:
用例 1:idleAutosaveConflictWritesNotice——验证冲突时写入 notice 并返回 false
@Test fun idleAutosaveConflictWritesNotice() { // 构造 AppContainer、JsonSession,Vault 中创建 a.json container.jsonVault.createFile("a.json", """{"saved":true}""") val session = JsonSession().apply { currentFile = "a.json" savedText = """{"saved":true}""" editor.setText("""{"dirty":true}""", recordUndo = false) } container.jsonVault.write("a.json", """{"disk":true}""") // 磁盘被外部改写 assertFalse(jsonVaultIdleAutosaveAttempt(container, session, monitor = null) { }) assertTrue(session.notice.isNotBlank()) }测试构造了三方数据不一致的状态:savedText是{"saved":true}、编辑器文本是{"dirty":true}、磁盘内容被外部改写为{"disk":true}。此时canOverwrite判定三向都不匹配 → 返回false,并断言session.notice非空。测试全程传入monitor = null,说明冲突判定不依赖 Git 修订监控。
用例 2:idleAutosaveSucceedsWhenCleanConflictCheckPasses——验证无冲突时成功落盘并返回 true
@Test fun idleAutosaveSucceedsWhenCleanConflictCheckPasses() { container.jsonVault.createFile("a.json", "{}") val session = JsonSession().apply { currentFile = "a.json" savedText = "{}" editor.setText("""{"a":1}""", recordUndo = false) } assertTrue(jsonVaultIdleAutosaveAttempt(container, session, monitor = null) { }) assertEquals("""{"a":1}""", session.savedText) }该用例覆盖正常的 250ms 防抖落盘:磁盘内容等于savedText(外部未改动),编辑器有脏内容{"a":1}→ 允许覆盖、写盘成功,返回true,且session.savedText更新为磁盘内容。同时验证了冲突回调onConflict在成功路径不会被触发。
验收方式与运行限制
按原文档约定,DIFF-370 的验收对象是F04 JSON Vault 自动保存反馈,执行方式为 Compose 工程的离线桌面测试:
cd next-compose ./gradlew :composeApp:desktopTest --offline--offline保证测试不依赖网络拉取依赖,是仓库next-compose/docs/acceptance.md中反复使用的标准验收形式。从仓库验收记录看,本机使用 JDK 21 运行该工程桌面测试的规模在 1340+ 用例量级,DIFF-370 的两个新增用例随JsonVaultIdleAutosaveTest纳入这一测试集。
需要说明的前提:Compose Desktop 测试针对 JVM 桌面运行时,其行为验证以单元测试形式断言(状态栏 UI 的最终呈现依赖session.notice被JsonScreen状态栏读取,见 JsonScreen.kt 的MooStatusMeta(session.notice)渲染分支),手工 UI 验收不在本次变更范围内。
小结:一条贯穿"随手记 ↔ JSON ↔ Vault"的反馈一致性链路
DIFF-370 表面上只是新增一个 8 行的辅助函数并改一处调用点,但它在 MooTool Compose 客户端的保存反馈体系里补齐了关键一环:
- 横向:JSON 空闲自动保存的失败反馈对齐了随手记(DIFF-199 的
session.error与 DIFF-373 的失败刷新); - 纵向:空闲自动保存路径对齐了 Vault 其他保存路径(DIFF-363 确立的
prepareJsonVaultContext/saveIfNeeded失败写状态栏原则); - 上游参照:行为基准始终对齐 Electron 版
JsonVaultPanel.persistSelectedOnIdle的静默自动保存语义(成功不打扰、失败必须有信号)。
这一变更使"外部冲突被拦截""写盘失败"等高频场景不再无声发生,为用户提供了可感知、可追溯的保存状态。对于需要为 Vault 类编辑器实现空闲自动保存的开发者,jsonVaultIdleAutosaveAttempt的"辅助函数封装showToast=false的保存 + 失败落notice+ 返回布尔值 + 调用方无条件刷新"组合,是一套可复用的成熟模式。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool Next-Compose 中 JSON Vault 空闲自动保存与树选中:Electron 对齐实现解析
MooTool Next Compose 中 JSON Vault 空闲自动保存与树选中:Electron 对齐实现解析 导读 本文以 MooTool Next
桌面应用开发工具MooTool Next Compose 全局 Toast 通知接入指南:JSON Vault CRUD、Host、HTTP 与格式化反馈
MooTool Next Compose 全局 Toast 通知接入指南:JSON Vault CRUD、Host、HTTP 与格式化反馈 本文聚焦 MooTo
桌面应用开发工具MooTool Next Compose JSON Vault:`prepareJsonVaultContext` 提取重构与冲突失败回归测试全解
MooTool Next Compose JSON Vault: prepareJsonVaultContext 提取重构与冲突失败回归测试全解 导读 本文聚焦
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考