- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
本篇围绕 MooTool 的 Compose Multiplatform 移植版(next-compose)中的一次功能切片展开:将图片工具(F23)与 HTTP 请求工具(F09)分散在各处的裸FileDialog文件选择,统一收敛到「导出目录感知」的共享辅助函数上,并通过共享设置项tools.exportDirectory实现"上次选择的目录成为下次默认目录"。读完后你可以理解该切片的改动范围、三个核心函数的调用链与参数行为、设置项的存储与回退机制,以及切片自带的离线验证方式。
1. 切片定位:基线、范围与验证方式
该切片的原始记录位于 DIFF-659,其基线是 DIFF-658(F24 PDF 切片)。两个切片属于同一批次工作:让各功能模块(F24 PDF、F23 图片、F09 HTTP 等)的导入/导出文件选择行为保持一致。
切片范围完整继承原文档的三条要点:
- F23(图片助手):导入多选、批量导出目录、SVG 单文件/多目录另存,统一改用
chooseFilesWithExportDirectory/chooseFileWithExportDirectory+persistToolsExportDirectory,移除原来直接使用裸FileDialog的导入与单文件 SVG 另存逻辑; - F09(HTTP 请求):响应另存(正文二进制/文本 Tab)改用
chooseFileWithExportDirectory,并在写入成功后调用persistToolsExportDirectory,移除裸FileDialog; - 共享:
persistToolsExportDirectory在用户选择目录时写入该目录路径(而非仅写其 parent 目录)。
验证方式(原文档原文保留):
# JDK 21 ./gradlew :composeApp:desktopTest --offline --no-daemon ./scripts/verify-product-evidence-prep.sh其中第一条从next-compose根目录执行,运行 desktop 目标的单元测试(--offline --no-daemon保证在离线 CI 环境下可复现);第二条位于 verify-product-evidence-prep.sh,用于校验产品级证据准备脚本。
2. 设置项tools.exportDirectory:存储结构与合法性回退
这个设置项定义在公共模型 ToolSettings 中:
@Serializable data class ToolSettings( val qrCodeSize: Int = 300, val qrErrorCorrection: String = "M", val randomStringLength: Int = 16, val exportDirectory: String = "", // 空串 = 使用产品默认导出目录 val translationProvider: String = "google", // ... )默认值为空字符串,语义是"使用产品默认目录"。用户可以在设置界面显式修改该值:SettingsScreen.kt 中的DirectorySettingRow直接读写settings.tools.exportDirectory,并在其上方展示settings.tools.exportDirHint提示文案。
配置值并非被无条件信任。所有读取方都先经过 VaultPathConfig 的归一化:
fun normalizedCustomRoot(value: String): String? { val trimmed = value.trim() if (trimmed.isEmpty()) return "" // 空值 = 保持产品默认 val path = runCatching { Path.of(trimmed).normalize() }.getOrNull() ?: return null if (!path.isAbsolute) return null // 相对路径视为非法 return path.toString() } /** 非法或相对的配置路径回退到产品默认 vault。 */ fun effectiveCustomRoot(value: String): String = normalizedCustomRoot(value) ?: ""这保证了:空配置 → 走默认;非法/相对路径配置 → 静默回退到默认,而不是抛错或写出到意外位置。
当设置项为空或非法时,"产品默认目录"的取值逻辑在 ToolsExportWiringPresentation 中,注释明确说明其对齐 Electron Desktop 的回退策略:
/** F10/F22/F24 外置导出/合并初始目录(对齐 `tools.exportDirectory` + Electron Desktop 回退)。 */ fun defaultExportDirectory(exportDirectorySetting: String): File { val export = VaultPathConfig.effectiveCustomRoot(exportDirectorySetting) val desktop = File(System.getProperty("user.home"), "Desktop").takeIf { it.isDirectory } ?: File(System.getProperty("user.home")) return export.takeIf { it.isNotBlank() }?.let { File(it) }?.takeIf { it.isDirectory } ?: desktop }即:有效配置的目录(且确实存在)→ 否则~/Desktop(若为目录)→ 否则用户主目录。该对象还提供两个派生默认值,供调用方预填文件名:defaultSvgPath(exportDirectorySetting, assetBaseName)生成<导出目录>/<图名>.svg,defaultHostExportFileName(profileName)将非法字符替换为-生成<方案名>.txt。
3. 共享辅助函数:ToolsExportDirectory.kt的三个入口
三个函数全部集中在 ToolsExportDirectory.kt,签名与行为如下。
3.1persistToolsExportDirectory:写入"用户选择的目录"
fun persistToolsExportDirectory(container: AppContainer, file: File) { val parent = when { file.isDirectory -> file.absolutePath.takeIf { it.isNotBlank() } else -> file.parentFile?.absolutePath?.takeIf { it.isNotBlank() } } ?: return if (!java.nio.file.Path.of(parent).isAbsolute) return if (container.settings.value.tools.exportDirectory == parent) return container.updateSettings { current -> current.copy(tools = current.tools.copy(exportDirectory = parent)) } }逐行对应原文档"写入该目录路径(非仅 parent)"的要求:
- 参数本身是目录时(批量导出选择目录的场景),直接采用
file.absolutePath,而不是再取一层 parent; - 参数是文件时(另存场景),取其
parentFile绝对路径; - 非绝对路径直接放弃写入(与
VaultPathConfig的合法性约束一致); - 与当前设置值相同时跳过更新,避免无意义的 settings 变更与持久化。
3.2chooseFileWithExportDirectory:单文件选择(LOAD/SAVE 双模式)
fun chooseFileWithExportDirectory( container: AppContainer, save: Boolean, title: String, defaultFileName: String = "", ): File? { val exportDir = VaultPathConfig.effectiveCustomRoot(container.settings.value.tools.exportDirectory) val dialog = FileDialog(null as Frame?, title, if (save) FileDialog.SAVE else FileDialog.LOAD) if (defaultFileName.isNotBlank()) { dialog.file = defaultFileName } if (exportDir.isNotBlank()) { val dir = File(exportDir) if (dir.isDirectory) { dialog.directory = exportDir } else { dir.parentFile?.takeIf { it.isDirectory }?.let { dialog.directory = it.absolutePath } } } dialog.isVisible = true val directory = dialog.directory ?: return null val name = dialog.file ?: return null return File(directory, name) }参数说明:
| 参数 | 含义 |
|---|---|
container | 应用容器,用于读取settings.tools.exportDirectory作为初始目录 |
save | true时构造FileDialog.SAVE(另存),否则FileDialog.LOAD(打开) |
title | 对话框标题 |
defaultFileName | 预填文件名(SAVE 模式下引导用户),为空则不预填 |
两处防御值得注意:配置目录存在性检查失败时(配置的目录已被删除),会回退到其父目录(若父目录存在)作为初始位置,而不是让用户面对失效路径;用户取消时dialog.file为 null,函数返回 null 而不是空File。
3.3chooseFilesWithExportDirectory:多选 LOAD
fun chooseFilesWithExportDirectory( container: AppContainer, title: String, fileFilter: String = "", ): List<File>- 通过
dialog.isMultipleMode = true启用多选;fileFilter非空时作为初始文件过滤串(对应原文档 F23"导入多选"场景); - 初始目录的解析与回退逻辑与单文件版完全一致(复用
effectiveCustomRoot); - 结果解析先读
dialog.files多值,为空时再降级读取单值dialog.directory + dialog.file,兼容某些平台/桌面环境下多选模式只回填单值的行为;用户取消返回emptyList()。
4. F23 图片工具侧的接入
调用方在 ImageScreen.kt。三处调用链与原文档范围逐条对应。
4.1 导入多选:importFiles
private fun importFiles(container: AppContainer, session: ImageSession, onLoaded: (String?) -> Unit) { val picked = chooseFilesWithExportDirectory(container, container.t("image.import")) val files = picked.filter { ImageLibraryStoreSafe(it.extension) } if (files.isEmpty()) return files.firstOrNull()?.let { persistToolsExportDirectory(container, it) } importPickedFiles(container, session, files, onLoaded) }要点:多选加载后经ImageLibraryStore.supported(extension)过滤出受支持扩展名的文件;取第一个选中文件调用persistToolsExportDirectory(其 parent 即用户本次浏览的目录);拖拽导入路径(desktopFileDropTarget)不触发该持久化,只走importPickedFiles。
4.2 批量导出目录:exportSelected
private fun exportSelected(container: AppContainer, session: ImageSession, names: List<String>) { val directory = chooseDirectory(container, container.t("image.export")) ?: return persistToolsExportDirectory(container, directory) // ImageWiringPresentation.runExportAssets { container.imageLibrary.export(names, directory.toPath()) } // 成功:toast + lastOutputs;失败:notifyImageFailure }这里chooseDirectory(JFileChooser的DIRECTORIES_ONLY模式,见 ImageScreen.kt#L1196-L1203)用ToolsExportWiringPresentation.defaultExportDirectory(...)预填初始目录,因此批量导出对话框也会落在上次使用的导出目录上;用户选定目录后传入的是目录本身,正好命中persistToolsExportDirectory中file.isDirectory分支——即"写入该目录路径(非仅 parent)"这条共享要求在实际调用中的体现。
4.3 SVG 另存:单文件与多目录两条路径
chooseSvgTargets 按选中图数量分叉:
- 单张图:用
ToolsExportWiringPresentation.defaultSvgPath(...)预填<导出目录>/<图名>.svg作为默认文件名,调chooseFileWithExportDirectory(save = true, ...);若用户保存时去掉了.svg扩展名,会自动补回(File(chosen.path + ".svg")),随后persistToolsExportDirectory(container, file)。这就是原文档"SVG 单文件另存统一chooseFileWithExportDirectory"; - 多张图:走
chooseDirectory选一个目标目录,persistToolsExportDirectory持久化后,为每张图生成<图名>.svg,并以内置去重逻辑处理冲突:reserved集合防同批重名、candidate.exists()防与既有文件冲突,冲突时递增为<图名>_2.svg、<图名>_3.svg……
实际写盘在vectorize协程中完成:写成功才记入written,批量失败时删除已写出的部分文件(if (targets.size > 1) written.forEach { deleteIfExists }),保证批量另存不留半成品——这与 feature-parity.md 中 F23 的"写失败不损坏原件"验收项相互印证。
5. F09 HTTP 响应侧的接入
调用方在 HttpScreen.kt。saveResponse按当前响应 Tab 分两条写入路径:
fun saveResponse(visible: HttpResponseResult?) { val bytes = HttpEngine.downloadBytes(visible) if (bytes != null && session.responseTab == HttpResponseTab.Body) { // 二进制:按原始字节另存 val target = pickHttpResponseSaveFile(container, binary = true) ?: run { /* 取消提示 */ return } when (val outcome = HttpResponsePresentation.runWriteResponseBytes(target, bytes)) { is HttpResponsePresentation.WriteExportOutcome.Success -> { persistToolsExportDirectory(container, target) // 写入成功后才持久化目录 // toast:http.responseSaved(path) } is HttpResponsePresentation.WriteExportOutcome.Failure -> { /* 报错,不持久化 */ return } } return } // 文本:Headers/Cookies 等 Tab 的 payload val target = pickHttpResponseSaveFile(container, binary = false) ?: run { /* 取消 */ return } when (val outcome = HttpResponsePresentation.runWriteResponseText(target, payload)) { is WriteExportOutcome.Success -> { persistToolsExportDirectory(container, target); /* toast */ } is WriteExportOutcome.Failure -> { /* 报错,不持久化 */ return } } }两个设计点:
- 文件选择统一:pickHttpResponseSaveFile 就是
chooseFileWithExportDirectory的薄封装,二进制 Tab 默认文件名http-response.bin,文本 Tab 为http-response.txt,初始目录来自tools.exportDirectory。 - "成功后才持久化"的时序:原文档要求 F09 是"成功后
persistToolsExportDirectory"。从源码看,persistToolsExportDirectory被放在WriteExportOutcome.Success分支内——若磁盘写入失败(例如权限不足、路径不可写),用户上次有效的导出目录不会被一个失败的另存操作覆盖。这与图片侧"选定目标即持久化、写盘失败另处理"的时序略有差异,属于按模块风险取舍的写法。
写入本身收敛到 HttpResponsePresentation 的两个可测试函数:runWriteResponseText以 UTF-8writeText,runWriteResponseBytes以writeBytes保留原始字节(对齐 F09 在 feature-parity.md 中"二进制或无法解码时保留原始字节供另存,不把预览当文件"的约束)。返回值是Success / Failure(error)sealed 结果,UI 侧据此决定 toast 与错误提示,测试可直接对纯函数断言。
6. 行为对照:改动前后的可验证差异
| 场景 | 改动前(裸FileDialog) | 改动后(本切片) |
|---|---|---|
| 图片导入(F23 多选) | 对话框从系统默认位置打开 | 从上次使用的导出目录打开;选中后该目录被持久化 |
| 图片批量导出(F23) | 各工具目录记忆不互通 | chooseDirectory以tools.exportDirectory为初始目录;选中目录直接写入设置 |
| SVG 单文件另存(F23) | 无默认文件名引导 | 预填<导出目录>/<图名>.svg;丢失的.svg扩展名自动补回 |
| 响应另存(F09 二进制/文本) | 每次重新选择目录 | chooseFileWithExportDirectory预填目录;仅写盘成功后更新设置 |
| 非法/被删除的配置目录 | —— | effectiveCustomRoot回退默认;对话框再回退到该目录的父目录 |
上述"目录记忆"是跨工具生效的:设置项位于全局ToolSettings,因此任意一个已接入的工具(如 PdfScreen.kt、QrCodeScreen.kt 等同样引用这三个辅助函数的模块)更新目录后,其他工具的下次选择都会以该目录为起点。
7. 验证与复现
切片的回归手段即原文档给出的两条命令,适用前提是 JDK 21 与离线依赖齐全(--offline):
# 在 next-compose 根目录执行 ./gradlew :composeApp:desktopTest --offline --no-daemon ./scripts/verify-product-evidence-prep.shdesktopTest目标覆盖 desktop 源集的单元测试;从源码结构看,本切片涉及的两个纯函数域(HttpResponsePresentation的写盘结果模型、ToolsExportWiringPresentation的默认路径推导)都是可离线断言的对象,与仓库中其他切片的测试风格一致。若只想人工核对行为,可按第 6 节对照表逐项操作:在设置中显式填写一个绝对路径目录 → 图片导入/另存/HTTP 响应另存均应落在该目录;把该目录删除后再触发选择 → 对话框应回退到其父目录或产品默认目录,且不崩溃。
8. 小结
DIFF-659 是一个典型的"横向切片":不新增功能,而是把 F23、F09 两个模块中语义相同的文件选择行为收敛到 ToolsExportDirectory.kt 的三个共享函数上,并借tools.exportDirectory这一全局设置项(VaultPathConfig归一化 +ToolsExportWiringPresentation桌面回退)实现跨工具的目录记忆。理解这条链路后,后续阅读 DIFF-660(F14 Crypto 摘要)、DIFF-661(F05/A01 备份 IO) 等同一批次切片时,可直接套用本文第 3、4 节的调用链模式。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool next-compose:配置转换工具的 `*ActionEnabled` 守卫接线与 `tools.exportDirectory` 统一导出链
MooTool next compose:配置转换工具的 ActionEnabled 守卫接线与 tools.exportDirectory 统一导出链 本篇基
桌面应用开发工具openclaw-cn 浏览器自动化 CLI 完全指南:clawdbot browser 的配置、标签页管理、快照操作与远程控制
openclaw cn 浏览器自动化 CLI 完全指南:clawdbot browser 的配置、标签页管理、快照操作与远程控制 clawdbot browse
桌面应用开发工具MooTool Compose 版二维码导出目录迁移:从 Java `qrCodeSaveAsPath` 到统一 `tools.exportDirectory` 的 DIFF-189 实战解析
MooTool Compose 版二维码导出目录迁移:从 Java qrCodeSaveAsPath 到统一 tools.exportDirectory 的 D
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考