- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
导读
本文以 MooTool Next Compose 桌面端的迭代记录 DIFF-585 为骨架,拆解三类典型操作(时间转换、Hosts 配置保存/应用、随手记保存)失败时的 toast 反馈机制,以及配套的 Compose 布局 CSS 切片与证据截图测试。读完本文,你将掌握 MooTool 中 "WiringPresentation 判定 → session.error/notice 状态 → toastError/toastSuccess 弹出" 这一统一失败反馈链路的完整实现,并能独立阅读同系列 DIFF 文档与对应源码、测试。
背景:DIFF-585 在迭代流中的定位
MooTool Next Compose 以next-compose/docs/diff/目录下的增量 DIFF 文档记录每一轮功能接线,每篇文档都给出范围(Scope)、**验证(Verification)**与对应的源码/测试证据路径。DIFF-585 的基线是 DIFF-584(工作区),范围围绕三个功能模块的失败 toast 补全:
- F18:时间转换失败 toast;
- F10:Hosts 配置模块失败 toast;
- F01:随手记(QuickNote)保存失败 toast;
- 证据:
TimeConvertActionsCaptureTest产出 Compose 帧180截图。
同时文档明确声明不重复DIFF-584 已完成的 F20 翻译 / F12 密钥 / 帧 179,避免范围重叠。这种"每轮只做一帧、基于上一轮基线增量推进"的组织方式,是阅读next-compose/docs/diff/系列文档的关键。
F18 时间转换:shouldToastConvertFailure与 error toast
判定入口:TimeWiringPresentation
DIFF-585 中 F18 的核心是TimeWiringPresentation.shouldToastConvertFailure与mooTimeConvertActions。源码位于 TimeWiringPresentation.kt:
object TimeWiringPresentation { fun canConvertTimestamp(timestamp: String): Boolean = timestamp.isNotBlank() fun canConvertLocal(localTime: String): Boolean = localTime.isNotBlank() fun canCopyField(value: String): Boolean = value.isNotBlank() fun timestampToLocalActionEnabled(timestamp: String): Boolean = canConvertTimestamp(timestamp) fun localToTimestampActionEnabled(localTime: String): Boolean = canConvertLocal(localTime) fun copyFieldActionEnabled(value: String): Boolean = canCopyField(value) sealed interface ConvertOutcome { data class ToLocal(val result: TimestampConversion) : ConvertOutcome data class ToTimestamp(val timestamp: String) : ConvertOutcome data class Failure(val error: Throwable) : ConvertOutcome } fun runTimestampToLocal(timestamp: String, unit: TimestampUnit, zone: String): ConvertOutcome = runCatching { TimeEngine.timestampToLocal(timestamp, unit, zone) }.fold( onSuccess = { ConvertOutcome.ToLocal(it) }, onFailure = { ConvertOutcome.Failure(it) }, ) // runLocalToTimestamp 同理…… fun shouldToastConvertFailure(error: Throwable): Boolean = true }值得注意的是一套双通道结果模型:转换函数不直接抛异常,而是通过ConvertOutcome(ToLocal/ToTimestamp/Failure)表达结果;UI 层对ConvertOutcome.Failure统一走notifyTimeConvertFailure,成功时写session.notice并调用container.toastSuccess(...)。
UI 接线:convertToLocal / convertToTimestamp / notifyTimeConvertFailure
在 TimeConvertScreen.kt 中可以看到完整调用链:
private fun convertToLocal(container: AppContainer, session: TimeSession, refresh: () -> Unit) { when (val outcome = TimeWiringPresentation.runTimestampToLocal(session.timestamp, session.unit, session.zone)) { is TimeWiringPresentation.ConvertOutcome.ToLocal -> { session.localTime = result.localTime session.error = "" val notice = container.t("time.notice.toLocal", mapOf("zone" to session.zone)) session.notice = notice container.toastSuccess(notice) // 成功 → success toast saveHistory(...) } is TimeWiringPresentation.ConvertOutcome.Failure -> { notifyTimeConvertFailure(container, session, outcome.error) } ... } refresh() } private fun notifyTimeConvertFailure(container: AppContainer, session: TimeSession, error: Throwable) { session.notice = "" val message = messageFor(container, error) session.error = message if (TimeWiringPresentation.shouldToastConvertFailure(error)) { container.toastError(message) // 失败 → error toast } }这里有三层反馈信息协同:
session.error:行内错误文本(红色文字,见session.error.isNotEmpty()时的渲染分支);session.notice:行内提示文本;container.toastError(message)/container.toastSuccess(notice):全局 toast。
错误分类与文案映射
messageFor将TimeException.code映射为 i18n 文案键:
| 错误码 | 场景 | 文案键 |
|---|---|---|
dst-gap | DST(夏令时)转换空洞 | time.error.dstGap |
dst-overlap | DST 重叠时段 | time.error.dstOverlap |
invalid-local-time | 本地时间非法 | time.error.localTime |
| 其他 / null | 时间戳非法等 | time.error.timestamp |
对应实现见 TimeConvertScreen.kt。
CSS 切片:mooTimeConvertActions
DIFF-585 中 F18 的布局切片为mooTimeConvertActions,定义在 Controls.kt:
fun Modifier.mooTimeConvertActions(): Modifier = heightIn(min = 34.dp)它约束时间转换操作行("转本地时间" / "转时间戳" 按钮行)的最小高度为 34dp,与同一界面中mooTimeCurrentBand、mooTimeQuickZones等布局修饰符一起构成 F18 的"CSS 切片"。在实际界面中该行用于放置两个转换按钮,按钮分别受timestampToLocalActionEnabled/localToTimestampActionEnabled控制启用状态。
F10 Hosts 配置:notifyHostFailure的多场景失败 toast
DIFF-585 中 F10 的范围是notifyHostFailure,覆盖保存 / 重命名 / 读系统 Host / 应用预览 / 恢复 / 应用失败时的 toast。该函数位于 HostScreen.kt:
private fun notifyHostFailure( container: AppContainer, session: HostSession, message: String, error: Throwable = IllegalStateException(message), io: Boolean = false, onPersist: () -> Unit, ) { session.error = message val shouldToast = if (io) { HostWiringPresentation.shouldToastIoFailure(error) } else { HostWiringPresentation.shouldToastOperationFailure(error) } if (shouldToast) { container.toastError(message) } onPersist() }关键设计点:
- IO 与非 IO 分类:通过
io参数选择shouldToastIoFailure或shouldToastOperationFailure两个判定入口,它们在 HostWiringPresentation.kt 中实现:fun shouldToastOperationFailure(error: Throwable): Boolean = true fun shouldToastIoFailure(error: Throwable): Boolean = true - 统一兜底:失败后总调用
onPersist(),即persist()(sessionManager.bump()+persistHost()),保证即使失败会话状态也能落盘。 - 错误码→文案:
messageFor将HostException.code映射到 i18n 键(host.error.invalid/empty/conflict/permission/missing/generic),并尝试附带error.message细节,见 HostScreen.kt。
调用场景列举
在 HostScreen.kt 中notifyHostFailure被大量接线:
- 保存配置时
nextName.isEmpty()→container.t("host.error.name")(L144); - 保存写盘失败 →
it.message ?: container.t("host.error.generic")(L160); - 应用预览构建、应用、恢复、重命名、读系统 Hosts 等分支均有对应调用(L314、L341、L403、L450、L485、L627、L658、L672 等处)。
此外 Host 模块的busy 守卫(应用/恢复进行中禁止再次触发)由HostWiringPresentation.canConfirmApply/canOpenApplyConfirm/canRestoreBackup提供,与 toast 链路共同保证操作并发安全。
F01 随手记:保存失败 toast 与 Vault 切换守卫
quickNoteSaveCurrent的成功/失败双向 toast
DIFF-585 中 F01 的范围是quickNoteSaveCurrent在showToast时对保存失败弹 toast,以及quickNoteVaultSaveIfNeeded失败 toast。核心实现在 QuickNoteVaultContext.kt:
internal fun quickNoteSaveCurrent( container: AppContainer, session: QuickNoteSession, vault: NoteVault, monitor: VaultRevisionMonitor?, showToast: Boolean = true, onConflict: (VaultConflictState) -> Unit, ): Boolean { val name = session.currentFile if (name.isBlank()) { session.error = session.error.ifBlank { container.t("quickNote.saveNoSelection") } return false } // 冲突检测:canOverwrite(session.savedText, diskBody, session.editor.text) if (!VaultConflictEngine.canOverwrite(...)) { onConflict(VaultConflictState(name, session.editor.text, diskBody, disk == null)) session.error = container.t("vault.conflict.blocked") return false } return runCatching { vault.saveNote(name, session.editor.text, metadata) } .onSuccess { document -> session.savedText = document.content session.error = "" if (showToast) { session.notice = container.t("quickNote.saved") container.toastSuccess(container.t("quickNote.saved")) // 成功 toast container.history.save(...) } ... } .onFailure { notifyQuickNoteOperationFailure(container, session, it, toast = showToast) // 失败 toast } .isSuccess }关键点:
showToast开关:用户主动保存(工具栏 / Ctrl+S)传默认true,250ms 空闲自动保存走quickNoteIdleAutosaveAttempt传false(见 QuickNoteVaultContext.kt)。这样自动保存失败只写session.error、不打扰用户,与 Electron/JSON 侧jsonVaultIdleAutosaveAttempt对齐。- 三态失败路径:未命名文档(
name.isBlank()→quickNote.saveNoSelection)、冲突阻塞(vault.conflict.blocked)、写盘异常(notifyQuickNoteOperationFailure)。
quickNoteVaultSaveIfNeeded与保存守卫
quickNoteVaultSaveIfNeeded是 Vault 树切换前的统一保存守卫,见 QuickNoteVaultContext.kt:
internal fun quickNoteVaultSaveIfNeeded( container: AppContainer, session: QuickNoteSession, vault: NoteVault, monitor: VaultRevisionMonitor?, onConflict: (VaultConflictState) -> Unit, ): Boolean { val ok = quickNoteSaveIfNeeded(quickNoteDirty(session)) { quickNoteSaveCurrent(container, session, vault, monitor, onConflict = onConflict) } if (!ok) { notifyQuickNoteSaveGuardFailure(container, session) } return ok }它的作用是:在选中目录/文件切换、Vault GitonFlush等场景,先判定当前笔记是否 dirty,脏则保存;保存失败则通过notifyQuickNoteSaveGuardFailure弹失败 toast 并阻止切换(调用方回滚session.vaultSelectedPath),从而保证脏数据不丢失、切换不静默失败。Vault GitonFlush场景还配套gitFlushSkipsWhenClean跳过干净文档的逻辑(同文件 L313-L332)。
toast 判定与消息组装
QuickNote 的 toast 判定集中在 QuickNoteWiringPresentation.kt:
fun operationFailureMessage(fallbackKey: String, error: Throwable?, t: (String) -> String): String = error?.message?.takeIf { it.isNotBlank() } ?: t(fallbackKey) fun shouldToastOperationFailure(): Boolean = true fun shouldToastSaveFailure(): Boolean = true fun shouldToastIoFailure(error: Throwable): Boolean = true三条shouldToast*全部返回true,意味着 DIFF-585 中 F01 的策略是操作失败、保存失败、IO 失败一律 toast,但自动保存场景通过showToast=false显式跳过。
证据测试:TimeConvertActionsCaptureTest与帧 180
DIFF-585 的证据产出是 Compose 帧180-compose-time-to-local-tab-focus.png,由 TimeConvertActionsCaptureTest.kt 生成。该测试名为captureTimeToLocalTabFocus,逻辑要点:
- 用
runDesktopComposeUiTest(width = 360, height = 72)模拟一个仅含 "转本地时间" 按钮的微窗口; - 以
mooTimeConvertActions()修饰按钮行、p5Toolbar = true取工具栏尺寸; - 通过
FocusRequester请求焦点,验证 Tab 焦点落在按钮上; - 截图写入
docs/evidence/2026-09-15-inspector-screencapture/windows/180-compose-time-to-local-tab-focus.png; - 用
countRingPixels统计焦点环色值0x316DC0的像素数(>= 8断言通过),证明焦点高亮环真实渲染。
这类"像素级断言"是 MooTool Next Compose 在无真实窗口环境下验证 Compose UI 帧渲染的典型手法,也解释了 DIFF 文档中"帧 180"证据的由来:它不是产品截图,而是由该测试自动生成的 UI 帧样本。
验证:JDK 21 离线测试
DIFF-585 文档给出的验证命令为:
./gradlew :composeApp:desktopTest --offline前提是 JDK 21。该命令在 next-compose 工作区根目录执行,运行composeApp模块的全部 desktop 测试(含TimeWiringPresentationTest、HostWiringPresentationTest、QuickNoteWiringPresentationTest、TimeConvertActionsCaptureTest等),--offline表示不访问网络仓库。在next-compose/docs/acceptance.md的历史记录中可以看到,composeApp:desktopTest在 JDK 21、--offline条件下规模约 1300+ 用例(例如 DIFF-669 轮次为 1342/1344,含 2 个被跳过的公网 smoke 测试),说明该验证入口是全模块回归的标准手段。
小结
DIFF-585 揭示的失败反馈链路可归纳为一条可复用的模板:
- Presentation 层判定:
*WiringPresentation中的shouldToast*/run*函数用runCatching{}.fold()封装业务引擎(TimeEngine、HostEngine、NoteVault),返回 sealed outcome; - Session 状态写入:失败写
session.error(行内红色提示)、成功写session.notice,并同步清空另一者; - Toast 触发:
shouldToast*返回true时调用container.toastError(message)/container.toastSuccess(notice),IO 失败与操作失败分类判定; - 持久化兜底:Host 场景失败后仍执行
persist(),QuickNote 场景失败后阻止 Vault 切换并回滚选择路径。
时间转换(F18)、Hosts 配置(F10)、随手记(F01)三个模块共享同一套反馈范式,差异仅在业务引擎与错误码映射。后续 DIFF 文档(如 DIFF-598、DIFF-592、DIFF-591)在此基础上持续扩展 toast 覆盖面,可作为继续跟踪该链路的入口。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool Next Compose 调色板失败 Toast 体系:DIFF-588 切片解析
MooTool Next Compose 调色板失败 Toast 体系:DIFF 588 切片解析 MooTool 是基于 Compose Multiplatf
桌面应用开发工具MooTool Compose 失败反馈可见性工程实践:F10/F08/F02 导入导出 error toast 与 Vault MCP 双库 notes→json 读取链(DIFF-577 剖析)
MooTool Compose 失败反馈可见性工程实践:F10/F08/F02 导入导出 error toast 与 Vault MCP 双库 notes→js
桌面应用开发工具GitBook 头部 Section 分组下拉中的“松散 Section”排序逻辑与实现解析
GitBook 头部 Section 分组下拉中的“松散 Section”排序逻辑与实现解析 本篇指南聚焦 GitBook 开源前端仓库(packages/gi
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考