ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MooTool next-compose:统一 F23 图片工具与 F09 HTTP 响应的 `tools.exportDirectory` 导出目录切片(DIFF-659)

MooTool next-compose:统一 F23 图片工具与 F09 HTTP 响应的 `tools.exportDirectory` 导出目录切片(DIFF-659)
  • 桌面应用
  • 开发工具

【免费下载链接】MooTool

Handy tool set for developers

项目地址:https://gitcode.com/gh_mirrors/mo/MooTool
点击查看免费下载

本篇围绕 MooTool 的 Compose Multiplatform 移植版(next-compose)中的一次功能切片展开:将图片工具(F23)与 HTTP 请求工具(F09)分散在各处的裸FileDialog文件选择,统一收敛到「导出目录感知」的共享辅助函数上,并通过共享设置项tools.exportDirectory实现"上次选择的目录成为下次默认目录"。读完后你可以理解该切片的改动范围、三个核心函数的调用链与参数行为、设置项的存储与回退机制,以及切片自带的离线验证方式。

1. 切片定位:基线、范围与验证方式

该切片的原始记录位于 DIFF-659,其基线是 DIFF-658(F24 PDF 切片)。两个切片属于同一批次工作:让各功能模块(F24 PDF、F23 图片、F09 HTTP 等)的导入/导出文件选择行为保持一致。

切片范围完整继承原文档的三条要点:

  1. F23(图片助手):导入多选、批量导出目录、SVG 单文件/多目录另存,统一改用chooseFilesWithExportDirectory/chooseFileWithExportDirectory+persistToolsExportDirectory,移除原来直接使用裸FileDialog的导入与单文件 SVG 另存逻辑;
  2. F09(HTTP 请求):响应另存(正文二进制/文本 Tab)改用chooseFileWithExportDirectory,并在写入成功后调用persistToolsExportDirectory,移除裸FileDialog;
  3. 共享: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作为初始目录
savetrue时构造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 } } }

两个设计点:

  1. 文件选择统一:pickHttpResponseSaveFile 就是chooseFileWithExportDirectory的薄封装,二进制 Tab 默认文件名http-response.bin,文本 Tab 为http-response.txt,初始目录来自tools.exportDirectory。
  2. "成功后才持久化"的时序:原文档要求 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.sh

desktopTest目标覆盖 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

项目地址:https://gitcode.com/gh_mirrors/mo/MooTool
点击查看免费下载

相关推荐

上一篇:Anthropic's Prompt Engineering Interactive Tutorial离线使用:本地部署方法
下一篇:MetaMask Extension版本控制策略:Git工作流与分支管理

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进