feat: v0.6.2 — sqlark 0.7.4 升级(KVStore) + 14 项缺陷修复 + 多文件搜索

- chore: 升级 @metona-team/metona-sqlark 0.4.4 → 0.7.4,存储后端迁移
  KVStore 引擎(内存索引+快照/日志,OPFS 落盘,jsdom 回退 memory),
  启动时自动删除旧 IndexedDB 库(aria-MarkLiteV2 / Dexie MarkLite),
  不做向下兼容
- fix: 保存竞态 — 快照比对后才清 isModified,防止保存期间的新输入被
  误清标记导致永不落盘(自动保存与手动保存均修复)
- fix: 另存为后标签重绑新路径(此前 Ctrl+S/自动保存仍写回旧文件),
  并同步最近文件与快照持久化
- fix: tabSwitched 只依赖活动文件路径(此前每次击键都触发 IPC 并
  重启主进程文件 watcher)
- fix: 主进程关闭兜底超时 5s→12s(长于 confirm 10s,防强杀丢编辑)
- fix: 外部修改检测覆盖非活动标签,banner 显示文件名,
  标签有未保存修改时显式提示不再静默
- fix: 导入备份后 flush + closeDatabase 再 reload(防 OPFS 未落盘丢数据),
  导入整体事务原子化(防半导入状态)
- fix: 文档大纲跳过代码块内标题;切换标签恢复光标位置
- fix: 首次启动主题跟随系统偏好(settings 无记录时 load 返回 null)
- fix: watcher error 恢复时重置 isSelfWriting,防外部修改通知被永久吞掉
- fix: 打开失败路径(最近文件/文件树/打开对话框)显式提示
- fix: 保存时保留原文件编码(UTF-16 LE/BE BOM 同编码写回)
- feat: 多文件搜索 — dir:search IPC + SearchPanel 弹层 + Ctrl+Shift+F,
  文件夹内递归搜索(大小写/正则,结果定位到行)
