正文
大家好,我是 Rain9。
先上链接,后面都是干货:
-
GitHub:github.com/you-want/Lu… (MIT,免费)
-
下载:github.com/you-want/Lu… (macOS universal + Windows x64)
Luma
Luma 是一个本地优先的文件空间分析与整理工具:
-
扫描你选定的目录,只读元数据,在本机建 SQLite 索引
-
空间分类、大文件、重复文件候选、开发项目识别、历史对比
-
FTS5 + BM25 的本地搜索,支持"大于 1GB 的最近一年的 PDF"这类确定性条件
-
文件管理器支持重命名、移动、复制、废纸篓
-
规则整理走 dry-run:先生成完整清单,你确认才动手,事后可撤销(重启也能撤)
-
中英文界面,macOS / Windows 双平台
没有 AI,不上传任何东西,也没有后台常驻。今天主要聊聊功能设计和开发中踩的坑。
为什么做这个
市面上的磁盘工具大多分两类:一类只能"看",看完大文件还得自己一个个去处理;一类敢"动手",但让一个工具直接动我的文件,我是拒绝的——它凭什么保证不出事?
所以 Luma 的核心思路就一句话:**把"看清楚"和"动手"放进同一个工作区,中间隔着一层预览和确认,动手之后还能反悔。**
功能速览
扫描与空间分析
选一个目录递归扫描,只读元数据(名称、扩展名、大小、修改时间),不跟随符号链接,可以随时取消。单个文件没权限就计入错误数,不会中断整个任务。
扫完有分类分段条、Top 20 大文件、"值得留意"发现(超大文件、长期未动、开发构建内容、压缩包、安装包五类)。每一类都能下钻看明细,汇总数字和明细列表走的是同一套判定规则,不会出现"总数 32 个点开只有 25 个"的尴尬。
搜索:FTS5 + BM25 + 确定性条件
关键词走 SQLite FTS5 external-content 索引,BM25 排序,文件名权重高于路径。中文和特殊字符走安全的字面量匹配兼容路径——用户输入永远不会被当成 FTS 操作符。
比较有意思的是条件解析:
"大于 1GB 的最近一年的 PDF" → size_bytes > 1GB AND mtime >= 一年前 AND extension = 'pdf'
注意这是纯规则解析,不是大模型——它把自然语言条件变成绑定参数和静态 SQL,可解释、可测试。解析器只认一组确定性模式(大于/小于 + 容量单位、最近一年、PDF、下载目录、代码项目),解析不出来的部分就当普通关键词,不会瞎猜。
重复文件:先比大小,再算哈希
先按文件大小筛出候选,再对候选算 BLAKE3 内容哈希,只对确认同内容的文件分组展示。可节省空间是按组去重后算的,不会把两个 10MB 重复文件算成 20MB。同样,只展示候选,不自动删。
规则整理:dry-run → 确认 → 可撤销
这是整个产品的核心链路。提供桌面 / 下载预设方案,也可以按类型、修改日期、扩展名、文件名关键词归类。流程是:
-
生成完整 dry-run 计划(预览阶段不碰文件系统)
-
预览清单:原路径 → 目标路径、归类依据,可以排除单个文件、改分类名,同名冲突选跳过或自动编号
-
确认后执行,成功项逐条写入 SQLite 操作日志
-
任何时候可撤销,撤销记录持久化,重启应用后依然有效
本地存储管理
v0.4.1 加的:Luma 自己的索引也占磁盘,所以应用里可以直接看索引占用、回收空闲空间、清理扫描索引。策略是只保留最近一次成功扫描的完整文件索引,历史扫描降级为统计摘要;单次扫描的索引写入有 256 MiB 预算,超限保留上次成功结果。一个管存储的工具,自己也得管好自己的存储。
工程结构
前端 React 19 + TypeScript + Vite,useSyncExternalStore 轻量 store,i18next 双语
后端 Rust + Tauri 2
scanner.rs walkdir 遍历 + 分类
database.rs SQLite schema(v5)、FTS5、查询
search.rs SearchProvider 抽象 + 条件解析
duplicates.rs 大小预筛 + BLAKE3
file_ops.rs 文件操作 + 操作日志 + 撤销状态机
organizer.rs 规则整理 dry-run
cleanup.rs 清理建议
存储 rusqlite(bundled SQLite),FTS5 external content + 触发器同步
扫描在 Rust 阻塞任务里跑,文件按 500 条一批写入 SQLite,进度约 100ms 节流一次通过事件推给前端。前端不加载完整文件表,分页和聚合都在数据库做。
性能基线(本机 debug、10 万条合成记录,仅供参考不构成承诺):索引写入约 16.6s,关键词搜索约 4.5ms,数据库约 31 MiB。
踩坑实录(这部分最有价值)
坑 1:中文路径 + SQLite SUBSTR,字节数和字符数打架
目录树查询需要用"父路径长度"去 SUBSTR 截出相对路径。我最初用 Rust 的 str.len()——它返回字节数;而 SQLite 的 SUBSTR 按字符数截。ASCII 下两者相等,所以英文测试目录全绿。
换成中文路径就露馅了:/Users/me/项目/child/file.txt 应该截出 child/file.txt,实际截出了 d/file.txt。
修复很朴素:parent_path.chars().count(),然后补上中文、emoji、同前缀目录的边界测试。教训是:涉及路径的字符串运算,字节和字符必须全链路统一口径,测试夹具里必须有非 ASCII 路径。
坑 2:LIKE 前缀没转义 % 和 _
拼接 path LIKE ?/ 前缀时没转义通配符,虽然写了 ESCAPE '\',参数自己不带转义等于白写——目录 /work/a_c 的查询会匹配到 /work/abc。这不是注入,是查询语义错误,更隐蔽。修法是入库前统一 escape_like()。
坑 3:撤销中断后,记录永久卡死
撤销开始时会把操作状态写成 undoing。如果进程恰好死在这个窗口:启动恢复逻辑只处理 running,不认 undoing;再试撤销又撞上 OP_BUSY;可撤销列表也不显示它。一条记录就这么永久卡住了。
修复是加了 reconcile_interrupted_operations():启动时把 running 和 undoing 都标记为 interrupted,而撤销实现本身是逐项持久化的,所以中断的撤销可以安全续跑。配套写了一个"模拟 kill 在状态更新之后"的测试。教训:文件系统操作和 SQLite 事务不是原子的,任何状态机都要考虑"死在两个状态之间"。
坑 4:"可释放约 30MB" 的数学问题
清理建议面板最初把各类建议的体积直接相加:两个相同的 10MB 压缩包,"压缩包"算 20MB,"重复候选"再加 10MB,合计"可释放约 30MB"——实际文件只有 20MB,删掉一个副本最多省 10MB。
分类会重叠,估算不能直接求和。现在文案改成了"待复查文件约 N",口径收敛到扫描范围内的并集,语义从"承诺能省多少"退到"这些值得你看看"。工具类产品,文案的确定性不能超过证据。
坑 5:Windows 保护路径硬编码了 c:\
系统目录保护最初匹配 c:\windows 这类前缀——Windows 装在 D 盘的用户直接失去保护。改成从路径自身解析盘符再做盘符不敏感的匹配,加了专门测试。这类"在我机器上没问题"的平台 bug,只能靠把平台差异显式写进测试。
安装与更新
安装包尚未做平台代码签名和公证(macOS Developer ID + notarization 在计划里),所以首次打开会被 Gatekeeper 拦一次:
-
macOS:系统设置 → 隐私与安全性 → 「仍要打开」
-
Windows:SmartScreen → 「更多信息 → 仍要运行」
放行一次之后不会再拦。README 里有完整引导。
应用内自动更新走 Tauri updater,更新产物有独立签名校验(和平台签名是两回事),已经有过一次真实的跨版本升级验证(v0.3.2 → v0.4.0)。
明确不做的事
划边界和做功能一样重要:
-
不做后台自动整理、不自动删除、没有永久删除(最多进废纸篓)
-
不分析文档语义——分类依据是文件名、类型、修改时间这些可解释的规则
-
暂不做 AI:等真实使用中出现"固定规则覆盖不了"的场景再说,来了也只做"把意图解析成结构化查询",不直接执行文件操作
最后
项目从 8 月开始写,最近刚发到 v0.4.1,质量门禁是前端测试 + Rust 测试 + fmt + clippy + 双平台 CI 构建全过。
Windows 真机验收还在完善,用出问题欢迎提 Issue;觉得有意思的话点个 Star,比什么都鼓励开发者 🌟