- feat: 标签恢复时与磁盘 mtime 比对,自动同步磁盘最新内容
- test: 新增 outlineUtils(6) / searchInDir(7) / updateTabFilePath(2) 测试
- docs: README/DESIGN 同步 kv 后端与版本号 v0.6.2
This commit is contained in:
thzxx
2026-08-15 21:18:05 +08:00
parent bbaea2df3e
commit 647d907573
38 changed files with 1191 additions and 123 deletions
+9 -6
View File
@@ -22,7 +22,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
| 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
| 持久化 | MetonaSqlark (IndexedDB) | v0.4.1 | 标签页状态 / 用户设置 / 最近文件(AriaEngine |
| 持久化 | MetonaSqlark | v0.7.4 | 标签页状态 / 用户设置 / 最近文件(AriaEngine + KVStoreOPFS 落盘;v0.6.2 起不再使用 IndexedDB,旧库启动时自动删除 |
| Markdown 解析 | MetonaEditor 内置解析器 | v0.4.0 | 零依赖,GFM + 脚注 + 数学公式 + mermaid |
| 代码高亮 | MetonaEditor 内置高亮器 | v0.4.0 | 零依赖,16 种语言 |
| Toast | @metona-team/metona-toast | v0.5.0 | 通知提示组件 |
@@ -162,11 +162,13 @@ interface SidebarState {
}
```
## 4. 数据持久化 — IndexedDB
## 4. 数据持久化 — MetonaSqlark (KVStore)
v0.5.0: 由 Dexie.js 迁移至 MetonaSqlarkAriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。
MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。
v0.6.2: sqlark 升级 0.7.4,存储后端改用自研 KVStore(内存索引 + 快照/日志,OPFS 落盘);
旧 IndexedDB 库在启动时自动删除,不做向下兼容。
### 4.1 数据库 Schema
@@ -175,11 +177,11 @@ MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
let dbPromise: Promise<MetonaSqlark> | null = null
export function getDb(): Promise<MetonaSqlark> {
if (!dbPromise) {
dbPromise = MetonaSqlark.create({
dbPromise = create({
name: 'MarkLiteV2',
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: 1,
diskEngine: 'kv', // v0.6.2: 自研 KVStore 后端(OPFS 落盘;jsdom 测试回退 memory
version: 0, // AriaEngine 忽略版本号
}).then(async (db) => {
if (!(await db.getTableNames()).includes('tabSnapshots')) {
await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... })
@@ -221,6 +223,7 @@ export function getDb(): Promise<MetonaSqlark> {
| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 |
| `dir:watch` | `dirPath` | `void` | 监听目录变化 |
| `dir:unwatch` | 无 | `void` | 停止监听目录变化 |
| `dir:search` | `{ dirPath, query, caseSensitive?, useRegex? }` | `SearchInDirResult` | v0.6.2: 文件夹内多文件内容搜索 |
### 5.2 主进程 → 渲染进程(send)
@@ -405,7 +408,7 @@ npm run build:portable # 便携版(免安装)
|------|------|------|
| react / react-dom | ^18.3 | UI 框架 |
| zustand | ^5.0 | 状态管理 |
| @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(AriaEngine |
| @metona-team/metona-sqlark | 0.7.4 | 前端关系型数据库(AriaEngine + KVStoreOPFS |
| nanoid | ^5.0 | 唯一 ID 生成 |
| @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(内置解析器 + 高亮) |
| @metona-team/metona-toast | 0.5.0 | Toast 通知组件 |
+4 -2
View File
@@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.6.1-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Version-v0.6.2-orange?style=flat-square" alt="Version">
</p>
<p align="center">
@@ -38,6 +38,7 @@
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
| 🔎 **多文件搜索** | Ctrl+Shift+F 在已打开文件夹内递归搜索内容,结果定位到行 |
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlarkAriaEngine)持久化,关闭后可恢复 |
@@ -134,6 +135,7 @@ npm run test:coverage
| `Ctrl + Shift + S` | 另存为 |
| `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 |
| `Ctrl + Shift + F` | 多文件搜索(当前文件夹内) |
| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
| `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置) |
@@ -149,7 +151,7 @@ npm run test:coverage
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.1 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + Zen 模式 + 插件系统 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.4 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 |
| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.7.4 (AriaEngine + KVStore/OPFS) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | MetonaEditor 内置解析器 | 零依赖:GFM / 脚注 / 数学公式 / Mermaid |
| 代码高亮 | MetonaEditor 内置高亮器 | 零依赖,16 种语言 |
| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v0.5.0 | 通知提示组件 |
+34 -9
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧪 在线演示 — MetonaSqlark v0.4.4</title>
<title>🧪 在线演示 — MetonaSqlark v0.7.4</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
@@ -84,7 +84,7 @@
<a href="demo.html" class="nav-active">演示</a>
<a href="benchmark.html">基准</a>
</nav>
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.4</div>
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.7.4</div>
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
</header>
@@ -92,7 +92,7 @@
<div class="main">
<div class="editor-panel">
<div class="editor-area">
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.4 在线演示
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.7.4 在线演示
-- 已预置 users / orders / products 表数据
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
@@ -134,6 +134,7 @@
<button class="btn btn-preset" onclick="loadPreset('onupdate')" style="color:#fbbf24;border-color:#fbbf24;">🔄 ON UPDATE</button>
<button class="btn btn-preset" onclick="loadPreset('repair')" style="color:#22c55e;border-color:#22c55e;">🛡 自愈</button>
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
<button class="btn btn-preset" onclick="loadPreset('maint')" style="color:#22c55e;border-color:#22c55e;">🛠 v0.5.1 维护语句</button>
</div>
</div>
</div>
@@ -554,13 +555,13 @@ SELECT COUNT(*) as total FROM temp_logs;
-- 清理
DROP TABLE temp_logs;`,
aria: `-- 🌲 AriaEngine 演示 (v0.4.4)
aria: `-- 🌲 AriaEngine 演示 (v0.7.4)
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter
-- 基础 CRUD 与 Memory 引擎完全兼容
-- 当前引擎确认
-- (内存中 Aria 引擎实例,数据经 WAL + SSTable 持久化到 IndexedDB)
-- (Aria 引擎实例,数据经 WAL + SSTable 持久化到 OPFS)
CREATE TABLE IF NOT EXISTS tasks (
id STRING PRIMARY KEY,
title STRING NOT NULL,
@@ -589,18 +590,42 @@ DROP TABLE temp_logs;`,
-- AriaEngine 特性:
-- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引
-- • WAL: 原子写入 + 崩溃恢复(残缺 SSTable 打开自动跳过)+ 批量组提交
-- • WAL: 分片文件 + 标准 CRC32 + 空洞检测 + 崩溃恢复(残缺 SSTable 自动跳过)
-- • MVCC: 版本链 + 快照隔离
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
-- • 页面化物理存储: SSTable 4KB 页面 + BufferPool LRU 缓存 (v0.5.0)
-- • 完整性: 整文件 CRC-32 校验(数据腐坏自动清理自愈)
-- • Bloom Filter: FNV-1a + Murmur 双哈希
-- • 二级索引: 每列独立 LSM + 跨重启自动恢复
-- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT (v0.4.2)
-- • 自愈: db.repair() 无需删库重建 (v0.4.2)
-- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT
-- • 自愈: db.repair() 无需删库重建(含孤儿页面/残留清理)
-- • 维护语句: EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT (v0.5.1)
-- 生产环境 API(与演示页右上角切换等价)
-- const db = await MetonaSqlark.create({
-- name: 'my-app', mode: 'aria'
-- });`,
maint: `-- 🛠 维护语句 (v0.5.1)
-- EXPLAIN: 输出查询计划(任何引擎可用)
EXPLAIN SELECT * FROM users WHERE age > 18;
-- ANALYZE: 收集表统计信息(仅 Aria 引擎)
ANALYZE TABLE users;
-- REINDEX: 重建二级索引(仅 Aria 引擎)
REINDEX TABLE users;
-- VACUUM: 压缩 LSM + 清理碎片(仅 Aria 引擎)
VACUUM;
-- SAVEPOINT: 嵌套事务保存点(仅 Aria 引擎)
BEGIN;
UPDATE users SET age = age + 1 WHERE id = '1';
SAVEPOINT sp1;
UPDATE users SET age = 0 WHERE id = '1';
ROLLBACK TO SAVEPOINT sp1;
RELEASE SAVEPOINT sp1;
COMMIT;
SELECT id, age FROM users WHERE id = '1';`,
casewhen: `-- 🎯 CASE WHEN 条件表达式 (v0.3.1 / v0.3.2)
-- SELECT 列:多 WHEN + ELSE
+102 -31
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📖 API 文档 — MetonaSqlark v0.4.4</title>
<title>📖 API 文档 — MetonaSqlark v0.7.4</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
@@ -152,7 +152,7 @@ npm install @metona-team/metona-sqlark</pre>
<pre><span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'hybrid'</span>, <span class="c">// 'memory' | 'disk' | 'hybrid' | 'aria' 🆕</span>
<span class="s">diskEngine</span>: <span class="s">'indexeddb'</span>, <span class="c">// 'indexeddb' | 'opfs'(仅 disk/hybrid 生效</span>
<span class="s">diskEngine</span>: <span class="s">'opfs'</span>, <span class="c">// 'opfs' | 'kv' | 'memory'v0.6.0: IndexedDB 已移除</span>
<span class="s">version</span>: <span class="n">1</span>,
<span class="s">plugins</span>: [], <span class="c">// MetonaPlugin[]</span>
<span class="s">onReady</span>: (db) => {}, <span class="c">// 就绪回调</span>
@@ -254,6 +254,14 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">-- TRUNCATE TABLE — 快速清空表数据 (v0.2.5)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);</pre>
<h3>参数化查询 (v0.7.0)</h3>
<p>位置参数 <code>?</code> 在词法层安全绑定(仅替换字符串字面量与注释之外的占位符),值按 SQL 字面量编码 —— 杜绝 SQL 注入,无需手动转义。</p>
<pre><span class="c">// 位置参数绑定 — 字符串 '' 转义自动完成</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES (?, ?, ?, ?)"</span>, [<span class="s">'2'</span>, <span class="s">"O'Brien"</span>, <span class="n">25</span>, <span class="s">'ob@demo.com'</span>]);
<span class="k">const</span> row = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT * FROM users WHERE name = ?'</span>, [<span class="s">"O'Brien"</span>]);
<span class="c">// 参数数量不匹配 → PARAM_ERROR;对象/数组参数显式拒绝;NaN/Infinity → NULL</span></pre>
<h3>SQL 扩展 (v0.3.0+)</h3>
<pre><span class="c">// 多语句 — 分号分隔一次执行(返回最后一条结果)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE t (id STRING PRIMARY KEY);
@@ -283,7 +291,29 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">// CREATE / DROP INDEX — 动态二级索引</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE INDEX idx_users_city ON users (city)'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_users_city ON users (city)'</span>);</pre>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_users_city ON users (city)'</span>);
<span class="c">// v0.7.4: 建表 UNIQUE 约束不可经 DROP INDEX 解除(NOT_SUPPORTED,重建表解除);
// 仅 CREATE UNIQUE INDEX 添加的唯一约束可随 DROP INDEX 一并删除(对齐 SQLite</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE UNIQUE INDEX idx_u ON users (email)'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_u ON users (email)'</span>); <span class="c">// unique 随之解除</span></pre>
<h3>维护语句 (v0.5.1 SQL 入口)</h3>
<pre><span class="c">// EXPLAIN — 查询计划(真实索引命中信息 v0.7.0)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"EXPLAIN SELECT * FROM users WHERE email = 'a@x.com'"</span>);
<span class="c">// { type, table, usingIndex: 'index:email', estimatedRows, actualTimeMs, ... }</span>
<span class="c">// ANALYZE / REINDEX / VACUUMAria 引擎;v0.7.3 统计含二级索引)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ANALYZE users'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'REINDEX users'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'VACUUM'</span>);
<span class="c">// SAVEPOINT — 嵌套事务保存点</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'BEGIN'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SAVEPOINT sp1'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ROLLBACK TO SAVEPOINT sp1'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'RELEASE SAVEPOINT sp1'</span>);
<span class="c">// 不支持的引擎执行维护语句抛 NOT_SUPPORTED</span></pre>
<h3>条件表达式</h3>
<pre><span class="c">// 比较运算符</span>
@@ -425,7 +455,7 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'orders'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'o1'</span>, <span class="s">userId</span>: <span class="s">'3'</span>, <span class="s">amount</span>: <span class="n">99</span> });
<span class="c">// ✅ 全部成功 → 自动 commit</span>
<span class="c">// ❌ 任何一步失败 → 自动 rollback,数据恢复原状</span>
<span class="c">// Memory 引擎:快照回滚 | IndexedDB 引擎:延迟写入</span>
<span class="c">// Memory 引擎:快照回滚 | KVStore 引擎:原子日志 flush</span>
});</pre>
<h2 id="subquery">🔍 子查询</h2>
@@ -441,7 +471,16 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">// NOT IN 子查询</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders)`</span>);</pre>
WHERE id NOT IN (SELECT user_id FROM orders)`</span>);
<span class="c">// v0.7.4: UPDATE / DELETE 的 WHERE 同样支持子查询(四引擎)
// 此前未解析的 $subquery 在引擎层恒 false → 静默影响 0 行</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`UPDATE users SET role = 'vip'
WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)`</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`DELETE FROM logs
WHERE ts < (SELECT MIN(ts) FROM keep_logs)`</span>);
<span class="c">// 写语句中关联引用($col / EXISTS 引用外层行)显式 NOT_SUPPORTED(不静默)</span></pre>
<h2 id="alter-table">🏗 ALTER TABLE (🆕 v0.2.5)</h2>
<p>v0.2.5 新增 ALTER TABLE 语法,支持动态添加和删除列。</p>
@@ -520,7 +559,7 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<pre><span class="c">// connect() — 获取或创建实例(单例复用)</span>
<span class="k">const</span> db1 = <span class="k">await</span> <span class="f">MetonaSqlark.connect</span>({ <span class="s">name</span>: <span class="s">'my-app'</span>, <span class="s">mode</span>: <span class="s">'hybrid'</span> });
<span class="k">const</span> db2 = <span class="k">await</span> <span class="f">MetonaSqlark.connect</span>({ <span class="s">name</span>: <span class="s">'my-app'</span> });
<span class="c">// db1 === db2 — 复用已有实例,避免重复 open IndexedDB</span>
<span class="c">// db1 === db2 — 复用已有实例,避免重复打开底层存储</span>
<span class="c">// disconnect() — 释放连接(引用计数 -1)</span>
<span class="k">await</span> db2.<span class="f">disconnect</span>(); <span class="c">// 引用计数: 2 → 1</span>
@@ -586,7 +625,7 @@ db.<span class="f">addMigration</span>(<span class="n">3</span>, <span class="k"
<tr><td><code>beforeDropTable</code></td><td>删除表前</td><td>tableName</td></tr>
<tr><td><code>afterDropTable</code></td><td>删除表后</td><td>tableName</td></tr>
<tr><td><code>beforeInsert</code></td><td>插入前</td><td>rows[]</td></tr>
<tr><td><code>afterInsert</code></td><td>插入后</td><td>rows[]</td></tr>
<tr><td><code>afterInsert</code></td><td>插入后</td><td>rows[], pks</td></tr>
<tr><td><code>beforeUpdate</code></td><td>更新前</td><td>query, updates</td></tr>
<tr><td><code>afterUpdate</code></td><td>更新后</td><td>query, updates, count</td></tr>
<tr><td><code>beforeDelete</code></td><td>删除前</td><td>query</td></tr>
@@ -737,13 +776,14 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<tr><th>属性</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td><code>name</code></td><td><code>string</code></td><td><code>'metona-sqlark'</code></td><td>数据库名称(必填)</td></tr>
<tr><td><code>mode</code></td><td><code>'memory'|'disk'|'hybrid'|'aria'</code></td><td><code>'hybrid'</code></td><td>存储模式 🆕 aria</td></tr>
<tr><td><code>diskEngine</code></td><td><code>'indexeddb'|'opfs'</code></td><td><code>'indexeddb'</code></td><td>磁盘引擎类型</td></tr>
<tr><td><code>diskEngine</code></td><td><code>'opfs'|'memory'|'kv'</code></td><td><code>'opfs'</code></td><td>磁盘引擎类型'kv' = 自研 KVStore 后端;v0.6.0: IndexedDB 已移除)</td></tr>
<tr><td><code>version</code></td><td><code>number</code></td><td><code>1</code></td><td>数据库版本号</td></tr>
<tr><td><code>plugins</code></td><td><code>MetonaPlugin[]</code></td><td><code>[]</code></td><td>初始插件列表</td></tr>
<tr><td><code>onReady</code></td><td><code>(db) => void</code></td><td>-</td><td>初始化完成回调</td></tr>
<tr><td><code>onError</code></td><td><code>(err) => void</code></td><td>-</td><td>错误回调(v0.2.5 接入执行路径)</td></tr>
<tr><td><code>maxRowsPerQuery</code></td><td><code>number</code></td><td><code>0</code></td><td>查询结果行数上限(0=不限制)✅ v0.2.5 生效</td></tr>
<tr><td><code>multiTabSync</code></td><td><code>boolean</code></td><td><code>false</code></td><td>多标签页同步:BroadcastChannel 广播表变更,其他标签页自动刷新 🆕 v0.3.2</td></tr>
<tr><td><code>aria</code></td><td><code>AriaEngineConfig</code></td><td>-</td><td>AriaEngine 专属配置透传:walSyncMode / checkpointInterval / encryption / pageStorage / compression 等 🆕 v0.5.0</td></tr>
</table>
<h2 id="engine">💾 存储引擎</h2>
@@ -751,11 +791,11 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<table>
<tr><th>引擎</th><th>模式</th><th>持久化</th><th>索引</th><th>事务</th><th>适用场景</th></tr>
<tr><td><code>MemoryEngine</code></td><td>memory</td><td></td><td>哈希</td><td>快照回滚</td><td>临时数据、缓存、测试</td></tr>
<tr><td><code>IndexedDBEngine</code></td><td>disk</td><td>IDB</td><td>IDB 索引</td><td>延迟写入</td><td>通用持久化,兼容性最好</td></tr>
<tr><td><code>OPFSEngine</code></td><td>disk</td><td>✅ OPFS</td><td>哈希</td><td>快照回滚</td><td>现代浏览器,文件级存储</td></tr>
<tr><td><code>KVStoreEngine</code> 🆕</td><td>disk</td><td>KVStore(OPFS)</td><td>哈希</td><td>原子日志 flush</td><td>标准持久化v0.6.0 替代 IndexedDB</td></tr>
<tr><td><code>HybridEngine</code></td><td>hybrid</td><td>✅ Write-Through</td><td>哈希</td><td>双引擎代理</td><td>生产推荐,读写均走内存</td></tr>
<tr style="border-top:2px solid var(--primary);"><td><code style="color:#ec4899;font-weight:700;">AriaEngine</code></td><td>aria</td><td>✅ WAL + SSTable</td><td>LSM-Tree</td><td>MVCC 快照隔离</td><td>自研引擎:大表、高并发、需崩溃恢复</td></tr>
</table>
<p><strong>v0.6.0: IndexedDB 已完全移除</strong> — disk 模式改用自研 KVStore 引擎(多 key 原子写 + 快照/日志崩溃恢复),旧库可经 <code>migrateFromIndexedDB()</code> 一键迁移。</p>
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
@@ -763,47 +803,65 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
<strong>v0.4.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内。<br>
<strong>v0.4.3 关闭时序与后台任务加固</strong> — 后台 flush/compaction 不再使用 setTimeout 延迟(close 排空全部任务后才关闭存储,杜绝"backend 关闭后写存储/重开污染")· 后台失败在 `flush()`/`close()` 显式报告(`ARIA_BACKGROUND_ERROR`,不静默吞错)· 预加载等待链稳定(修复 compaction 竞态跳块丢数据)· 事务提交先落 WAL 再合并快照(崩溃一致)· OPFS 写操作串行队列 + close 等待。<br>
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2"SSTC")与 v1"SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持 · 958 测试 52 套件。</p>
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2"SSTC")与 v1"SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持<br>
<strong>v0.5.0 存储后端生产级硬化</strong> — 真实 CRC-32 完整性校验(SSTable 整文件 + WAL 记录,旧文件兼容)· 全库 AES-256-GCM 透明加密(EncryptedBackend + PBKDF2 密钥派生 + 密码验证)· WAL 分片文件重构(真追加 + 空洞检测 + 旧格式迁移)· SSTable 4KB 页面化物理存储(BufferPool/FileManager 真实接入,meta 存 pageIds 兼容旧数据)· OPFS 后端 v2(append 真追加 / 写队列健壮性 / 残留清理)· Web Locks 多标签页独占锁(ARIA_LOCKED)· LZ4 v2 原始大小头 · Playwright 真实 Chromium e2e7 用例)· DatabaseConfig.aria 配置透传。<br>
<strong>v0.5.1 深度审查修复</strong> — 14 个生命周期钩子全部真实接线(此前 6 个 CRUD 钩子从未触发)· EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT SQL 入口补齐(此前仅有引擎方法无法触发)· db.backup() 公共方法 · 删除全部死代码(utils.ts 整文件 / MVCC 读侧 / estimateQueryCost 未接线优化器 / 40+ 统计辅助方法)。<br>
<strong>v0.6.0 完全移除 IndexedDB</strong> — 自研 KVStore 事务存储引擎(多 key 原子写 = 单日志记录原子追加 · 快照 checkpoint + 两阶段崩溃恢复 · CRC-32 自愈)· disk 模式切换 KVStoreEngine(替代 IndexedDBEngine + OPFSEngine)· 事务内 DDL / 外键级联 / 二级索引完整持久化 · migrateFromIndexedDB() 旧库一键迁移 · 10 万 key 压力验证 · e2e 崩溃注入 + KVStoreEngine 真实环境(12 用例)。<br>
<strong>v0.6.1 生产可用性深度审查</strong> — MemoryEngine 级联环(A→B→A)无限递归修复(visited 保护,与 Aria 对齐)· KVStore 快照损坏水位 bug 修复(metaSeq 误跳日志)· 多表事务 / 级联写入合并单条日志记录真原子(崩溃无部分提交)· 未 open 防护统一 · <strong>AriaEngine 可选自研 KVStore 后端</strong>storageBackend: 'kv'KVStore APPEND 日志支持 WAL 追加、writeMany 真原子、不再依赖浏览器 OPFS)· 二级索引范围扫描尾块漏读 P0 修复 · 批量插入性能悬崖修复(10 万行 kv 后端 353s → 12.5s)· 页面 id 崩溃回退 / flush 并发写 meta 两个崩溃恢复 P0 修复 · 10 万级 kv/opfs 双后端回归。<br>
<strong>v0.6.2 深度审计修复(数据正确性专项)</strong> — KVStoreEngine 数值主键 update 丢行 P0 · update 主键变更撞已有主键静默覆盖 P0 · Aria 二级索引范围查询边界算法错误(小数/字符串静默丢数据)P1 · 索引列 IS NULL 恒空(AriaP1 · Aria unique 约束未强制(批内互查 + 索引前缀扫描)· 非主键 update 索引旧值残留 · EXPLAIN 写语句产生真实副作用修复。<br>
<strong>v0.6.3 原子性 / 一致性 / 资源治理</strong> — KVStore 混合写单记录真原子(writeBatch,主键变更/级联全有或全无)· WAL full 模式写入失败抛错(不再吞错)· Memory SET NULL 级联索引残留 · delete 级联两阶段 RESTRICT 预检(无事务部分级联修复)· BufferPool 驱逐清理 pages Map(内存预算真实生效)· MVCC 已提交版本清理 · LSM.flush 重复入链 · rollbackToSavepoint 重建二级索引。<br>
<strong>v0.7.0 参数化查询 / 事务性能 / 语义硬化</strong><strong>参数化查询</strong><code>db.query(sql, params)</code> 位置参数 `?`,词法层绑定 + SQL 字面量安全编码,注入防护从根上成立)· EXPLAIN 真实索引命中信息(pk / index:col)· KVStoreEngine 事务行级增量 flush(大表事务改 1 行 commit 仅 1 条记录)· 移除每次 commit 强制全量 checkpoint · 复合主键显式拒绝(SCHEMA_ERRORv0.8 路线图)。<br>
<strong>v0.7.1 API 修复与防御统一</strong><code>MetonaSqlark.create</code> 静态工厂(README 示例在 ESM/Node 下可用)· close 未初始化崩溃防御 · AriaEngine 未 open 防护统一 · 事务回滚失败不掩盖原始错误 · <code>__proto__</code> 列名原型污染防护 · lint 清零。<br>
<strong>v0.7.2 语句级原子性 / 事务 DDL / 约束硬化</strong> — UPDATE 语句级两阶段原子(多行匹配第 N 行失败整句不执行 + 批内唯一互查)· 事务内 DDL 显式拒绝(四引擎对齐)· SET NULL 级联绕过 required 约束整体拒绝 · 参数绑定注释感知(注释中 `?`/引号不参与绑定)· 未闭合字符串显式 PARSE_ERROR · 未知 where 操作符抛 QUERY_ERROR(此前静默全匹配)· update undefined 语义化(保留旧值)· Hybrid write-through 失败补偿(磁盘失败自动重载内存对齐)。<br>
<strong>v0.7.3 深度审计第六阶段:INSERT 原子 / 索引一致性 / 边界窗口</strong> — INSERT 语句级两阶段原子(三引擎 + Aria PK 批内重复,此前部分提交)· 索引列 IS NULL 恒空修复(Memory/KVStore/Hybrid,对齐 Aria)· delete RESTRICT 预检不再破坏索引 · queryStream 子查询静默空结果修复($subquery/$exists/$col 回退物化)· ALTER DROP 索引列残留清理 · CREATE UNIQUE INDEX 存量重复数据校验(失败原子回滚)· <code>SELECT *, col AS alias</code> 解析与投影 · WAL BEGIN/ROLLBACK 写失败窗口修复(事务不泄漏/数据不复活)· aria $in 批级预加载(消除逐值 drainChain 性能悬崖)· 多条件 AND 等值下推(索引真正生效,EXPLAIN 同步)· ANALYZE 统计含二级索引 · React/Vue hooks 生命周期修复(config 变更重建 / 卸载关闭)· 迁移无主键旧库兜底 · 1256 测试 75 套件 · 90.0% 行覆盖率。<br>
<strong>v0.7.4 深度审计第七阶段:写语句子查询 / 约束硬化 / 真惰性流式</strong> — UPDATE/DELETE WHERE 子查询正确执行(四引擎,此前静默 0 行;关联引用显式 NOT_SUPPORTEDEXPLAIN 估算同步修复)· 主键 NULL/undefined 强制拒绝(SQL 语义 PK 隐含 NOT NULL,此前静默生成 "null"/"undefined" 主键)· DROP INDEX 保留建表 UNIQUE 约束(对齐 SQLite:需重建表解除;仅 CREATE UNIQUE INDEX 添加的可随索引删除)· GROUP BY / DISTINCT / UNION 键类型安全编码(null 与 'null' 字符串不再合并)· UPDATE 未知列显式 COLUMN_NOT_FOUND(此前脏列写入存储行)· queryStream 多语句显式 PARSE_ERROR(此前静默忽略后续语句)· KVStore 后台错误跨 reopen 清理 + Hybrid beginTransaction 失败补偿回滚 · <strong>Aria findStream 真惰性</strong>MergeIterator 迭代器化 + SSTable/MemTable 生成器扫描,limit 提前终止,大表流式内存 O(1)——此前内部 drain 全量物化)· REINDEX 单次全表扫描重建全部索引列(此前每列一次全扫描)· RB-Tree 删除双黑修复边界 + LSM 死代码清理 · 1304 测试 76 套件 · 90.1% 行覆盖率。</p>
<h3>存储模式对比</h3>
<table>
<tr><th>特性</th><th>Memory</th><th>Disk(IDB)</th><th>Disk(OPFS)</th><th>Hybrid</th><th>Aria</th></tr>
<tr><td>持久化</td><td></td><td>IDB</td><td>✅ OPFS</td><td>✅ 双写</td><td>✅ 后端决定</td></tr>
<tr><td>事务</td><td>✅ 快照</td><td>原子</td><td>✅ 快照</td><td>✅ 双引擎</td><td>✅ MVCC</td></tr>
<tr><td>索引</td><td>Hash</td><td>Hash</td><td>Hash</td><td>Hash</td><td>LSM二级</td></tr>
<tr><td>上限</td><td>内存</td><td>~2GB</td><td>磁盘</td><td>~2GB</td><td>内存</td></tr>
<tr><td>浏览器</td><td>全部</td><td>全部</td><td>Chromium</td><td>全部</td><td>全部</td></tr>
<tr><th>特性</th><th>Memory</th><th>Disk (KVStore)</th><th>Hybrid</th><th>Aria</th></tr>
<tr><td>持久化</td><td></td><td>KVStoreOPFS / 内存介质)</td><td>✅ 双写</td><td>✅ 后端决定</td></tr>
<tr><td>事务</td><td>✅ 快照回滚</td><td>单日志记录原子写</td><td>✅ 双引擎(磁盘优先)</td><td>✅ MVCC 快照隔离</td></tr>
<tr><td>索引</td><td>Hash</td><td>Hash(重启恢复)</td><td>Hash</td><td>LSM 二级(重启恢复)</td></tr>
<tr><td>上限</td><td>内存</td><td>磁盘可用</td><td>磁盘可用</td><td>内存</td></tr>
<tr><td>多标签页锁</td><td></td><td></td><td></td><td>✅ Web Locks</td></tr>
<tr><td>全库加密</td><td></td><td></td><td></td><td>✅ AES-GCM</td></tr>
<tr><td>浏览器</td><td>全部</td><td>Chrome/Edge 102+ · Firefox 111+ · Safari 15.2+</td><td>同上</td><td>同上(后端决定)</td></tr>
</table>
<h3>核心特性</h3>
<table>
<tr><th>特性</th><th>说明</th></tr>
<tr><td><strong>LSM-Tree 索引</strong></td><td>MemTable (红黑树) + 多级 SSTable,写优化,支持范围扫描</td></tr>
<tr><td><strong>Slotted Page 格式</strong></td><td>4KB 固定页面,Slot Directory + Tuple 二进制序列化</td></tr>
<tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB)</td></tr>
<tr><td><strong>WAL 日志</strong></td><td>Write-Ahead Log 保证崩溃恢复,支持 full/batch/none 三种同步模式(full 模式真正同步 ✅ v0.2.5),16MB 阈值自动 checkpoint</td></tr>
<tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC,读写路径接入版本链 ✅ v0.2.5</td></tr>
<tr><td><strong>4KB 页面化物理存储</strong></td><td>SSTable 切分为 4KB 页面(FileManager 分配 pageId + BufferPool LRU 缓存 256 页 ≈ 1MB),save 即落盘,`SSTableMeta.pageIds` 持久化(旧整 value 数据兼容)✅ v0.5.0</td></tr>
<tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB),脏页写回 ✅ v0.5.0 真实接入</td></tr>
<tr><td><strong>WAL 日志</strong></td><td>分片文件(`__wal_%06d.bin`4MB 阈值切换 + 真追加)· 标准 CRC32 记录校验 · full/batch/none 三种同步模式(full 真正同步)· 空洞检测截断 · 16MB 阈值自动 checkpoint ✅ v0.5.0</td></tr>
<tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC(引擎事务读取走 txnSnapshotMVCC 版本链作 undo log</td></tr>
<tr><td><strong>全库 AES-GCM 加密</strong></td><td>EncryptedBackend 透明加解密(WAL/SSTable/Schema/元数据全密文)· PBKDF2 密钥派生 + salt 持久化 + 密码验证 · 明文/加密库开关一致性检测 ✅ v0.5.0</td></tr>
<tr><td><strong>多标签页独占锁</strong></td><td>Web Locks API:第二个标签页打开同一库抛 `ARIA_LOCKED`open 失败自动释放 ✅ v0.5.0</td></tr>
<tr><td><strong>Bloom Filter</strong></td><td>快速判定 key 不存在,减少无效磁盘 I/OSSTableReader 二分查找统一 ✅ v0.2.5</td></tr>
<tr><td><strong>LZ4 压缩</strong></td><td>可选页面级压缩,空间效率提升</td></tr>
<tr><td><strong>LZ4 压缩v2</strong></td><td>SSTable 级压缩,压缩流自带原始大小头(高压缩率不截断)✅ v0.5.0</td></tr>
</table>
<h3>使用方式</h3>
<pre><span class="c">// 激活 AriaEngine</span>
<pre><span class="c">// 激活 AriaEngineOPFS 后端默认页面化存储)</span>
<span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'aria'</span>, <span class="c">// 🆕 AriaEngine 模式</span>
<span class="s">diskEngine</span>: <span class="s">'indexeddb'</span>, <span class="c">// 底层存储后端(indexeddb | opfs | memory</span>
<span class="s">diskEngine</span>: <span class="s">'opfs'</span>, <span class="c">// 底层存储后端(opfs | kv | memory</span>
<span class="s">aria</span>: { <span class="c">// AriaEngine 专属配置透传 🆕 v0.5.0</span>
<span class="s">walSyncMode</span>: <span class="s">'full'</span>, <span class="c">// WAL 同步模式</span>
<span class="s">encryption</span>: { <span class="s">password</span>: <span class="s">'my-password'</span> }, <span class="c">// 全库 AES-GCM 加密 🆕 v0.5.0</span>
},
});
<span class="c">// 或直接实例化 — 支持细粒度配置</span>
<span class="k">import</span> { <span class="t">AriaEngine</span> } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark'</span>;
<span class="k">const</span> engine = <span class="k">new</span> <span class="f">AriaEngine</span>({
<span class="s">pageSize</span>: <span class="n">4096</span>, <span class="c">// 页面大小</span>
<span class="s">bufferPoolPages</span>: <span class="n">256</span>, <span class="c">// 缓存页数</span>
<span class="s">bufferPoolPages</span>: <span class="n">256</span>, <span class="c">// Buffer Pool 页面数量</span>
<span class="s">memtableSizeThreshold</span>: <span class="n">4194304</span>, <span class="c">// MemTable 刷盘阈值 4MB</span>
<span class="s">walEnabled</span>: <span class="k">true</span>, <span class="c">// 启用 WAL</span>
<span class="s">walSyncMode</span>: <span class="s">'batch'</span>, <span class="c">// 'full' | 'batch' | 'none'</span>
<span class="s">storageBackend</span>: <span class="s">'indexeddb'</span>, <span class="c">// 存储后端</span>
<span class="s">walSyncMode</span>: <span class="s">'full'</span>, <span class="c">// 'full' | 'batch' | 'none'(默认 full</span>
<span class="s">storageBackend</span>: <span class="s">'opfs'</span>, <span class="c">// 存储后端(opfs | kv | memory</span>
<span class="s">encryption</span>: { <span class="s">password</span>: <span class="s">'my-password'</span> }, <span class="c">// 全库加密</span>
});</pre>
<h3>AriaEngine 配置项</h3>
@@ -815,11 +873,13 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<tr><td><code>levelSizeMultiplier</code></td><td><code>number</code></td><td><code>10</code></td><td>LSM 层级容量倍数</td></tr>
<tr><td><code>bloomFilterBitsPerKey</code></td><td><code>number</code></td><td><code>10</code></td><td>Bloom Filter 每 key 位数</td></tr>
<tr><td><code>walEnabled</code></td><td><code>boolean</code></td><td><code>true</code></td><td>是否启用 WAL</td></tr>
<tr><td><code>walSyncMode</code></td><td><code>'full'|'batch'|'none'</code></td><td><code>'batch'</code></td><td>WAL 同步策略(full 模式真正同步 ✅ v0.2.5</td></tr>
<tr><td><code>walSyncMode</code></td><td><code>'full'|'batch'|'none'</code></td><td><code>'full'</code></td><td>WAL 同步策略(full 真正同步 ✅ v0.2.4 起默认 full</td></tr>
<tr><td><code>checkpointInterval</code></td><td><code>number</code></td><td><code>1000</code></td><td>Checkpoint 触发间隔(操作数)</td></tr>
<tr><td><code>walSizeThreshold</code></td><td><code>number</code></td><td><code>16777216</code></td><td>WAL 大小阈值(字节),超阈值触发 checkpoint ✅ v0.2.5</td></tr>
<tr><td><code>compression</code></td><td><code>boolean</code></td><td><code>false</code></td><td>是否启用页面压缩</td></tr>
<tr><td><code>storageBackend</code></td><td><code>'indexeddb'|'opfs'|'memory'</code></td><td><code>'indexeddb'</code></td><td>存储后端类型</td></tr>
<tr><td><code>storageBackend</code></td><td><code>'opfs'|'memory'|'kv'</code></td><td><code>'opfs'</code></td><td>存储后端类型'kv' = 自研 KVStore 后端 🆕 v0.6.1</td></tr>
<tr><td><code>encryption</code></td><td><code>{ password: string }</code></td><td>-</td><td>全库 AES-256-GCM 透明加密(PBKDF2 派生 + salt 持久化 + 密码验证)🆕 v0.5.0</td></tr>
<tr><td><code>pageStorage</code></td><td><code>boolean</code></td><td>opfs/kv 自动启用</td><td>SSTable 4KB 页面化物理存储(BufferPool 缓存)🆕 v0.5.0</td></tr>
</table>
<h2 id="errors">⚠️ 错误处理</h2>
@@ -849,6 +909,17 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<tr><td><code>TRANSACTION_ERROR</code></td><td>事务执行失败</td></tr>
<tr><td><code>COMPILE_ERROR</code></td><td>编译 AST 到查询计划失败</td></tr>
<tr><td><code>CONFIG_ERROR</code></td><td>配置错误</td></tr>
<tr><td><code>ARIA_LOCKED</code></td><td>数据库已被其他标签页打开(Web Locks 独占锁)🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_DECRYPT_ERROR</code></td><td>解密失败:密码错误 / 密钥元数据损坏 / 数据被篡改 🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_ENCRYPT_REQUIRED</code></td><td>库已加密但未提供密码 🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_ENCRYPT_CONFIG_ERROR</code></td><td>明文库无法以加密模式打开 / 空密码 🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_BACKGROUND_ERROR</code></td><td>后台 flush/compaction 失败(flush/close 时显式报告)</td></tr>
<tr><td><code>SAVEPOINT_EXISTS</code> / <code>SAVEPOINT_NOT_FOUND</code></td><td>保存点已存在 / 回滚到不存在的保存点 🆕 v0.5.1 SQL 入口</td></tr>
<tr><td><code>NOT_SUPPORTED</code></td><td>引擎不支持的操作(如 Memory 引擎执行 ANALYZE / VACUUM / SAVEPOINT)🆕 v0.5.1</td></tr>
<tr><td><code>PARAM_ERROR</code></td><td>参数绑定错误:数量不匹配 / 对象数组参数 🆕 v0.7.0</td></tr>
<tr><td><code>QUERY_ERROR</code></td><td>未知 where 操作符(如拼错的 <code>$betwen</code>)🆕 v0.7.2</td></tr>
<tr><td><code>COLUMN_NOT_FOUND</code></td><td>列不存在:UPDATE 未知列 / ALTER DROP 不存在列(v0.7.4 UPDATE 未知列显式报错)</td></tr>
<tr><td><code>INDEX_NOT_FOUND</code></td><td>DROP 不存在的索引</td></tr>
</table>
</main>
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.6.1",
"version": "0.6.2",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
@@ -29,7 +29,7 @@
"license": "MIT",
"dependencies": {
"@metona-team/metona-editor": "0.4.1",
"@metona-team/metona-sqlark": "0.4.4",
"@metona-team/metona-sqlark": "0.7.4",
"@metona-team/metona-toast": "^0.5.0",
"mermaid": "^10.9.6",
"nanoid": "^5.1.5",
@@ -51,7 +51,6 @@
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"fake-indexeddb": "^6.2.5",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.7",
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { searchInDir } from '../file-system'
describe('searchInDir (主进程多文件搜索)', () => {
let dir: string
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'marklite-search-'))
mkdirSync(join(dir, 'sub'))
mkdirSync(join(dir, 'node_modules'))
writeFileSync(join(dir, 'a.md'), '# Hello\nline two\nWorld hello again\n')
writeFileSync(join(dir, 'sub', 'b.markdown'), 'nothing here\nHELLO UPPER\n')
writeFileSync(join(dir, 'skip.js'), 'hello in js\n')
writeFileSync(join(dir, 'node_modules', 'c.md'), 'hello in deps\n')
writeFileSync(join(dir, '.hidden.md'), 'hello hidden\n')
})
afterAll(() => {
rmSync(dir, { recursive: true, force: true })
})
it('should find matches across files (case-insensitive default)', async () => {
const result = await searchInDir({ dirPath: dir, query: 'hello' })
expect(result.success).toBe(true)
// a.md: 2 处(# Hello / World hello again + b.markdown: 1 处(HELLO UPPER
expect(result.matches).toHaveLength(3)
expect(result.totalFiles).toBe(2)
})
it('should respect case-sensitive option', async () => {
const result = await searchInDir({ dirPath: dir, query: 'HELLO', caseSensitive: true })
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(1)
expect(result.matches?.[0].filePath).toContain('b.markdown')
})
it('should support regex search', async () => {
const result = await searchInDir({ dirPath: dir, query: '^line', useRegex: true })
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(1)
expect(result.matches?.[0].line).toBe(2)
})
it('should reject invalid regex with error', async () => {
const result = await searchInDir({ dirPath: dir, query: '([', useRegex: true })
expect(result.success).toBe(false)
expect(result.error).toBe('无效的正则表达式')
})
it('should skip node_modules and dotfiles', async () => {
const result = await searchInDir({ dirPath: dir, query: 'hidden' })
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(0)
})
it('should reject empty query', async () => {
const result = await searchInDir({ dirPath: dir, query: '' })
expect(result.success).toBe(false)
})
it('should return error for invalid directory', async () => {
const result = await searchInDir({ dirPath: join(dir, 'nope'), query: 'x' })
// 目录不存在:walk 内部吞掉 readdir 错误,返回 0 结果(success: true
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(0)
})
})
+122 -2
View File
@@ -1,7 +1,13 @@
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
import { join, extname, dirname, basename } from 'path'
import { randomBytes } from 'crypto'
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
import type {
ReadFileResult,
SaveFileResult,
FileNode,
SearchInDirPayload,
SearchInDirResult,
} from '../shared/types'
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
@@ -42,11 +48,34 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink
// B14-fix: 检测原文件编码(UTF-16 LE/BE BOM),保存时保持同编码,
// 避免 UTF-16 文件被强制改写为 UTF-8 造成乱码
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
const randomSuffix = randomBytes(8).toString('hex')
const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`)
try {
await writeFile(tmpFile, content, 'utf-8')
let data: Buffer
try {
const existing = await readFile(filePath)
if (existing.length >= 2 && existing[0] === 0xfe && existing[1] === 0xff) {
// 原文件 UTF-16 BEBOM + BE 编码写回
const bom = Buffer.from([0xfe, 0xff])
const body = Buffer.from(content, 'utf16le')
body.swap16()
data = Buffer.concat([bom, body])
} else if (existing.length >= 2 && existing[0] === 0xff && existing[1] === 0xfe) {
// 原文件 UTF-16 LEBOM + LE 编码写回
const bom = Buffer.from([0xff, 0xfe])
const body = Buffer.from(content, 'utf16le')
data = Buffer.concat([bom, body])
} else {
data = Buffer.from(content, 'utf-8')
}
} catch {
// 原文件不存在/不可读:按 UTF-8 写新文件
data = Buffer.from(content, 'utf-8')
}
await writeFile(tmpFile, data)
await rename(tmpFile, filePath)
return { success: true, filePath }
} catch (err) {
@@ -116,3 +145,94 @@ export async function buildDirTree(
}
return children
}
// v0.6.2: 多文件搜索 — 递归遍历目录,逐行匹配 md/markdown/txt 文件内容
const SEARCH_MAX_MATCHES = 500
const SEARCH_MAX_DEPTH = 10
const SEARCH_LINE_PREVIEW_LEN = 200
function buildSearchMatcher(
query: string,
caseSensitive: boolean,
useRegex: boolean,
): { test: (line: string) => boolean } | null {
if (useRegex) {
try {
const re = new RegExp(query, caseSensitive ? '' : 'i')
return { test: (line: string) => re.test(line) }
} catch {
return null // 无效正则
}
}
const needle = caseSensitive ? query : query.toLowerCase()
return {
test: (line: string) =>
(caseSensitive ? line : line.toLowerCase()).includes(needle),
}
}
export async function searchInDir(
payload: SearchInDirPayload,
): Promise<SearchInDirResult> {
const { dirPath, query, caseSensitive = false, useRegex = false } = payload
if (!query) {
return { success: false, error: '搜索内容为空' }
}
const matcher = buildSearchMatcher(query, caseSensitive, useRegex)
if (!matcher) {
return { success: false, error: '无效的正则表达式' }
}
const matches: SearchInDirResult['matches'] = []
let totalFiles = 0
let truncated = false
const walk = async (dir: string, depth: number): Promise<void> => {
if (truncated || depth > SEARCH_MAX_DEPTH) return
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (truncated) return
const name = entry.name
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue
const childPath = join(dir, name)
if (entry.isDirectory()) {
await walk(childPath, depth + 1)
continue
}
if (!ALLOWED_EXTENSIONS_SET.has(extname(name).toLowerCase())) continue
totalFiles++
try {
const fileStat = await stat(childPath)
if (fileStat.size > MAX_FILE_SIZE) continue
const content = await readFile(childPath, 'utf-8')
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
if (!matcher.test(lines[i])) continue
matches?.push({
filePath: childPath,
line: i + 1,
lineText: lines[i].slice(0, SEARCH_LINE_PREVIEW_LEN),
})
if ((matches?.length ?? 0) >= SEARCH_MAX_MATCHES) {
truncated = true
return
}
}
} catch {
// 单文件读取失败(编码/权限)跳过,不中断整体搜索
}
}
}
try {
await walk(dirPath, 0)
return { success: true, matches, totalFiles, truncated }
} catch (err) {
return { success: false, error: (err as Error).message }
}
}
+4
View File
@@ -27,6 +27,10 @@ export class FileWatcher {
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
this.watcher.on('error', () => {
const originalPath = this.currentPath
// B12-fix: watcher 已失效,必然不再处于"自写"状态 —
// 若 error 恰好发生在保存流程 start() 与 setSelfWriting(false) 之间,
// 残留的 isSelfWriting=true 会永久吞掉轮询恢复后的外部修改通知
this.isSelfWriting = false
this.stop()
if (originalPath) {
this.pollTimer = setInterval(() => {
+3 -1
View File
@@ -63,13 +63,15 @@ if (!lockOk) {
mainWindow?.close()
return
}
// B4-fix: 超时 12s — 需长于渲染进程 MeToast.confirm 的 10 秒安全超时,
// 否则用户尚未确认就被强杀(丢失最后 500ms 防抖窗口内的编辑)
state.closeTimeout = setTimeout(() => {
state.closeTimeout = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.removeAllListeners('close')
mainWindow.close()
}
}, 5000)
}, 12000)
})
}
+19 -1
View File
@@ -1,7 +1,8 @@
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { readFileContent, saveFileContent, buildDirTree, searchInDir } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { SearchInDirPayload } from '../shared/types'
import { stat, readFile, writeFile } from 'fs/promises'
import { basename, isAbsolute } from 'path'
@@ -206,6 +207,23 @@ export function registerIpcHandlers(
sidebarWatcher.stop()
})
// v0.6.2: 多文件搜索 — 目录内递归搜索匹配行
ipcMain.handle(
IPC_CHANNELS.DIR_SEARCH,
async (_event: IpcMainInvokeEvent, payload: SearchInDirPayload) => {
if (!payload || typeof payload !== 'object') {
return { success: false, error: '无效的搜索参数' }
}
if (!validatePath(payload.dirPath)) {
return { success: false, error: '无效的目录路径' }
}
if (typeof payload.query !== 'string') {
return { success: false, error: '无效的搜索内容' }
}
return searchInDir(payload)
},
)
// 标签切换
ipcMain.handle(
IPC_CHANNELS.TAB_SWITCHED,
+2
View File
@@ -37,6 +37,8 @@ const api: ElectronAPI = {
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
searchInDir: (payload: import('../shared/types').SearchInDirPayload) =>
ipcRenderer.invoke(IPC_CHANNELS.DIR_SEARCH, payload),
// v0.6.0: 数据备份导出/导入
exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
+63 -9
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useEditorStore, getMetonaEditor } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard'
@@ -11,8 +11,9 @@ import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners'
import { MeToast } from './lib/toast'
import { MeToast, showToast } from './lib/toast'
import { closeDatabase } from './db/schema'
import { recentFilesRepository } from './db/recentFilesRepository'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
@@ -22,6 +23,7 @@ import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { SearchPanel } from './components/SearchPanel'
export function App() {
const tabs = useTabStore(s => s.tabs)
@@ -35,7 +37,9 @@ export function App() {
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { themeMode, cycleTheme } = useTheme()
const [showAbout, setShowAbout] = useState(false)
const [showSearch, setShowSearch] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
const handleCloseSearch = useCallback(() => setShowSearch(false), [])
useSettingsInit()
useEffect(() => {
@@ -65,24 +69,69 @@ export function App() {
}, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useKeyboard(handleOpenFile, handleSave, handleSaveAs, () => setShowSearch(true))
useIpcListeners()
// v0.6.2: 多文件搜索 — 打开结果文件并定位到匹配行
const handleSearchOpenResult = useCallback(
async (filePath: string, line: number) => {
const existing = tabs.find(t => t.filePath === filePath)
if (existing) {
useTabStore.getState().switchToTab(existing.id)
} else if (window.electronAPI) {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content !== undefined) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
} else {
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
return
}
}
setShowSearch(false)
// 等待编辑器完成标签切换后再定位
setTimeout(() => {
const editor = getMetonaEditor()
if (editor) {
editor.scrollToLine(line)
editor.setCursorPosition(line, 0)
editor.focus()
}
}, 150)
},
[tabs, createTab],
)
// B3-fix: 只依赖活动文件的路径 — 此前依赖 tabs 数组导致每次击键
// updateTabContent)都触发 tabSwitched IPC 并重启主进程文件 watcher
const activeFilePath = useMemo(
() => tabs.find(t => t.id === activeTabId)?.filePath ?? null,
[tabs, activeTabId],
)
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
}, [activeTabId, tabs])
window.electronAPI.tabSwitched(activeFilePath)
}, [activeFilePath])
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab?.isModified) return
if (!tab) return
if (!tab) {
setExternallyModified(null)
return
}
// B6-fix: 标签有未保存修改时不再静默无反应,显式提示
if (tab.isModified) {
showToast('当前标签有未保存的修改,请先保存或放弃修改后再重新加载', 'warning')
return
}
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content !== undefined) {
updateTabContent(tab.id, result.content)
setModified(tab.id, false)
} else {
showToast(`重新加载失败: ${result.error ?? '未知错误'}`, 'error')
}
setExternallyModified(null)
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
@@ -96,6 +145,7 @@ export function App() {
themeMode={themeMode}
onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)}
onShowSearch={() => setShowSearch(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
@@ -106,6 +156,7 @@ export function App() {
<TabBar />
{externallyModified && (
<ModifiedBanner
filePath={externallyModified.filePath}
onReload={handleReloadModified}
onDismiss={() => setExternallyModified(null)}
/>
@@ -127,6 +178,9 @@ export function App() {
</div>
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
{showSearch && (
<SearchPanel onClose={handleCloseSearch} onOpenResult={handleSearchOpenResult} />
)}
</div>
</ErrorBoundary>
)
@@ -52,7 +52,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
<span>git.metona.cn/MetonaTeam/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.4.4</p>
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.7.4</p>
<p className="about-copyright">© 2026 thzxx</p>
</div>
<button className="about-close-btn" onClick={onClose}>
+11 -1
View File
@@ -221,7 +221,7 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
// silent: true — 不触发 onChange,避免重复更新 tabStore
editorRef.current.setValue(activeTab.content, { silent: true })
// 恢复滚动位置
// 恢复滚动位置 + 光标位置
requestAnimationFrame(() => {
const c = containerRef.current
if (!c) return
@@ -233,6 +233,16 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
if (preview) {
preview.scrollTop = activeTab.scrollTop
}
// B10-fix: 恢复光标位置(此前 selectionStart/End 已持久化但从未恢复)
const editor = editorRef.current
if (editor && activeTab.selectionStart > 0) {
const before = activeTab.content.substring(
0,
Math.min(activeTab.selectionStart, activeTab.content.length),
)
const lines = before.split('\n')
editor.setCursorPosition(lines.length, lines[lines.length - 1].length)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
+19
View File
@@ -277,6 +277,25 @@ export function Download({ size = defaultProps.size }: IconProps) {
)
}
// ===== v0.6.2: 多文件搜索图标 =====
export function SearchIcon({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
export function Upload({ size = defaultProps.size }: IconProps) {
return (
<svg
@@ -1,17 +1,20 @@
import React from 'react'
import { getFileName } from '../../lib/fileUtils'
interface ModifiedBannerProps {
filePath: string
onReload: () => void
onDismiss: () => void
}
export const ModifiedBanner = React.memo(function ModifiedBanner({
filePath,
onReload,
onDismiss,
}: ModifiedBannerProps) {
return (
<div id="modified-banner" role="alert" aria-live="assertive">
<span></span>
<span>{getFileName(filePath)} </span>
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">
</button>
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { parseHeadings } from '../outlineUtils'
describe('parseHeadings', () => {
it('should parse headings from markdown', () => {
const headings = parseHeadings('# Title\n## Section\ncontent')
expect(headings).toHaveLength(2)
expect(headings[0]).toEqual({ level: 1, text: 'Title', pos: 0 })
expect(headings[1].level).toBe(2)
expect(headings[1].text).toBe('Section')
})
it('should skip headings inside fenced code blocks', () => {
const md = [
'# Real Title',
'```markdown',
'# Fake Heading',
'## Fake Sub',
'```',
'## After Code',
].join('\n')
const headings = parseHeadings(md)
expect(headings.map(h => h.text)).toEqual(['Real Title', 'After Code'])
})
it('should skip headings inside tilde code blocks', () => {
const md = ['# A', '~~~', '### Inside', '~~~', '## B'].join('\n')
const headings = parseHeadings(md)
expect(headings.map(h => h.text)).toEqual(['A', 'B'])
})
it('should keep position offsets (pos) correct', () => {
const md = 'aaa\n## B'
const headings = parseHeadings(md)
expect(headings).toHaveLength(1)
expect(headings[0].pos).toBe(4)
})
it('should handle unclosed fence (rest treated as code)', () => {
const md = ['# A', '```', '## Inside', '# Also Inside'].join('\n')
const headings = parseHeadings(md)
expect(headings.map(h => h.text)).toEqual(['A'])
})
it('should return empty array for empty markdown', () => {
expect(parseHeadings('')).toEqual([])
})
})
@@ -5,22 +5,39 @@ export interface Heading {
pos: number
}
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
/**
* Parse headings from raw markdown content using regex.
*
* B9-fix: 逐行扫描并跟踪代码围栏(``` / ~~~)状态,
* 跳过代码块内的 # 行(此前会被误判为标题,大纲点击导航到错误位置)。
*/
export function parseHeadings(markdown: string): Heading[] {
const headings: Heading[] = []
let match: RegExpExecArray | null
const lines = markdown.split('\n')
let pos = 0
let fence: string | null = null // 当前围栏标记(``` 或 ~~~),null = 不在代码块内
// Reset regex state
HEADING_RE.lastIndex = 0
for (const line of lines) {
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line)
if (fenceMatch) {
const marker = fenceMatch[2][0]
if (fence === null) {
fence = marker
} else if (marker === fence && fenceMatch[1].length < 4) {
fence = null
}
pos += line.length + 1
continue
}
while ((match = HEADING_RE.exec(markdown)) !== null) {
const level = match[1].length
const text = match[2].trim()
headings.push({ level, text, pos: match.index })
if (fence === null) {
const m = /^(#{1,6})\s+(.+)$/.exec(line)
if (m) {
headings.push({ level: m[1].length, text: m[2].trim(), pos })
}
}
pos += line.length + 1
}
return headings
@@ -0,0 +1,171 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import { showToast } from '../../lib/toast'
import { logError } from '../../lib/errorHandler'
import type { SearchMatch } from '../../../shared/types'
interface SearchPanelProps {
onClose: () => void
onOpenResult: (filePath: string, line: number) => void
}
/**
* v0.6.2: 多文件搜索面板 — 在侧边栏已打开的文件夹内递归搜索
* md/markdown/txt 文件内容,结果按 文件+行号 展示,点击打开并定位。
*/
export const SearchPanel = React.memo(function SearchPanel({ onClose, onOpenResult }: SearchPanelProps) {
const rootPath = useSidebarStore(s => s.rootPath)
const [query, setQuery] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [useRegex, setUseRegex] = useState(false)
const [searching, setSearching] = useState(false)
const [matches, setMatches] = useState<SearchMatch[]>([])
const [totalFiles, setTotalFiles] = useState(0)
const [truncated, setTruncated] = useState(false)
const [searched, setSearched] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
const runSearch = useCallback(
async (q: string) => {
if (!q.trim()) return
if (!rootPath || !window.electronAPI) {
showToast('请先在侧边栏打开一个文件夹', 'warning')
return
}
setSearching(true)
setSearched(true)
try {
const result = await window.electronAPI.searchInDir({
dirPath: rootPath,
query: q,
caseSensitive,
useRegex,
})
if (result.success) {
setMatches(result.matches ?? [])
setTotalFiles(result.totalFiles ?? 0)
setTruncated(result.truncated ?? false)
} else {
setMatches([])
showToast(result.error ?? '搜索失败', 'error')
}
} catch (error) {
logError('多文件搜索失败', error)
showToast('搜索失败', 'error')
} finally {
setSearching(false)
}
},
[rootPath, caseSensitive, useRegex],
)
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
runSearch(query)
},
[query, runSearch],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
},
[onClose],
)
return (
<div
className="search-overlay"
role="dialog"
aria-modal="true"
aria-label="多文件搜索"
onKeyDown={handleKeyDown}
onClick={onClose}
>
<div className="search-panel" onClick={e => e.stopPropagation()}>
<div className="search-header">
<form className="search-form" onSubmit={handleSubmit}>
<input
ref={inputRef}
className="search-input"
type="text"
placeholder={
rootPath ? `${getFileName(rootPath)} 中搜索...` : '请先在侧边栏打开文件夹'
}
value={query}
onChange={e => setQuery(e.target.value)}
aria-label="搜索内容"
/>
<button className="search-run-btn" type="submit" disabled={searching || !query.trim()}>
{searching ? '搜索中...' : '搜索'}
</button>
</form>
<div className="search-options">
<label className="search-option">
<input
type="checkbox"
checked={caseSensitive}
onChange={e => setCaseSensitive(e.target.checked)}
/>
</label>
<label className="search-option">
<input
type="checkbox"
checked={useRegex}
onChange={e => setUseRegex(e.target.checked)}
/>
</label>
<button className="search-close-btn" onClick={onClose} aria-label="关闭搜索">
</button>
</div>
</div>
<div className="search-body">
{!searched ? (
<div className="search-empty">Ctrl+Shift+F </div>
) : searching ? (
<div className="search-empty">...</div>
) : matches.length === 0 ? (
<div className="search-empty">
{totalFiles > 0 ? `(已扫描 ${totalFiles} 个文件)` : ''}
</div>
) : (
<>
<div className="search-summary">
{matches.length}
{truncated && '(结果过多,仅显示前 500 条)'} · {totalFiles}
</div>
<div className="search-results" role="list" aria-label="搜索结果">
{matches.map((m, i) => (
<button
key={`${m.filePath}:${m.line}:${i}`}
className="search-result-item"
onClick={() => onOpenResult(m.filePath, m.line)}
title={m.filePath}
>
<span className="search-result-file">{getFileName(m.filePath)}</span>
<span className="search-result-line">:{m.line}</span>
<span className="search-result-text">{m.lineText}</span>
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
)
})
SearchPanel.displayName = 'SearchPanel'
@@ -0,0 +1 @@
export { SearchPanel } from './SearchPanel'
@@ -3,6 +3,8 @@ import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { logError } from '../../lib/errorHandler'
import { showToast } from '../../lib/toast'
import { FolderPlus, File } from '../Icons'
import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize'
@@ -98,7 +100,13 @@ export const Sidebar = React.memo(function Sidebar() {
if (result.success && result.content !== undefined) {
createTab(path, result.content)
recentFilesRepository.add(path)
} else {
// B13-fix: 读取失败(文件被删除/权限问题)显式提示
showToast(`无法打开 "${path}": ${result.error ?? '未知错误'}`, 'error')
}
} catch (error) {
logError('文件树打开文件失败', error)
showToast(`无法打开 "${path}"`, 'error')
} finally {
setLoading('file-open', false)
}
+23 -3
View File
@@ -1,7 +1,9 @@
import React, { useCallback } from 'react'
import { FolderOpen, Save, Moon, Sun, Info, Download, Upload } from '../Icons'
import { FolderOpen, Save, Moon, Sun, Info, Download, Upload, SearchIcon } from '../Icons'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
import { backupRepository } from '../../db/backupRepository'
import { closeDatabase } from '../../db/schema'
import { flushSaveToDB } from '../../stores/tabStore'
import { showToast } from '../../lib/toast'
import { logError } from '../../lib/errorHandler'
import type { ThemeMode } from '../../types/settings'
@@ -12,6 +14,7 @@ interface ToolbarProps {
themeMode: ThemeMode
onCycleTheme: () => void
onShowAbout: () => void
onShowSearch: () => void
isAutoSaving: boolean
autoSaveEnabled: boolean
onToggleAutoSave: () => void
@@ -33,6 +36,7 @@ export const Toolbar = React.memo(function Toolbar({
themeMode,
onCycleTheme,
onShowAbout,
onShowSearch,
isAutoSaving,
autoSaveEnabled,
onToggleAutoSave,
@@ -60,7 +64,7 @@ export const Toolbar = React.memo(function Toolbar({
}
}, [])
// v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable
// v0.6.0: 数据备份导入(JSON 文件 → sqlark 事务导入
const handleImport = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.importData()
@@ -73,7 +77,15 @@ export const Toolbar = React.memo(function Toolbar({
// 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
// 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
showToast('备份已恢复', 'success')
setTimeout(() => window.location.reload(), 800)
// B7-fix: 先 flush 快照并干净关闭数据库,确保导入数据落盘后再重载
// (此前固定 800ms 后 reload,OPFS 写未完成会丢导入数据)
try {
await flushSaveToDB()
await closeDatabase()
} catch {
/* flush/close 失败仍继续 reload,由自愈流程兜底 */
}
window.location.reload()
} else {
showToast('恢复备份失败', 'error')
}
@@ -148,6 +160,14 @@ export const Toolbar = React.memo(function Toolbar({
>
<Upload size={16} />
</button>
<button
className="toolbar-btn"
onClick={onShowSearch}
title="多文件搜索 (Ctrl+Shift+F)"
aria-label="多文件搜索"
>
<SearchIcon size={16} />
</button>
<button
className="toolbar-btn"
onClick={onCycleTheme}
@@ -1,8 +1,9 @@
import 'fake-indexeddb/auto'
import { describe, it, expect } from 'vitest'
import { recentFilesRepository } from '../recentFilesRepository'
describe('recentFilesRepository (真实 AriaEngine + IndexedDB)', () => {
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端,jsdom 无 OPFS →
// schema.ts 自动回退 AriaEngine memory 后端(见 DISK_ENGINE 探测)。
describe('recentFilesRepository (真实 AriaEngine + memory 后端)', () => {
it('should add and read recent files', async () => {
await recentFilesRepository.add('/test/a.md')
const files = await recentFilesRepository.getAll(10)
+4 -3
View File
@@ -23,18 +23,19 @@ export const backupRepository = {
async importAll(data: BackupData): Promise<boolean> {
try {
const db = await getDb()
// 先清空现有四表,再按表导入(清空走事务保证原子性)
// B8-fix: 清空 + 导入在单个事务内完成(此前 clear 事务与逐表 importTable
// 分离,中途失败会留下半导入状态)
await db.transaction(async trx => {
for (const tableName of BACKUP_TABLES) {
await trx.table(tableName).clear()
}
})
for (const tableName of BACKUP_TABLES) {
const rows = data[tableName]
if (Array.isArray(rows) && rows.length > 0) {
await db.importTable(tableName, rows)
await trx.table(tableName).insertMany(rows as Record<string, unknown>[])
}
}
})
return true
} catch (error) {
logError('导入备份失败', error)
+82 -13
View File
@@ -36,12 +36,20 @@ export type RecentFile = {
lastOpened: number
}
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
// v0.6.1: 存储引擎回归 AriaEngineLSM-Tree + WAL + MVCC,功能最强)。
// sqlark 0.4.4 修复 SSTable 大 value 编码缺陷(v2 格式 "SSTC"
// u32 长度字段 + UTF-8 字节精确估算 + 超大条目独立成块,兼容旧 v1 格式)。
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端(v0.6.0 起),存储后端改用 KVStore
// 引擎(内存索引 + 快照/日志,落盘于 OPFS 目录 DB_NAME)。
// 不再考虑向下兼容:启动时直接删除旧 IndexedDB 库(见 deleteLegacyDatabases)。
const DB_NAME = 'MarkLiteV2'
// v0.6.2: OPFS 可用性探测 — Electron/Chromium 提供 navigator.storage.getDirectory
// jsdom 等测试环境无 OPFS,回退 AriaEngine memory 后端(测试数据不持久化)。
// kv 后端的 KVStore 底层 medium 同样基于 OPFS。
const DISK_ENGINE: 'kv' | 'memory' =
typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function'
? 'kv'
: 'memory'
/**
* v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)。
* v0.6.1: sqlark 0.4.2 已修复 IndexedDBEngine 版本管理(打开时自动自适应当前版本),
@@ -97,16 +105,73 @@ let isClosing = false
let initChain: Promise<unknown> = Promise.resolve()
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
// AriaEngine 的 IndexedDBBackend 内部库名 = `aria-${name}`sqlark 源码约定)
const DB_STORAGE_NAME = `aria-${DB_NAME}`
/** 删除损坏数据库(无活动连接时立即成功) */
/**
* 删除损坏数据库。
* v0.6.2: sqlark 0.7.4 的 KVStore 后端(KVStoreEngine)底层 medium 为 OPFSBackend
* 以库名(DB_NAME)为目录名存储在 OPFS 根目录下,删除 = 递归移除该目录。
*/
function deleteStorageDatabase(): Promise<void> {
return new Promise<void>(resolve => {
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME)
req.onsuccess = () => resolve()
req.onerror = () => resolve()
req.onblocked = () => resolve()
try {
if (!navigator.storage?.getDirectory) {
resolve()
return
}
navigator.storage
.getDirectory()
.then(root => root.removeEntry(DB_NAME, { recursive: true }))
.then(() => resolve())
.catch(() => resolve())
} catch {
resolve()
}
})
}
/**
* v0.6.2: 删除旧版本遗留的 IndexedDB 库 — 不考虑向下兼容。
* 覆盖旧 sqlark0.4.x,库名 aria-MarkLiteV2)与更早 DexieMarkLite)。
* 枚举 IndexedDB 中所有含 MarkLite 的库删除(失败静默,不阻塞启动)。
*/
function deleteLegacyIndexedDB(): Promise<void> {
return new Promise<void>(resolve => {
const finish = () => resolve()
try {
if (typeof indexedDB === 'undefined' || typeof indexedDB.deleteDatabase !== 'function') {
finish()
return
}
const candidates: string[] = ['aria-MarkLiteV2', 'MarkLiteV2', 'MarkLite']
const deleteOne = (name: string) =>
new Promise<void>(r => {
try {
const req = indexedDB.deleteDatabase(name)
req.onsuccess = () => r()
req.onerror = () => r()
req.onblocked = () => r()
} catch {
r()
}
})
// 优先枚举全部 IDB 库(Chromium 支持),找不到 API 则回退固定名单
if (typeof indexedDB.databases === 'function') {
indexedDB
.databases()
.then(dbs => {
const names = dbs
.map(d => d.name ?? '')
.filter(n => n.includes('MarkLite') && !candidates.includes(n))
return Promise.all([...candidates, ...names].map(deleteOne))
})
.then(() => finish())
.catch(() => Promise.all(candidates.map(deleteOne)).then(() => finish()))
} else {
Promise.all(candidates.map(deleteOne)).then(() => finish())
}
} catch {
finish()
}
})
}
@@ -128,8 +193,10 @@ async function resetCorruptDatabaseIfNeeded(): Promise<void> {
async function createDatabase(): Promise<MetonaSqlark> {
const db = await create({
name: DB_NAME,
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离sqlark 0.4.4 修复大 value 编码)
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
// v0.6.2: sqlark 0.7.4 DiskEngine 移除 indexeddb,选用 kv(自研 KVStore 后端:
// 内存索引 + 快照/日志,OPFS 落盘)。无 OPFS 环境(jsdom 测试)回退 memory。
diskEngine: DISK_ENGINE, // 底层存储后端(opfs | kv | memory
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
onError: (err: Error) => logError('数据库错误', err),
})
@@ -149,6 +216,8 @@ async function initDb(): Promise<MetonaSqlark> {
if (!healTriggered) {
healTriggered = true
await resetCorruptDatabaseIfNeeded()
// v0.6.2: 清理旧版本 IndexedDB 库(不向下兼容,静默删除)
await deleteLegacyIndexedDB()
try {
const db = await createDatabase()
// 创建成功:清除重置标记(自愈完成)
+9 -3
View File
@@ -4,7 +4,12 @@ import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
import { logError } from '../lib/errorHandler'
export const settingsRepository = {
async load(): Promise<Settings> {
/**
* B11-fix: 无记录时返回 null(首次启动)——
* 此前永远返回完整 DEFAULT_SETTINGS,导致 useSettingsInit 中
* "跟随系统主题偏好"分支成为死代码。
*/
async load(): Promise<Settings | null> {
try {
const db = await getDb()
const rows = await db.table('settings').select().where({ id: 'default' }).execute()
@@ -17,10 +22,11 @@ export const settingsRepository = {
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
}
}
return null
} catch (error) {
logError('加载设置失败', error)
return null
}
return { ...DEFAULT_SETTINGS }
},
// C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
@@ -28,7 +34,7 @@ export const settingsRepository = {
async save(partial: Partial<Settings>): Promise<void> {
try {
const db = await getDb()
const current: Settings = await this.load()
const current: Settings = (await this.load()) ?? DEFAULT_SETTINGS
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
const tbl = db.table('settings') as Table<SettingsRecord>
const rows = await tbl.select().where({ id: 'default' }).execute()
+12 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useAutoSaveStore } from '../stores/autoSaveStore'
import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast'
/** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000
@@ -79,15 +80,25 @@ export function useAutoSave(): {
isSavingRef.current = true
if (mountedRef.current) setIsAutoSaving(true)
// B1-fix: 记录保存时的内容快照 — 保存期间用户继续输入的话,
// 完成后仅当内容仍是快照时才清除 isModified(否则新输入会被误清标记
// 导致永不落盘、关闭时也无未保存提示)
const savedContent = tabToSave.content
try {
if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: tabToSave.filePath,
content: tabToSave.content,
content: savedContent,
})
if (result.success && mountedRef.current) {
const latest = useTabStore.getState().tabs.find(t => t.id === tabIdToSave)
if (latest && latest.content === savedContent) {
currentState.setModified(tabToSave.id, false)
}
} else if (mountedRef.current && !result.success && !result.canceled) {
showToast(`自动保存失败: ${result.error ?? '未知错误'}`, 'error')
}
} catch (error) {
logError('自动保存失败', error)
} finally {
+26 -4
View File
@@ -14,6 +14,7 @@ import { MeToast, showToast } from '../lib/toast'
export function useFileOperations() {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const updateTabFilePath = useTabStore(s => s.updateTabFilePath)
const setLoading = useEditorStore(s => s.setLoading)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
@@ -30,6 +31,8 @@ export function useFileOperations() {
if (result.filePath) recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
loading.success('文件已打开')
} else if (result && 'error' in result) {
loading.error(`打开失败: ${result.error}`)
} else {
loading.dismiss()
}
@@ -48,11 +51,14 @@ export function useFileOperations() {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
// B1-fix: 保存前记录内容快照 — 保存期间继续输入的话,完成后
// 仅当内容未变化时才清除 isModified,避免新输入被误清标记
const savedContent = tab.content
// v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
const result = await MeToast.promise(
window.electronAPI.saveFile({
filePath: tab.filePath,
content: tab.content,
content: savedContent,
}),
{
loading: '保存中...',
@@ -61,10 +67,15 @@ export function useFileOperations() {
},
)
if (result.success) {
const latest = useTabStore.getState().tabs.find(t => t.id === tab.id)
if (latest && latest.content === savedContent) {
useTabStore.getState().setModified(tab.id, false)
}
} else {
// IPC 返回 success:false 不 rejectpromise 的 error 文案不会触发,需显式提示
showToast('保存失败', 'error')
if (!result.canceled) {
showToast(`保存失败: ${result.error ?? '未知错误'}`, 'error')
}
}
} catch (error) {
logError('保存文件失败', error)
@@ -88,15 +99,20 @@ export function useFileOperations() {
error: '另存为失败',
},
)
if (result.success) {
if (result.success && result.filePath) {
// B2-fix: 另存为成功后把标签重新绑定到新路径(此前标签仍指向旧文件,
// 之后 Ctrl+S/自动保存会写回旧文件)
updateTabFilePath(tab.id, result.filePath)
useTabStore.getState().setModified(tab.id, false)
recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
} else if (!result.canceled) {
showToast('另存为失败', 'error')
}
} catch (error) {
logError('另存为失败', error)
}
}, [getActiveTab])
}, [getActiveTab, updateTabFilePath])
const handleOpenRecent = useCallback(
async (filePath: string): Promise<void> => {
@@ -108,7 +124,13 @@ export function useFileOperations() {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
} else {
// B13-fix: 打开失败(文件被删除/移动)显式提示,不再静默
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
}
} catch (error) {
logError('打开最近文件失败', error)
showToast(`无法打开 "${filePath}"`, 'error')
} finally {
setLoading('file-open', false)
}
+5 -5
View File
@@ -7,20 +7,20 @@ import { useEditorStore } from '../stores/editorStore'
* 使用 Zustand store 状态替代 DOM CustomEvent
*/
export function useFileWatch() {
const getActiveTab = useTabStore(s => s.getActiveTab)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
useEffect(() => {
if (!window.electronAPI) return
// B5-fix: 任何打开的标签对应的文件被外部修改都提示(此前只检测活动标签,
// 后台标签被外部改后切过去无感知,显示的还是内存旧内容)
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab()
if (tab && tab.filePath === filePath) {
// AR-03: 直接更新 store 状态,不再派发 CustomEvent
const tabs = useTabStore.getState().tabs
if (tabs.some(t => t.filePath === filePath)) {
setExternallyModified({ filePath })
}
})
return unsubscribe
}, [getActiveTab, setExternallyModified])
}, [setExternallyModified])
}
+8 -1
View File
@@ -11,6 +11,7 @@ export function useKeyboard(
handleOpenFile: () => void,
handleSave: () => void,
handleSaveAs: () => void,
handleShowSearch: () => void,
) {
const handleKeydown = useCallback(
(e: KeyboardEvent) => {
@@ -32,6 +33,12 @@ export function useKeyboard(
handleSaveAs()
return
}
// v0.6.2: 多文件搜索
if (isCtrl && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
e.preventDefault()
handleShowSearch()
return
}
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') {
@@ -64,7 +71,7 @@ export function useKeyboard(
return
}
},
[handleOpenFile, handleSave, handleSaveAs],
[handleOpenFile, handleSave, handleSaveAs, handleShowSearch],
)
useEffect(() => {
+7 -5
View File
@@ -3,6 +3,7 @@ import { useEditorStore } from '../stores/editorStore'
import { useSidebarStore } from '../stores/sidebarStore'
import { settingsRepository } from '../db/settingsRepository'
import { logError } from '../lib/errorHandler'
import { DEFAULT_SETTINGS } from '../types/settings'
/**
* AR-04: 统一设置加载 hook
@@ -21,20 +22,21 @@ export function useSettingsInit() {
settingsRepository
.load()
.then(settings => {
// 主题:优先使用保存的设置,否则跟随系统偏好
// B11-fix: 首次启动(settings 为 null)时主题跟随系统偏好
// 有保存记录则使用保存值
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
const themeMode = settings?.themeMode ?? (prefersDark ? 'dark' : 'light')
setThemeMode(themeMode)
// 视图模式
setViewMode(settings.viewMode ?? 'editor')
setViewMode(settings?.viewMode ?? 'editor')
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB
const sidebarStore = useSidebarStore.getState()
if (!sidebarStore._loaded) {
useSidebarStore.setState({
isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth,
isVisible: settings ? !settings.sidebarCollapsed : true,
sidebarWidth: settings?.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
_loaded: true,
})
}
@@ -249,6 +249,32 @@ describe('tabStore', () => {
})
})
describe('updateTabFilePath (B2)', () => {
it('should rebind tab to a new file path (save-as)', () => {
const { createTab, updateTabFilePath } = useTabStore.getState()
const tab = createTab('/old.md', '# Content')
updateTabFilePath(tab.id, '/new.md')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.filePath).toBe('/new.md')
expect(updated.content).toBe('# Content')
})
it('should not affect other tabs', () => {
const { createTab, updateTabFilePath } = useTabStore.getState()
const tab1 = createTab('/a.md')
const tab2 = createTab('/b.md')
updateTabFilePath(tab1.id, '/c.md')
const state = useTabStore.getState()
expect(state.tabs.find(t => t.id === tab1.id)?.filePath).toBe('/c.md')
expect(state.tabs.find(t => t.id === tab2.id)?.filePath).toBe('/b.md')
})
})
describe('updateTabContent same-content guard (A3)', () => {
it('should not mark as modified when content is unchanged', () => {
const { createTab, updateTabContent } = useTabStore.getState()
+44
View File
@@ -39,6 +39,7 @@ interface TabState {
moveTab: (fromId: string, toIndex: number) => void
switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void
updateTabFilePath: (tabId: string, filePath: string) => void
setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null
updateTabScroll: (
@@ -101,11 +102,46 @@ export const useTabStore = create<TabState>((set, get) => {
selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd,
}))
// v0.6.2: 磁盘 mtime 比对 — 未修改的标签若磁盘内容比快照新
// (应用关闭期间被外部修改),恢复时读取磁盘最新内容
let refreshed = 0
if (typeof window !== 'undefined' && window.electronAPI) {
for (const tab of tabs) {
if (!tab.filePath || tab.isModified) continue
const snap = snapshots.find(s => s.id === tab.id)
if (!snap || !snap.updatedAt) continue
try {
const stats = await window.electronAPI.getFileStats(tab.filePath)
if (
stats.success &&
stats.mtime &&
new Date(stats.mtime).getTime() > snap.updatedAt
) {
const read = await window.electronAPI.readFile(tab.filePath)
if (read.success && read.content !== undefined) {
tab.content = read.content
refreshed++
}
}
} catch {
// 单个文件校验失败不影响整体恢复
}
}
}
const activeTabId =
savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
? savedActiveTabId
: tabs[tabs.length - 1].id
set({ tabs, activeTabId, _loaded: true })
if (refreshed > 0) {
_debouncedSaveToDB?.()
try {
const { showToast } = await import('../lib/toast')
showToast(`${refreshed} 个标签已同步磁盘最新内容`, 'info')
} catch {
/* 提示失败不影响主流程 */
}
}
} else {
set({ _loaded: true })
}
@@ -255,6 +291,14 @@ export const useTabStore = create<TabState>((set, get) => {
}))
},
// B2-fix: 另存为成功后重新绑定标签的文件路径(并触发快照持久化)
updateTabFilePath: (tabId: string, filePath: string) => {
set(state => ({
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, filePath } : t)),
}))
_debouncedSaveToDB?.()
},
getActiveTab: (): Tab | null => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
+182
View File
@@ -942,6 +942,188 @@ button:focus-visible,
border: 0;
}
/* ===== v0.6.2: 多文件搜索面板 ===== */
.search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 10001;
padding-top: 8vh;
animation: fadeIn 0.15s ease;
}
.search-panel {
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
width: 640px;
max-width: 92vw;
max-height: 76vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
.search-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.search-form {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text);
font-size: 13px;
font-family: var(--font-ui);
outline: none;
transition: border-color 0.15s ease;
}
.search-input:focus {
border-color: var(--primary);
}
.search-run-btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius);
background: var(--primary);
color: white;
font-size: 13px;
font-family: var(--font-ui);
cursor: pointer;
white-space: nowrap;
}
.search-run-btn:disabled {
opacity: 0.5;
cursor: default;
}
.search-options {
display: flex;
align-items: center;
gap: 14px;
margin-top: 8px;
}
.search-option {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.search-option input {
accent-color: var(--primary);
}
.search-close-btn {
margin-left: auto;
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
}
.search-close-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.search-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.search-empty {
padding: 32px 16px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.search-summary {
padding: 8px 14px;
font-size: 12px;
color: var(--text-tertiary);
border-bottom: 1px solid var(--border-light);
}
.search-results {
padding: 4px 0;
}
.search-result-item {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
padding: 6px 14px;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
font-family: var(--font-ui);
text-align: left;
cursor: pointer;
transition: background 0.1s ease;
}
.search-result-item:hover {
background: var(--primary-light);
}
.search-result-file {
flex-shrink: 0;
font-weight: 500;
color: var(--primary);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result-line {
flex-shrink: 0;
color: var(--text-tertiary);
min-width: 24px;
text-align: right;
}
.search-result-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
/* ===== ErrorBoundary ===== */
.error-boundary-root {
display: flex;
+4
View File
@@ -7,6 +7,8 @@ import type {
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult,
SearchInDirPayload,
SearchInDirResult,
} from '../../shared/types'
export interface IpcInvokeMap {
@@ -24,6 +26,7 @@ export interface IpcInvokeMap {
'dir:openDialog': [void, string | null]
'dir:watch': [string, void]
'dir:unwatch': [void, void]
'dir:search': [SearchInDirPayload, SearchInDirResult]
'data:export': [
string,
{ success: boolean; canceled?: boolean; filePath?: string; error?: string },
@@ -49,6 +52,7 @@ export interface ElectronAPI {
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
searchInDir: (payload: SearchInDirPayload) => Promise<SearchInDirResult>
exportData: (
content: string,
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.6.1'
export const APP_VERSION = 'v0.6.2'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
+1
View File
@@ -15,6 +15,7 @@ export const IPC_CHANNELS = {
DIR_OPEN_DIALOG: 'dir:openDialog',
DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch',
DIR_SEARCH: 'dir:search',
// v0.6.0: 数据备份导出/导入(JSON
DATA_EXPORT: 'data:export',
+24
View File
@@ -63,3 +63,27 @@ export interface OpenFileError {
}
export type OpenFileResponse = OpenFileResult | OpenFileError | null
// v0.6.2: 多文件搜索
export interface SearchInDirPayload {
dirPath: string
query: string
caseSensitive?: boolean
useRegex?: boolean
}
export interface SearchMatch {
filePath: string
/** 1-based 行号 */
line: number
/** 匹配行内容(截断至 200 字符) */
lineText: string
}
export interface SearchInDirResult {
success: boolean
matches?: SearchMatch[]
totalFiles?: number
truncated?: boolean
error?: string
}