本地优先、可高度自定义、高性能的 Markdown 知识库桌面应用。
你的笔记就是普通文件夹里的普通 Markdown 文件 —— 可被 Git 管理、可被任何编辑器打开、随时可以整体搬走。 应用不联网、不遥测、不上传;所有派生数据(未来的索引、图谱)都可以从文件重建。
当前状态:M1 / M1.5 / M2 已交付,M3(体验完善)范围已全部交付(含拖拽整理文件与跨目录移动的全库链接改写)。 范围与后续里程碑见
docs/milestones.md,架构与决策见docs/architecture.md。
| 能力 | 说明 |
|---|---|
| 打开 Vault | 系统目录选择框,或命令行 mimenote.exe <目录> 直接打开 → Rust 侧迭代式扫描(不跟随符号链接、忽略 .git/node_modules 等)→ 一次调用返回概要与完整条目表 |
| 文件树 | 虚拟列表(固定行高 + overscan,10 万条目也只挂载几十行 DOM)、键盘导航(↑↓←→/Enter/Delete)、中文与数字自然排序、子串过滤并自动保留祖先 |
| 编辑器 | CodeMirror 6 + 所见即所得(Live Preview):标题/引用/列表/任务复选框/代码块/分隔线/GFM 表格/callout 在正文里直接渲染,列表标记也真的渲染出来(无序画 • / ◦ / ▪,有序画算出来的序号 —— 源码写 1. 1. 1. 就显示 1. 2. 3.、3. 开头就显示 3. 4.,序号右对齐在等宽栏里所以正文左边界齐平;光标进入该行整行露原文),**粗体**、*斜体*、`代码`、[文字](链接)、[[双链]] 在光标不在该处时隐藏标记、光标进入即露出原文;图片  与 ![[…]] 直接显示;表格按分隔行决定每列对齐、列多时横向滚动而不是把正文挤宽、单元格里的行内语法(粗体/代码/链接/[[双链]]/图片)照常渲染,光标一进表格就整块露出原文(列宽是全表共享的,"只露一行"会让竖线对不上、视线反复重定位),表格在引用块/列表里或超过 200 行时原样显示;callout(> [!note] 标题,ADR-0022)的标记换成图标、标题保持可编辑的真文字、[!note]- 的正文真的收起(光标进入即展开)、点图标把 - ↔ + 写回文档(走同一条保存流水线);输入路径零 IO、零全量重渲染(装饰按视口计算,见 ADR-0009) |
| 链接补全 | 敲下 [[ 自动弹出当前 Vault 的笔记候选(![[ 之后连图片等附件一起列),边打字边过滤、↑↓ 选择、Enter/Tab 确认、Esc 取消、Ctrl+Space 手动唤出;排序与宿主消歧规则一致(同名多篇都能选到,冲突时自动写成相对当前笔记的路径),代码块/行内代码/frontmatter 里不弹,弹层关闭时 Tab/Enter/Esc 的既有语义一字不变 |
| 表格格式化 | Ctrl+Alt+F(或命令面板「格式化表格」)把光标所在的 Markdown 表格对齐:按显示宽度算列宽(中文算两列,否则永远差一点)、保留 :---/:---:/---: 三种对齐、单元格里的转义竖线 | 不当分隔符;只改空白与竖线位置,内容一个字符都不动,已经是整齐的表格再按一次不会产生任何 diff |
| 视图 | 主区域三选一:编辑(所见即所得)/ 阅读(渲染后)/ 知识图谱;"编辑 + 预览"双栏已移除(ADR-0009) |
| 保存 | 防抖自动保存(默认 600ms)+ Ctrl+S;写入串行化;原子替换(同目录临时文件 → fsync → rename);状态栏显示每次写入实测耗时 |
| 冲突保护 | mtime 版本令牌;文件被外部修改时拒绝静默覆盖,弹出横幅让用户选「覆盖 / 重新加载」 |
| 外部改动自动同步 | 在应用之外改 Vault(资源管理器 / 别的编辑器 / 同步盘把别的设备的改动落下来)→ 几秒内文件树与索引自己跟上,不再需要手动重扫(ADR-0016):宿主用 notify 监听 Vault 根(Windows 上是 ReadDirectoryChangesW),把事件去抖合并(静默期 500ms、风暴硬上限 2s)后重扫条目表,并在后台增量重建索引(不阻塞输入、可取消)。当前打开的笔记:没有未保存修改 → 自动重新加载并提示"已在磁盘上更新,已重新加载";有 → 进既有的冲突横幅(绝不自动覆盖、也绝不静默丢弃)。应用自己写的文件不会被当成外部改动(判定的是"磁盘 vs 条目表",而保存/新建/改名/搬迁都会立刻更新条目表),并且 .mimenote/ 内部(缓存库每轮构建都在写)、原子写的 .mimenote-*.tmp、扫描器本来就跳过的路径(.git/node_modules/隐藏项)一律忽略;换 Vault / 关闭 Vault 会停掉旧监听(副作用可逆) |
| 换行保真 | 识别并保留 CRLF / LF 与 UTF-8 BOM,避免"保存一次 = 全文 diff" |
| 预览 | markdown-it(关闭 raw HTML)+ DOMPurify 二次净化的实时预览;useDeferredValue 降优先级。任务列表 - [ ] / - [x] 渲染成禁用的原生复选框(ADR-0024;已完成的任务加删除线,ADR-0027 推翻了"只变暗"的旧取舍,编辑器里删除线只圈文字、不划复选框 ——判据只有 domain/task-list.ts 一份,结论写在 token 上由阅读视图 / 导出件 / 应用内打印 / 静态站点 / 图谱卡片五处消费;阅读视图是只读的,勾选请去编辑器;净化白名单为它单独放开 input,并由 DOMPurify 钩子强制"只有禁用的 checkbox 能活下来";已完成只变暗、不用删除线)。callout(> [!note] 标题,ADR-0022)与阅读视图、单篇导出、静态站点、图谱卡片同一份判据、同一套样式:13 种类型(含 hint/caution/done 等别名,大小写不敏感)、未知类型按 note 渲染但保留用户写的类型名当标题、[!note]-/[!note]+ 渲染成低调的折叠角标(静态渲染里不真的收起 —— 读者看不到内容只会以为那篇笔记里没有它)、> > [!tip] 归内层。大文档做了三件事(ADR-0020):① 图片授权不再触发整篇重渲染 —— 授权回来的图片就地把占位元素换成真图(从前每 200 张授权就会把整篇重渲染一遍,1000 张图 = 6 次全量渲染);② wikilink 补类名线性化(从前每个元素都做一次 outbound.find 且两侧都归一化,2000 条链接就是几千万次比较);③ 超过 1 MiB 的文档把 markdown-it 解析搬进 Web Worker(净化与 DOM 落地只能在主线程,占大头;界面上用 data-mn-render="worker|sync" 标出走的哪条路),Worker 不可用时自动回落同步路径。仍未做:分块 + 视口窗口化(那才是消灭十万级 DOM 元素的办法,见 ADR-0020 的"本轮没有做") |
| 双链与反链 | [[笔记]] / [[笔记|别名]] / [[笔记#小节]] / ![[嵌入]] 解析与点击跳转;右侧链接面板显示反向链接与出链(点反向链接会跳到来源笔记里写出这条链接的那一行);悬空链接标黄并点击即创建;同名多篇按「同目录 → 更短路径 → 字典序」消歧并标记 |
| 重命名 | 文件树 F2(或工具栏铅笔)改名:宿主同步改写全库指向它的链接(保留别名/锚点、跳过代码块、BOM 与换行保真)并增量更新索引;正在编辑的笔记原地换路径(保留光标与撤销历史),其他被改写的文件自动重新加载 |
| 拖拽整理文件 | 把笔记或文件夹拖到另一个文件夹即可移动:拖到文件夹行 = 移到那个文件夹、拖到另一篇笔记上 = 移到它所在的目录、拖到树的空白区域 = 移到 Vault 根目录;悬停时目标行给出明确落点反馈(已经在该目录则标成不可放置)。移动用原子 rename(跨卷退回复制 + 删源)、改写全库指向它的链接(跨目录写成相对新位置的路径,避免裸名链接被重新解释到别的同名笔记)、增量更新索引与文件树;目标目录已有同名文件时拒绝覆盖并提示。拖文件夹时多一条守卫:拖进它自己或它的后代是无效落点(悬停时给出原因,宿主也拦同一件事),否则等于把目录搬进自己里面。目录不存在会自动创建(F6 / 工具栏的「移动到文件夹」按钮 / 命令面板「移动到文件夹…」是同一条链路的键盘入口) |
| 目录重命名 / 目录移动 | 文件树里选中文件夹按 F2 改名、或拖到另一个文件夹 / 用 F6 移动:整棵子树的路径跟着变(项目/子项目/细节.md → 工程/子项目/细节.md)并且全库指向子树里每一篇的链接被精确改写([[项目/甲]]、[[子/丙]]、相对路径、裸名 wikilink 按需重算;BOM/换行保真、跳过代码块),索引与文件树增量更新。磁盘上整棵目录一次原子 rename(跨卷退回复制 + 删源);目标同名目录拒绝(绝不覆盖、也绝不把两棵子树合并)。正在编辑的笔记在子树里时原地换路径(保留光标与撤销历史),标签页整棵子树换前缀(顺序不乱)。1000 篇的目录实测 2–10 s(与被引用的篇数成正比;见 ADR-0015) |
| 标签与属性 | mn-core 抽取 frontmatter 的 tags/tag 字段与正文行内 #标签(跳过代码块、行内代码、HTML 注释、标题行;#父/子 层级;#Rust 与 #rust 归为一个);右侧标签面板显示本篇标签、frontmatter 属性表与全库标签(可点标签展开"哪些笔记用了它"→ 点笔记直接打开)。改标签不用再手动编辑正文:frontmatter 标签旁边有 ×,下面一个输入框(回车提交;一次可加多个、逗号分隔,开头的 # 与首尾空白会被清掉,标签里可以有空格),改完立刻写盘 —— 面板、全库标签与索引(标签/搜索/图谱)同步跟上,正在编辑的笔记不丢撤销历史。写盘沿用 ADR-0004 那条路(先落盘 → 令牌校验 → 原子写 → 索引增量同步):磁盘被外部改过就返回 CONFLICT 并走同一条冲突横幅,绝不静默覆盖(见 ADR-0006「后续修订」)。正文行内 #标签 在加/删时只读(带「正文」角标,点 × 会说明"请到正文里删")。 |
重命名 / 合并:面板每一行(本篇标签与全库标签概览)都有 ✎ 入口 → 三段式对话框(输入 → 预览"这会改 N 篇"(不落盘)→ 确认)→ 全库改写:frontmatter 的 tags 与 tag 两个字段、以及正文行内 #标签 一起改(跳过围栏代码块、行内代码、HTML 注释、标题行 —— 判据与抽取器是同一个扫描器);「连同子标签」默认开,父→母 时 父/子→母/子(只换前缀那一段,后缀逐字保留);合并时同一篇里重复的提及会被去掉。跳过如实汇报("改了 12 篇,3 篇没改(磁盘被外部改动)"),改过的文件重试安全(幂等,不写盘)。按标签过滤文件树:文件树头部一个标签选择器 → 只留用了这些标签的笔记(祖先目录保留、多选是并集、「含子标签」是显式开关、计数显示"只显示 M / 共 N 篇"、Esc 一键回到全量);选项行末尾的「排除」= 有 A 且没有 B("不含"以单独的胶囊列出、可与多个标签组合、与"含"互斥),命中集合由宿主的 tag_filter 一次算完(层级展开也在那边做,不再逐个标签问);它是只读收窄,拖拽/F2/新建/删除照旧,只有"拖到树的空白区域 = 移到根目录"在过滤期间被禁用(列表短、空白大,误拖会静默搬家)。 |
|
层级编辑:全库标签概览里每个标签旁的 ⇥ 打开「移到…」对话框 —— 输入父标签把它挂过去(#甲 → #父/甲),留空 = 提回顶层(#父/甲 → #甲),也可以点「移到顶层」一键清空。规则是只换祖先、不动名字(#父/甲/孙 移到 #母 下面是 #母/孙);连同子标签一起移动默认开(#甲/子 → #父/甲/子)。移动 ≠ 合并:目标标签已经存在时拒绝并提示用「重命名 / 合并」——静默合并会让你以为只是换了个位置。非法移动(挂到自己、挂到自己的子标签下、父标签里写了 父//子、已经在那里了)在写盘前就被拦下,理由原话显示;它与重命名共用同一条写路径(同一把写锁、同一份令牌对账、同一个原子写、同一处索引同步),对话框也共用(三段式预览 → 确认 → 如实汇报)。仍推迟:标签面板上的拖拽成树、多选一起移动 |
|
| 快速切换与命令面板 | Ctrl+P 在当前 Vault 的笔记里模糊搜索(子序列匹配 + 命中高亮,1 万篇下按键路径仍是一次 O(n) 扫描);Ctrl+K 列出全部命令并执行(依赖 Vault 的命令置灰并说明原因);两者共用同一个面板,↑↓/Enter/Esc 与点选都支持 |
| 全文搜索 | Ctrl+Shift+F 搜索正文(SQLite FTS5 + bm25 排序,中文与多词都支持);结果按笔记分组显示行号与片段并高亮命中词,回车/点击打开命中笔记并把光标落到那一行(滚到视口正中、该行闪一下;同一篇笔记的多条命中各跳各的行)。索引是缓存:<Vault>/.mimenote/cache/search.db,删掉即自动重建 |
| 附件预览(这一轮) | 文件树里点一张图片不再只是选中它: 文件树里点一张图片不再只是选中它:主区换成只读查看器,走 asset: 逐文件授权把图真的显示出来(naturalWidth > 0),带「适应窗口 ↔ 实际大小」开关与"像素尺寸 · 文件大小 · 路径"信息行(ADR-0032)。三条纪律:只读(编辑仍然只发生在 Markdown 上)、不进标签页(标签页仍然是"我开着哪几篇笔记")、非桌面环境不假装(浏览器预览里显示一句说明而不是永远转圈)。状态栏最左跟着换成"我正在看的那张图",打开一篇笔记 / 点状态栏视图按钮 / 换 Vault 都会回到笔记。判据只有 domain/viewable.ts 一份。纯文本也走同一根主轴:.txt / .log / .json / .csv / .toml / .yaml / 源码… 用等宽、保留空白、带「折行 ↔ 不折行」开关的纯文本查看器显示(宿主 note_read 本来就不限扩展名,一个新 IPC 都没加);.zip/.db 这类二进制不按文本打开(乱码比"打不开"更像 bug)。结构化查看器(JSON 折叠树 / CSV 表格 / frontmatter 属性面板)是下一片,挂同一根主轴 |
| 本地图片 | 预览与编辑器都渲染本地图片(、![[附件/图.png]];相对路径、../、根绝对三种写法),两处都能点击放大(阅读视图与所见即所得里的图片是两套不同的元素,灯箱都认)。尺寸用 Obsidian 的写法:![[图.png|300]] 定宽、![[图.png|300x200]] 定宽高(数字以外的别名仍是图注 —— ![[图.png|一张示意图]])。图片经 asset 协议逐文件授权:路径先由 path_guard 逐级检查符号链接与越界,只放行通过校验的那一个文件(见 ADR-0007);点击放大(灯箱:缩放/平移/Esc 关闭,同一篇里有多张时用「上一张 / 下一张」或 PageUp/PageDown 翻页并显示"第 N / 共 M 张",被超大图封顶裁短时角标提示"点击查看原图"),加载失败回退成占位元素而不是裂图;独占一段的图片渲染成块级居中,段落里与文字混排的图片仍随文字排 |
| 粘贴 / 拖入图片 | 编辑器里 Ctrl+V 粘一张截图(或从资源管理器拖入图片)即写进 Vault 的附件目录(默认 附件,可在设置页改,不存在则自动创建)并插入 :保留原文件名、同名追加 1/ 2 绝不覆盖、通用名(image.png/blob/无扩展名)换成带时间戳的名字;紧跟的光标落到图片下一行,因此插入即可见(所见即所得)且 Ctrl+Z 一次可撤销。宿主只接受图片扩展名(与渲染授权同一份白名单)、单张 ≤ 8 MiB、一批 ≤ 32 MiB,路径一律过 path_guard、走原子写(见 ADR-0013);拖入非图片会明确拒绝并说明原因(不静默失败),纯文本粘贴完全不受影响 |
| 知识图谱 | Ctrl+G 打开后默认是关系图(ADR-0021/0023):以当前打开的笔记为圆心,只显示与它相关的子图,离中心几跳就是半径(同心环作为种子),深度可调 1..5 跳且双向(出链与反链都算一跳),每张卡片正面就是那篇笔记的完整 Markdown 预览(标题/段落/列表/引用/代码/表格/图片占位/提示框,与阅读视图同一套块清单)。种子之上跑力导向浮动态:张力(弹簧自然长度)/斥力/向心力/阻尼可调、五个预设(均衡 / 紧凑 / 舒展 / 漂浮 / 聚焦),节点像漂浮一样被张力牵着走;按住一张卡片它就停在原地而其余继续漂(HUD 上有「松开卡片」),拖动时卡片跟着光标走、被撞上的那张自己让开(交互会给力场重新加热,所以邻居是被斥力/弹簧推开的、不只是"撞上去才动";拖动中的那张画在最上层,不会滑到别人下面;漂浮开着时力场有一个很小的强度底值,静止时也在缓慢呼吸)。HUD 上的「力度管理」把每一个旋钮都摆出来逐项调(向心力 / 斥力 / 弹簧强度与长度 / 阻尼 / 速度上限 / 弹簧范围 / 碰撞强度与轮数 / 强度衰减),整套参数跨会话保存,手调之后角标会如实说"这不只是原来那一档了";笔记之间会碰撞:斥力只是"倾向",碰撞才是"保证"—— 落定之后卡片不会重叠(宿主的 data-graph-overlaps 恒为 0,端到端用例钉住;「漂浮」档力永不降温,密集时可能留下 0.07 像素级的重叠,界面里明说了)。连线不是从卡片边缘凭空开始的:正文里 [[甲]] 那一段文字先画一段虚线引到卡片边界,出了卡片才变成实线与张力曲线(张力滑块可调,0 就是直线),分界处严丝合缝(两段端点逐坐标相同、虚线相位收在边界上、两端都是 butt 线帽),而且卡片内那段单独成层画在卡片之上 —— 卡片是不透明底画在 canvas 上的,引线留在下面会被整段盖掉、看起来就是"线从卡片边缘开始";找不到对应文字时如实降级成"从卡片边界出发"并在 tooltip 里说明。卡片画在 canvas 上(一帧只画视口内的卡片:80 张全在屏幕里也只要 ≈1 ms),缩放时文字重新排版而不是被放大成模糊的一团;卡片大小可调(拖右下角把手改宽度、HUD 上选高度上限档位,按 Vault 持久化)。方向键在卡片间移动选中项、单击预览正文、双击进编辑器打开(这也顺带换掉了圆心——圆心跟着当前笔记走);选中一张卡片后可以浮窗打开:可拖动、可缩放、可多个并存、点一下置顶,Esc 先关最上面那个浮窗(浮窗不持久化——它是临时的阅读姿势)。HUD 上还有"关系图 / 整个 Vault"两个按钮、深度加减、"定位笔记…"(按名字直达,卡片在折叠的容器里会自动展开再定位)。整个 Vault 视图仍保留原来的全库结构视图:文件夹自动成组可收起、卡片可拖动并持久化位置、连线入链虚线出链实线、指向还不存在的笔记时标出用户写下的目标名(节点上限 8000)。视图与深度记在 localStorage,不按 Vault 分 |
| 图谱卡片与连线(这一轮) | 卡片正面就是完整正文(ADR-0025):Frontmatter 不再画在卡片上(判据仍是 domain/frontmatter.ts 那一份);「仅标题」档把每篇压成一行高(不排正文,引线如实降级);缩放手柄同时调宽与高,高度档新增**「全文」(不截断,与数值档互斥 —— 长文的卡片会非常高);单击 = 选中(相关连线高亮),"读全文"交给浮窗,停靠预览面板已移除;按住一张卡片后失去焦点即松开**(点空白或选中换走,都会加热力场让它继续漂)。连线按用户建议重做过(ADR-0028):语义色相 —— 暖色 = 我指向它、冷色 = 它指向我、中性 = 环与环之间;越远越细越淡(跳数编码线宽与透明度,强调/淡化是在这个基准上乘性调制);环向走线 —— 同一环的边沿"卡片最远的角之外"的弧走、跨环的边朝外鼓,都不再横穿圆心那块最挤的地方(HUD 上的「沿环走线」可关);悬停正文里某段 [[链接]] 时对应连线提亮、引线也一起亮;引线本身加粗加深但仍是虚线 |
| 图谱视觉刷新(这一轮) | 画布加世界坐标点阵(跟着平移/缩放走,屏幕间距 < 22px 时整层跳过)与中心极淡聚光;卡片圆角 6 → 10,当前笔记常驻一圈淡光晕、悬停/选中时更亮(描边模拟,不给画布加阴影);HUD 改成半透明玻璃浮层 + 分段控件 + 无边框胶囊(DOM 结构与 data-* 抓手一字未动,自动化不受影响);状态栏把知识图谱按钮从编辑/阅读那组里分出来(中间一条细竖线)—— ADR-0039 |
| 容器切割树(这一轮) | 界面是一棵二叉切割树(ADR-0035):每个格子都是"标签条 + 内容",笔记与视图模块(文件树/链接/标签/大纲)是同一类标签,混排在格子顶部;标签拖到别的格子就是搬过去(标签条上 = 插到第几位,内容区中央 = 并入,四边带 = 切一刀分出新格),格子间的分隔条拖比例、双击均分。键盘等价物 Alt+1/2/3(搬到主区左/右/下)与 Alt+←/→(条内换位置),模块标签的右键菜单与键盘共用同一处判据。一格可写、其余可读:编辑器全局一份(单文档模型),别的格子里的笔记渲染只读预览(与图谱浮窗同一条渲染链路),点它的标签把编辑器搬过去。切割树持久化(mimenote.ui.v1 的 layout),旧的 dockLayout 键保留作回滚;可见性仍归各开关(Ctrl+B 等一字未改),"隐藏 = 那一格收缩,再开就回原位"。三区停靠(ADR-0026)是它的退化特例,老配置迁移时按旧像素宽度换算比例(文件树不会突然占半窗)。尺寸防护:分隔条除比例钳制外还有 120px 绝对像素下限(嵌套切割切不出几十像素的废格);已落盘的窄格降级为只留标签条(标签可点可拖自救);命令面板有「重置布局」兜底(回到与首跑逐像素一致的默认分栏) |
| 模块右键菜单(这一轮) | 一个通用右键菜单接入四处:文件树行(打开/在这里新建/重命名/移动到文件夹/定位/复制相对路径/展开折叠全部/删除到回收站)、标签页(关闭 / 关闭其他 / 关闭全部 / 在文件树中定位)、模块标签(搬到主区左/右/下、条内前/后移、隐藏这一块)、图谱卡片(在编辑器中打开 / 浮窗打开 / 居中显示 / 松开这张卡片 / 恢复自动尺寸)。菜单项直接执行既有命令,与 F2 / F6 / Delete、工具栏按钮完全同一条链路;点击外部、Esc、↑↓、超出视口自动夹回都齐 |
| 文件树排序(这一轮) | 工具栏上的排序菜单可配置依据(名称 / 修改时间 / 大小 / 类型)、方向与目录是否在前(ADR-0027):判据只有 domain/tree.ts 一份,mtimeMs: null(目录、附件)恒排最后且不随方向翻转,兜底键永远升序(结果与输入顺序无关);默认仍是"目录在前 + 名称自然序",与从前逐字节一致 |
| 最近打开的 Vault(这一轮) | 左侧栏底部固定一栏"最近 · N":最多 8 个 Vault、按根路径去重、最新在前、每条可 × 移除;点一条即切换(先落盘未保存内容);closeVault 不清它 —— 它就是为"切走再切回来"准备的 |
| 设置与菜单 | 应用菜单(文件树格子右下角,ADR-0034)按分类列出全部命令(带快捷键,↑↓/Enter/Esc 可用);设置页(Ctrl+,)四个分区:外观(主题/界面字号/编辑器字号/阅读视图字号)、编辑器(自动保存延迟/Tab 宽度/显示行号(可关:关掉之后正文宽出十几像素)/附件目录)、Vault(CSS 片段开关、索引状态与重扫)、关于(版本与 Vault 统计);所有设置即时生效(编辑器那几项走 CodeMirror 的 Compartment,不重建编辑器:文档、光标、选区与撤销历史都不动)并持久化 |
| 多标签页 | 打开过的笔记排在各自格子的标签条里(ADR-0035 的容器切割树:标签条是每格一条,不再有全窗口唯一的那条 —— 它曾先后在主区域里、标题栏上方、标题栏中区 —— ADR-0026/0034):点击切换、× 或中键关闭、Ctrl+W 关当前、Ctrl+Alt+←/→ 循环、Ctrl+1..9 直达第 N 个;未保存(●)与冲突(⚠)标记只出现在激活标签上;切换前自动落盘(复用 note-store 的保存流水线,不另开写路径);按 Vault 根持久化,重开时回到上次的工作现场。功能型标签(文件 / 链接 / 标签 / 大纲)用图标显示(16px,面板开关不是文档,与工具栏图标按钮同一套视觉语言;文字说明在悬停提示里),命中区是整条标签,笔记标签仍是“文件名 + 关闭”(ADR-0038) |
| 导出 | Ctrl+Shift+S 导出自包含 HTML:正文用与阅读视图同一套净化管线渲染,本地图片经 asset_read_base64 内嵌成 data: URL、主题令牌取成字面值 —— 产物是单个文件,可直接在浏览器打开或发给别人;Ctrl+Shift+P 导出 PDF(把同一份正文挂到只在 @media print 可见的容器后调系统打印对话框,"另存为 PDF",打印结束即清理) |
| 整库导出静态站点 | Ctrl+Alt+S(或文件树工具栏「导出」里的第三个选项)把整个 Vault 导出成一个静态站点(ADR-0019):每篇笔记一个 HTML(镜像目录树:项目/设计.md → 项目/设计.html)、[[双链]] 变成指向另一份 HTML 的相对链接(悬空的渲染成不可点的文字,不做假链接)、每页底部列出反向链接、图片按 assets/<原路径> 复制(不内嵌 data:,同一张图不会在几千个页面里各存一份)、整站一份 assets/site.css(复用单篇导出的排版,外加导航/索引/反链样式)、根目录一个 index.html 按目录分组列出全部笔记。零 JavaScript:双击 index.html(file://)就能读,折叠用 <details>、明暗跟随系统。输出目录必须选在 Vault 之外(写进 Vault 会触发重扫、还会被同步盘整站上传);非空目录必须是我们上次导出过的(靠 mimenote-export.json 标记),从不删除任何文件;同一个 Vault 导出两次逐字节相同(页面里没有时间戳,同步盘不会整站重传)。进度按篇显示、可中途停止,没导出的、悬空的、撞名的、上次残留的都在结果与索引页里逐条列出 |
| 大纲面板 | Ctrl+Shift+O 打开标题树(当前笔记的 # 标题,按层级缩进、随输入实时更新):点一条就跳过去 —— 编辑视图把光标放到那一行(不改正文、不进撤销历史),阅读视图滚到对应标题并高亮一下,图谱视图先切回编辑视图(同一个操作只给一种结果)。两个视图里都有"当前章节"高亮:编辑时跟光标行(取"最后一个不晚于光标行的标题",所以光标落在正文里仍属于上面那一节),阅读时跟滚动位置(视口顶部那一节);读屏软件会念"当前"。长笔记还能按级别过滤(顶部 H1–H6 开关,默认全亮;按 Vault 记住选择)与逐节折叠(收起一条会藏起它下面所有更深的标题,直到同级或更浅的那条;折叠不持久化,下次打开要的是完整结构)。当前章节若刚好被过滤或折叠藏起来,面板会如实说"当前章节(第 N 行)没有显示在列表里",不会把高亮挪到别的条目上。只认 ATX 标题(# 标题),代码块里的伪标题、frontmatter 里的 # 都不算 |
| 阅读视图 | 渲染后正文 + 每个代码块右上角一个复制按钮与语言标签(鼠标移到代码块上或聚焦时显示;复制走 navigator.clipboard,失败有 execCommand 兜底,两条都失败会显示"复制失败"而不是假装成功);图片点击放大(灯箱)、[[双链]] 与内部链接点击跳转、悬空链接点击即创建 |
| 窗口标题 | 系统窗口标题跟着当前笔记走(笔记名 — Mimenote,名字不带 .md,有未保存修改时加一个圆点):任务栏、Alt+Tab 与截图里都能看出这是哪篇笔记 |
| 图标尺寸有刻度了(这一轮) | <Icon> 的 size 从裸数字改成刻度联合类型(ADR-0033):xs 12 / sm 14 / md 16(默认)/ lg 20 / xl 24 —— 写刻度外的数字编译不过。此前是 78 个调用点、9 种字面尺寸(11/12/13/14/15/16/18/22/26),现已一次收敛;另有两道单测闸:源码里 <Icon size={数字}> 必须 0 处、size 的实参只能是刻度名。CSS 侧留了同一把尺子(--icon-xs…xl)并与组件刻度钉在一起。两类图标不走这把尺子:callout 的 ✎、编辑器行内的 ✓ 这类字形图标按 em 跟着正文字号走 |
| 设计令牌与默认字号(这一轮) | 全部颜色/字体/圆角/间距/字号都经 CSS 变量暴露,两层命名(ADR-0031):--mn-* 是存储格式(主题 JSON 的键),VI 规范名(--bg-base / --text-primary / --brand-primary / --radius-md / --space-4 / --font-reading …)是公开书写面,插件与用户样式片段用它;指向主题可选令牌的别名一律带兜底,因此既有三套主题一个字都不用改。默认字号三档统一 16px(界面 / 编辑器 / 阅读),真值在设置层、设置页三档仍可各自调(11–18 / 12–28 / 12–30);文件树行高随之 26 → 30;新增门禁:四栏不得裁字、树行上下留白 ≥3px。字体族仍是 UI 字体 + 等宽只给代码(阅读衬线/编辑等宽留作可选皮肤:主题里给 --mn-font-reading / --mn-font-editor 即可) |
笔记名不带 .md(这一轮) |
界面上凡是"这是哪一篇"的地方都只写文件名主干:标签页、状态栏最左路径、文件树行、反向链接与出链的解析路径、快速切换(Ctrl+P)、全文搜索命中、窗口标题、回收站列表 —— 判据只有 domain/paths.ts 的 displayName / displayPath 一份(ADR-0030)。想看全名随时有出口:悬停标签页 / 状态栏路径 / 文件树行,提示里都是完整文件名。三处刻意保留真实文件名:① 导出件与静态站点(给 Vault 之外的人看的东西);② 宿主报错原文(文件不存在:xxx.md —— 那是它在描述磁盘上的文件);③ 移动对话框这类"编辑路径"的地方(那里用户编辑的就是路径本身)。附件(图.png)与非 Markdown 文件一律原样,只有 .md / .markdown 会被隐藏;自动化的身份探针(data-note-path / data-tab-path / data-rel-path)也仍然是真实路径——可见文字不是身份 |
| 顶部空间与窗口按钮 | 应用关掉了系统标题栏(decorations: false,见 ADR-0017),并且从 ADR-0038 起没有独立标题栏:内容从窗口最顶边开始(主体 = 窗口 − 状态栏),顶行就是每格自己的标签条(36px)。业务控件各自归位 —— 库名/条目统计在状态栏、导出在文件树工具栏(文件树收起时走命令面板 Ctrl+K)。三个窗口按钮(最小化 / 最大化↔还原 / 关闭)住在主叶标签条右端的固定槽(在标签滚动区之外,标签再多也挤不掉),所以主叶标签条即使没有标签也渲染。整条标签条都是拖动区(data-tauri-drag-region="deep":按住空白处就能拖窗口,双击即最大化/还原;Tauri 的脚本会自动跳过标签与按钮,点击不受影响);最大化状态是从真实窗口读回来的(onResized 订阅),所以双击标签条、Win+↑、拖到屏幕上沿之后,按钮上的图标会自己跟上。浏览器预览(pnpm dev)里没有窗口 API,那三个按钮整组不出现。"我在看什么"(当前笔记/附件路径,不带 .md)在状态栏最左(ADR-0034) |
| 删除保护 | 必须二次确认;文件移入 Vault 内 .mimenote/trash/ 并写台账,不做 unlink。能拿回来(ADR-0018):菜单 / 命令面板搜「回收站」→ 列出删过的条目(名字、原路径、删了多久、大小),一条条「恢复」或「恢复为…」。恢复到原来的位置;那里已经有别的文件时绝不覆盖,改用「恢复为…」换个名字放回去;删过整个目录再恢复时,会把你顺手建了哪些目录如实说出来。台账里有、回收站里已经没有的那些(手工清过回收站)会标成"文件已不在回收站"并禁用恢复 —— 不让用户点两次才明白 |
| 可定制 | 命令注册表 + 快捷键、JSON 主题(CSS 变量,内置深色 / 浅色 / 「纸墨」(暖白纸感的阅读主题,这一轮新增))、Vault 内 .mimenote/snippets/*.css 用户样式片段(可整体卸载) |
| 安全 | 路径越界/符号链接逃逸/Windows 保留名拦截、严格 CSP、能力声明最小化(core:default + dialog:allow-open + dialog:allow-save);写 Vault 之外的单文件只有 export_write_html 一条命令且只接受 .html/.htm(见 ADR-0011);写 Vault 之外的目录树只有整库导出的两条命令,且输出目录必须在 Vault 之外、站内路径逐段校验、扩展名只允许 .html/.css/.json、从不删除任何文件(见 ADR-0019);写 Vault 之内的图片只有 attachment_save 一条命令,且只接受图片扩展名、单张 ≤ 8 MiB、路径一律过 path_guard(见 ADR-0013) |
第三方插件系统(M4,第一批已交付 ADR-0040:manifest 校验 + 权限确认 UI + 命令宿主 + 示例插件;动态加载与 Worker 隔离未做)、Git 集成、同步。
这一轮(ADR-0025 / 0026 / 0027 / 0028)交付了:图谱卡片的六条改动(移除停靠预览、仅标题档、
宽高都能拖 + 全文档、引线加深、悬停 wikilink 高亮连线、失去焦点即松开)、连线的语义分层与环向走线、
视图模块的三区停靠 + 标签栏移到窗口顶部 + 通用右键菜单、文件树排序可配置、最近打开的 Vault、
阅读主题「纸墨」、已完成任务删除线。
之后(ADR-0035)交付了容器切割树:模型 → 迁移与对账 → 落盘 → 渲染器换树(每格一条标签条、
拖标签落点、分隔条、键盘等价物、非当前笔记的只读预览),三区停靠随之成为它的退化特例。
连线这一块明确留到下一轮的:把连线搬进 canvas(60fps 的前提,也是下面那些效果的前置)、
渐细笔触取代箭头、同一张笔记的多条链接画成平行束、真正的避障布线。
M3「体验完善」的范围已全部交付:所见即所得编辑与三视图(移除双栏)、知识图谱卡片画布、
设置页与应用菜单、导出 HTML/PDF、多标签页、拖拽整理文件(跨目录移动 + 全库链接改写 + 移动时改写自身相对链接)。
M3 之后又提前交付了两块原属 M5 的能力(索引跨会话复用:打开 1 万笔记的 Vault 从 ~10 s 降到 0.25–0.34 s;
文件监听:Vault 的外部改动几秒内自动同步到文件树与索引,见 ADR-0016)
以及一批体验项(搜索命中行跳转、图片粘贴/拖入附件、大纲面板、大纲的级别过滤与章节折叠、
表格对齐格式化、代码块复制、窗口标题、把 URL 粘到选中的文字上直接变成链接、
目录重命名与目录移动:整棵子树的路径与全库链接一起改,见 ADR-0015)。
标签的整套编辑能力也已交付(面板里加/删、全库重命名/合并、层级编辑,见 ADR-0006 的三次「后续修订」),
整库导出静态站点见 ADR-0019。
知识图谱改成了"自我中心子图 + canvas 卡片"(默认只看与当前笔记相关的部分、深度可调、节点正面就是完整
Markdown 预览,见 ADR-0021),Obsidian 风格 callout
(> [!note] 标题,在阅读视图/所见即所得/导出件/静态站点/图谱卡片五处渲染一致,见
ADR-0022)。
详见 docs/architecture.md §8 与 docs/milestones.md。
| 依赖 | 版本 | 说明 |
|---|---|---|
| Node.js | ≥ 22(实测 26.2.0) | 前端构建与测试 |
| pnpm | ≥ 10(实测 11.7.0) | 包管理(corepack enable pnpm) |
| Rust | stable(实测 1.96.0) | Rust 宿主 |
| MSVC Build Tools / VS 2022 | Windows 需要 | 链接 Tauri 宿主 |
| WebView2 Runtime | Windows 11 自带 | 渲染前端 |
| Xcode Command Line Tools | macOS 需要(实验支持) | 编译 Rust 宿主与 .app |
| 平台 | 状态 | 说明 |
|---|---|---|
| Windows 10/11(x64) | 正式支持 | 全部门禁(含真实应用 E2E)、发布 NSIS / MSI 安装包 |
| macOS(Apple Silicon / Intel) | 实验支持 | CI 跑跨平台核心(typecheck / 测试 / cargo / 构建)+ UI smoke E2E + .app 可构建;未签名、未公证、不发布安装包 |
| Linux | 暂不支持(明确决策) | WebKitGTK 的渲染与行为差异未验证;等 macOS 链路稳定后再评估(见下文签名/公证同一批发布工程) |
平台差异在代码里的落点(每处都有唯一事实来源,新增平台差异先找这张表):
| 差异 | 处理 |
|---|---|
| 快捷键 | 注册表一律写 Mod(macOS = ⌘、其余 = Ctrl),归一化与展示在 app/commands.ts(isMacPlatform / formatChord / chordFromEvent 同一份) |
| asset 协议 | Tauri 的 asset: 自定义协议 + 逐文件授权(assets.rs,ADR-0007),三平台语义一致;UI E2E 的静态服务器复用同一份 CSP 兜"协议相关"回归 |
| 文件监听 | notify crate 统一抽象(Windows ReadDirectoryChangesW / macOS FSEvents),宿主只消费去抖后的事件流(ADR-0016),网络盘/UNC 的完整性边界见 ADR-0016 代价表 |
| 路径与大小写 | 跨 IPC 只传 Vault 相对 POSIX 路径(path_guard 逐段校验,含 Windows 保留名);NTFS 与 APFS 默认都大小写不敏感,链接判同走归一化键(mn-index),不依赖文件系统大小写语义 |
| 窗口行为 | 无系统装饰(decorations: false,ADR-0017)+ 无独立标题栏(ADR-0038):拖动/双击最大化落在标签条上,最小化/最大化/关闭按钮在主叶标签条右端;最大化状态从真实窗口事件读回,不猜平台 |
| 真实应用 E2E | WebView2 远程调试只在 Windows 存在(e2e/real-app.e2e.test.ts 在非 Windows 显式跳过,不假装通过);macOS 的等价验证 = 跨平台 CI 的构建 + UI smoke |
macOS 正式发布前必须补齐(当前缺口,随 v0.2.0 评估):Apple Developer 签名证书
(tauri.conf.json 的 bundle.macOS.signingIdentity)、公证(notarization,Tauri 支持
APPLE_API_KEY 环境变量直传)、Release workflow 的 macOS 构建腿与 DMG 产物。
未补齐前 macOS 只以源码构建方式实验,不分发安装包。
pnpm install # 安装前端依赖(Cargo 依赖在首次构建时自动拉取)
pnpm tauri:dev # 启动桌面应用(开发模式,热更新)
pnpm dev # 仅启动前端(浏览器里跑 Mock Vault,用于调界面)
# 直接用某个文件夹启动(命令行参数 / 快捷方式 / "打开方式")
target\release\mimenote.exe D:\我的笔记首次 pnpm tauri:dev 需要编译约 400 个 crate(本机约 4~5 分钟),之后为增量编译。
pnpm tauri:build # 出安装包(Windows: NSIS + MSI,需要下载打包工具)
pnpm --filter @mimenote/desktop exec tauri build --no-bundle # 只出可执行文件 → target/release/mimenote.exe
⚠️ 必须用 Tauri CLI 构建(pnpm tauri:build),不要直接cargo build --release。tauri-build通过TAURI_ENV_DEBUG环境变量判断"开发 / 生产",这个变量只有 Tauri CLI 会设置; 直接跑 cargo 时它会退化为cfg!(debug_assertions),于是生产二进制里被写入了 devUrl, 启动后窗口会去连http://127.0.0.1:1420并显示"网络错误"。 同理,target/debug/mimenote.exe也总是要连 dev server —— 调试请用pnpm tauri:dev。
版本号遵循语义化版本 2.0.0,发布以打标签为准:
- 格式
主版本号.次版本号.修订号:不兼容修改升主版本号、向下兼容的新功能升次版本号、向下兼容的修正升修订号; - 当前处于
0.y.z初始开发阶段,公共 API 不承诺稳定,每次发行递增次版本号; - 发布前把新版本号同步写进四处(必须一致,CI 与 Release 会校验):根
package.json、apps/desktop/package.json、根Cargo.toml的workspace.package.version、apps/desktop/src-tauri/tauri.conf.json; - 修改 Cargo workspace 版本后运行
cargo check --workspace,将更新后的Cargo.lock一并提交; - 本地运行
pnpm check:version检查四处一致性,或pnpm check:version v0.2.0同时核对待发布标签;无需安装前端依赖时可直接运行node scripts/check-version.mjs [标签]。错误输出包含文件路径与实际版本,退出码非零; - Release 在构建前执行同一脚本,任一来源与标签不一致即阻断。标签支持
vX.Y.Z和vX.Y.Z-prerelease,沿用现有发布规则,不支持+build构建元数据; - 已发行的版本禁止改动,任何修改都以新版本发行。
git tag v0.2.0 && git push origin v0.2.0 # 触发 Release 工作流:构建 NSIS/MSI 并发布到 GitHub Releasepnpm check # 版本检查 + IPC 契约检查 + 类型检查 + 脚本测试 + 前端测试 + Rust 测试
pnpm check:version # 四处版本一致性检查(可选参数:发布标签)
pnpm check:ipc # IPC 契约一致性检查(命令三方 / 错误码 / 51 组 DTO / 字符串枚举)
pnpm typecheck # tsc --noEmit(TS 严格模式,含 noUncheckedIndexedAccess)
pnpm test # Node 脚本回归测试 + vitest(前端单元 + 集成测试)
pnpm test:scripts # 版本校验脚本的回归测试
pnpm test:rust # cargo test --workspace
pnpm lint:rust # cargo clippy --workspace --all-targets -- -D warnings
pnpm fmt:rust # cargo fmt --all
pnpm test:e2e # 两层端到端测试(见下)| 触发 | 门禁 |
|---|---|
| PR | check(版本 / IPC 契约 / typecheck / vitest / cargo / clippy / fmt)+ UI smoke E2E(test:e2e:ui:smoke,5 条稳定主路径)+ 依赖安全审计(cargo audit + pnpm audit 高危)+ macOS 跨平台核心 |
| main 推送 / 每周定时 | check + 完整 UI E2E(test:e2e:ui)+ 依赖安全审计 + macOS 跨平台核心 |
v* 标签 Release |
check + 依赖安全审计 + 构建 NSIS/MSI 并发布;真实应用 smoke E2E 不在 CI 里跑(GitHub 托管 runner 起不了带窗口的 WebView2,CDP 端口一直不开),它是本地发布前的手动门禁 |
macOS 跨平台核心(check-macos job):版本/契约检查、typecheck、前端测试、cargo test --workspace、前端构建、.app 可编译链接(tauri build --no-bundle,不出安装包)、系统 Edge 的 UI smoke E2E。真实应用 E2E 只在 Windows 存在(WebView2 远程调试),macOS 侧由构建 + UI smoke 等价兜底,平台分工见「平台支持矩阵」。
各层覆盖范围:单测(vitest / cargo test)钉住纯逻辑与解析细节;UI E2E 钉住真实 Chromium 下的布局与交互链路(快、可进 PR);真实应用 E2E 钉住 release 二进制的 WebView2 启动、IPC 接线与真实磁盘写入(本地发布前手动门禁:GitHub 托管 runner 起不了带窗口的 WebView2)。E2E 失败时截图写入 apps/desktop/e2e-artifacts/,CI 会以 artifact 形式上传。smoke 套件只起一个应用实例,直接复用 tauri build 刚产出的二进制,不重复编译。
分两层,各自解决不同的问题:
| 层 | 命令 | 被测对象 | 平台 |
|---|---|---|---|
| 真实应用 | pnpm test:e2e:app |
tauri build 产出的 release 二进制:真实 WebView2、真实 IPC、真实磁盘写入 |
仅 Windows(需要 WebView2 的远程调试) |
| 真实应用 smoke | pnpm test:e2e:app:smoke |
同一二进制,但只跑 5 条主路径(打开 Vault / 打开笔记 / 编辑保存 / 搜索 / 链接导航);本地发布前手动跑(CI runner 起不了 WebView2) | 仅 Windows |
| UI 层 | pnpm test:e2e:ui |
系统 Edge + dist/ 构建产物 + 内存 Mock Vault:真实 Chromium 布局与交互 |
跨平台,秒级,适合 CI |
| UI 层 smoke | pnpm test:e2e:ui:smoke |
同一底座,按名称过滤出 5 条稳定主路径,PR 门禁用 | 跨平台,秒级 |
运行前提:
pnpm --filter @mimenote/desktop build # UI 层需要 dist/
pnpm --filter @mimenote/desktop exec tauri build --no-bundle # 应用层需要 release 二进制覆盖的场景:
- 应用层(
e2e/real-app.e2e.test.ts):命令行参数自动打开 Vault、文件树渲染、未选中任何笔记时布局即铺满窗口、打开笔记 → 编辑器载入 → 输入 → 防抖后内容真的落到磁盘、文件被外部修改 → 冲突横幅 → 磁盘未被覆盖 → 重新加载恢复、删除到回收站(二次确认 + 真实.mimenote/trash)、后台索引完成后反向链接可见 → 点击跳转到来源笔记的那一行 → 悬空链接一键创建真实文件、重命名:F2改名后磁盘上的文件名与全库链接都被改写(跨目录相对路径、别名与锚点保留、[[甲]]不误伤[[甲虫]]、代码块与行内代码不动、CRLF 保真),且索引立刻同步、标签面板(真实 IPC):frontmatter 与行内标签、属性表、点标签列出笔记、切换笔记后跟随刷新、面板里加/删标签(真实磁盘上 frontmatter 真的多一项/少一项,CRLF 与行尾注释逐字不变,面板与全库标签跟着更新)、全文搜索(真实 FTS5)命中 → 回车打开、全文搜索命中 → 回车后光标与视口真的落在命中行(真实布局:该行在视口中部、滚动条动过、焦点在编辑器里)、本地图片真的被解码(naturalWidth > 0),而 Vault 越界引用与符号链接引用都留在占位态、所见即所得编辑(标记被装饰掉但光标进入即露原文)、知识图谱(真实graph_ego:默认就是关系图、圆心 = 当前打开的笔记、1 跳;点圆心那张卡片就地预览真实正文;切到「整个 Vault」后文件夹成组、悬空链接点名、入链虚线/出链实线)、设置页与应用菜单(真实菜单项与设置项落盘)、[[链接补全(真实编辑器里敲[[→ 过滤 → 回车,磁盘上只多出一个]];Esc取消后文件不变、焦点离开编辑器弹层自动收起)、编辑器里的图片点开放大(所见即所得的img.mn-md-image也能开灯箱,且灯箱里的大图真的被解码)、标签层级编辑(真实tag_move:⇥→ 输入父标签 → 预览"这会改 2 篇"不落盘 → 确认后 CRLF/行尾注释/代码块逐字保真、子标签一起挪 → 概览换成新键且编辑器内存对齐;留空 = 提回顶层;非法移动被宿主拒绝、理由是原话且磁盘一个字节不变)、大文档阅读视图真的开了 Worker(临时 Vault 里写一篇 >1 MiB 的笔记 → 切阅读视图 →data-mn-render="worker"且 CDP 看得到 worker 实例;小笔记仍走同步路径) - UI 层(
e2e/ui.e2e.test.ts):容器切割树(ADR-0035:每个格子 = 标签条 + 内容,标签条住在格子里而不是标题栏,Alt+3把文件树搬到主区下方并落盘、Alt+1搬回且新格默认取模块的家尺寸而不是 0.5)、顶部无独立标题栏(主叶标签条贴窗口顶边、36px 且是拖动区、窗口按钮槽在滚动区右侧;"我在看什么"在状态栏、阅读/图谱视图里仍然在)、笔记名在各处都不带.md(状态栏 / 格子里的标签页 / 文件树,而title与data-*仍是真实路径)、模块右键菜单(文件树行 → 菜单 →Esc;标签页 → 关闭其他)、文件树排序(换方向真的重排并落盘)、图谱的仅标题档 / 全文档 / 卡片右键菜单、门闸 → 打开 Vault → 树、缩小窗口后布局跟随、打开笔记前后布局不变、阅读视图渲染表格/代码块、wikilink 点击跳转与悬空标记、链接面板反向链接跳转、过滤保留祖先、主题即时切换、三种视图模式(编辑/阅读/图谱,主叶只有一个 pane)、文件树键盘导航、分隔条拖拽、重命名对话框(F2→ 改名 → 预览里的链接跟着改 → 再改回来)、标签面板(Ctrl+Shift+T)、命令面板(编辑器聚焦时Ctrl+K也能打开、过滤后回车执行)、快速切换(Ctrl+P只列笔记、回车打开、Esc关闭)、全文搜索(Ctrl+Shift+F→ 输入 → 回车打开)、知识图谱(默认是「关系图」且 1 跳、深度 + 真的重拉子图、两个视图互切、点圆心卡片预览、点空白处关掉预览、收起文件夹后卡片变少、入链虚线/出链实线、拖卡片本体跟着光标走并把撞上的那张推开(松手后它处于"被按住"状态)、卡片正面画的是正文(像素级证据:正文越长卡片画得越高)、方向键换选中、跳数刷新后仍生效)、多标签页(打开多篇成标签、点标签切换、×关闭,且标签住在主叶的标签条里、布局高度契约不变)、全局快捷键赢过编辑器自己的绑定(焦点在编辑器里按Ctrl+G是"打开图谱"而不是"查找下一个"、Ctrl+B切文件树格子同样生效)、拖拽整理(把项目/设计.md拖到另一个文件夹:悬停时目标文件夹标成可放置 → 树里出现在新目录、原目录不再有它 → 预览里指向它的链接已改写成相对新位置的路径且不悬空 → 布局契约不变 → 再拖回来)、文件夹改名(选中项目按F2→ 改名框预填目录名且不显示扩展名 → 整棵子树换前缀(深层文件也在)→ 子树里的链接仍然解析得到 → 拖到空白区域被标成无效落点并给出原因 → 再改回原名)、粘贴图片(造一次真实的ClipboardEvent粘一张 PNG:附件目录里出现新文件、编辑器里出现图片 widget 且它指向的正是树里那一条 —— Mock 模式没有 asset 协议,所以断言的是占位形态的图片元素与其title)、标签层级编辑(⇥→ 输父标签 → 预览 → 确认后概览与正文行内都换成新键,再「移到顶层」提回来,把状态还给后面的用例)、整库导出静态站点(浏览器预览模式没有系统目录选择框 → 那条选项可用、点了之后如实说明"这里写不了文件"并自己收起,不假装写出去了)、大文档预览:小笔记走同步路径(data-mn-render="sync",门槛生效)+ 生产 CSP 下打包出来的 worker 产物能被同源加载并回包(UI 层 E2E 的静态服务器现在带上与tauri.conf.json同一份 CSP —— "在 Edge 里好好的、装进应用被 CSP 拦掉"这类问题因此能在秒级这一层逮到;大文档那一侧由应用层验,见上)、窄叶防护闭环(注入旧版切出的 31px 窄叶 → 降级只留标签条;「重置布局」拉回默认分栏;Alt+3后把分隔条拖出窗口底边,树格仍 ≥ 120px 像素下限)
Tauri 官方文档的 E2E 路径是 tauri-driver + WebdriverIO:tauri-driver 是一个 WebDriver 服务端,
而 Playwright 不走 WebDriver 协议,两者无法对接;走那条路还要一个与 WebView2 版本精确匹配的
msedgedriver.exe。WebView2 支持 --remote-debugging-port(通过环境变量
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS 传入),Playwright 的 chromium.connectOverCDP()
可以直接接管它 —— 于是既保留了 Playwright,又能跑真实二进制。
实现见 e2e/support/harness.ts,其中记了两个坑:
- 端口被别的程序(浏览器、其它调试实例)占用时,WebView2 会退到 IPv6
[::1], 而 IPv4 上仍是别人在监听 —— 探到的 404 是别人的。必须先申请一个空闲端口。 - 不通过
Emulation.setDeviceMetricsOverride测窗口缩放:WebView2 的 CDP 不暴露该域, 缩放场景放在 UI 层(真实 Chromium)验证。
自动化已经覆盖了"打开 Vault → 编辑 → 保存 → 冲突保护"与布局链路的行为;
剩下需要人眼判断的是观感(配色、字号、间距、动画是否舒服)。用仓库自带的示例 Vault:
examples/demo-vault/。
10k 笔记扫描基准(会真实创建 1 万个文件,默认忽略):
cargo test -p mn-core --release -- --ignored --nocapture bench_scan_10k_notes目录搬迁基准(1000 篇的子树,默认忽略;三档分别看"有引用 / 引用少 / 分段耗时"):
cargo test -p mn-index --release -- --ignored --nocapture bench_move_directorymimenote/
├─ crates/mn-core/ 文件层(纯 Rust 库,不依赖 tauri,可独立测试)
│ ├─ src/path_guard.rs 路径校验与越界防护(VaultRoot)
│ ├─ src/atomic.rs 原子写 / 限长读取 / mtime 工具
│ ├─ src/scanner.rs 迭代式 Vault 扫描(忽略规则、深度与条目上限)
│ ├─ src/trash.rs 回收站(移动 + jsonl 台账)
│ ├─ src/text_stats.rs CJK 感知统计
│ ├─ src/links.rs 链接抽取(含字符 span,重命名精确改写用)
│ ├─ src/frontmatter.rs 极简 YAML 子集解析 + 最小 diff 改写
│ ├─ src/tags.rs 标签抽取与归一化(`normalize_tag` 是唯一判同键)
│ ├─ src/site.rs 静态站点的页面命名 / URL 编码 / 相对路径算术(纯函数)
│ └─ tests/vault_ops.rs 端到端文件层测试
├─ crates/mn-index/ 索引层(纯 Rust 库,索引是可重建的缓存)
│ ├─ src/lib.rs 链接索引 + 标签索引(挂在同一个 upsert/remove 上)
│ ├─ src/rename.rs 改名 / 跨目录移动:候选集计划 → 精确改写 → 索引同步
│ ├─ src/dir_move.rs 目录改名/移动:整棵子树 + 前缀映射改写(ADR-0015)
│ ├─ src/graph.rs 图谱组装(节点/边、出入度、悬空目标名、截断)与自我中心子图(双向 BFS,ADR-0021)
│ ├─ src/site.rs 整库导出的计划(页面表 / 链接 href / 反链 / 统计)
│ └─ src/search.rs SQLite FTS5 全文索引(`.mimenote/cache/search.db`)
├─ apps/desktop/
│ ├─ src-tauri/ Tauri 宿主(状态 + IPC + 错误码,不含业务逻辑)
│ │ ├─ src/lib.rs 应用装配与命令注册(按领域分块枚举,M4 capability 的映射基础)
│ │ ├─ src/state.rs 会话状态(Vault 缓存、写锁、索引与搜索句柄)
│ │ ├─ src/commands/ IPC 命令与 DTO,按领域拆分:vault / notes / tags / trash /
│ │ │ links / graph / search / system(mod.rs 内有领域 → 命令对照表)
│ │ ├─ src/assets.rs 本地图片的逐文件读取授权(ADR-0007)
│ │ ├─ src/attachments.rs 粘贴/拖入图片的写入(唯一"写图片"的命令,ADR-0013)
│ │ ├─ src/export.rs 自包含 HTML 的写出(唯一允许写 Vault 外路径,ADR-0011)
│ │ ├─ src/site_export.rs 整库导出静态站点:计划/落盘/复制图片(ADR-0019)
│ │ ├─ src/error.rs mn-core 错误 → 稳定错误码
│ │ ├─ capabilities/default.json 最小能力声明
│ │ └─ tauri.conf.json CSP / 窗口 / 打包配置(关了 WebView 缩放热键)
│ ├─ src/
│ │ ├─ app/ bootstrap、命令注册表、快捷键、高层动作
│ │ ├─ domain/ 纯函数:树、虚拟列表、拖拽落点、EOL、Markdown、统计、图片解析、静态站点路径
│ │ ├─ ipc/ IPC 契约类型 + 可替换适配器(tauri / mock)
│ │ ├─ state/ zustand store(vault / note / ui / links / tags / tabs / graph / toast / confirm)
│ │ ├─ features/ vault(门闸、工具栏、虚拟化文件树、重命名对话框、拖拽整理 + 移动到对话框)
│ │ │ editor(CM6 装配 + 所见即所得装饰)、preview(阅读视图)
│ │ │ tabs(多标签页)、links(反向链接面板)、tags(标签与属性面板)
│ │ │ palette(命令面板 / 快速切换 / 全文搜索)
│ │ │ graph(知识图谱:自我中心子图 + canvas 卡片 + 全库视图,ADR-0021;
│ │ │ 连线走线 edge-routing,ADR-0028)、settings(设置页)
│ │ │ export(导出 HTML / 打印 PDF / 整库静态站点)、lightbox(图片灯箱)、status(状态栏)
│ │ ├─ theme/ 主题令牌、JSON 主题(深色 / 浅色 / 纸墨)、CSS 片段装载
│ │ ├─ features/dock/ 旧停靠布局的**读取面**(类型/缺省/校验;迁移与回滚用,ADR-0026→0035)
│ │ ├─ features/layout/ 容器切割树(tree-layout 纯函数模型 + layout-sync 迁移对账 + TreeHost 渲染器
│ │ │ + LeafTabs 格子标签条 + drop-target 落点几何 + tree-keys 键盘 + split-size 家尺寸,ADR-0035)
│ │ ├─ components/ Icon、Splitter、ContextMenu(通用右键菜单)、Toasts、确认框、错误边界
│ │ └─ styles/app.css 布局与组件样式(颜色全部走 CSS 变量)
│ ├─ tests/ vitest:领域层单测 + store 集成测试
│ └─ scripts/make-icon.mjs 纯 Node 生成应用图标源图(无图像依赖)
├─ examples/demo-vault/ 用于手工验收的示例 Vault(含 CSS 片段示例)
└─ docs/ 架构、里程碑、ADR、依赖清单
| 快捷键 | 命令 |
|---|---|
Ctrl+K |
命令面板(当前所有命令,可模糊搜索后执行) |
Ctrl+P |
快速切换笔记(模糊搜索当前 Vault 的笔记) |
Ctrl+Shift+F |
全文搜索(搜索笔记正文,支持中文与多词;↑↓ 选择、Enter 打开并跳到命中行) |
Ctrl+O |
打开 Vault |
Ctrl+N |
新建笔记(在选中目录 / 选中文件的父目录) |
Ctrl+S |
保存 |
Ctrl+Shift+S |
导出为自包含 HTML(图片内嵌为 data: URL,产物可直接在浏览器打开) |
Ctrl+Shift+P |
导出为 PDF(走系统打印对话框,选"另存为 PDF") |
Ctrl+Alt+S |
把整个 Vault 导出为静态站点(每篇一个 HTML + 可点的双链;输出目录要在 Vault 之外) |
Ctrl+W |
关闭当前标签(有未保存修改会二次确认) |
Ctrl+Alt+← / Ctrl+Alt+→ |
上一个 / 下一个标签 |
Ctrl+1 … Ctrl+9 |
切到第 N 个标签(标签不够时该命令在面板里置灰并写明原因) |
Ctrl+Alt+L |
从磁盘重新加载(丢弃内存改动,会二次确认) |
Ctrl+Alt+F |
格式化光标所在的 Markdown 表格(对齐列宽与竖线;只改空白、不动内容) |
Delete |
删除到回收站(树上有焦点时,会二次确认) |
F2 |
重命名选中笔记或文件夹(选题是文件夹时改名的是目录,连同整棵子树;都会同时改写全库指向它的链接) |
F6 |
移动到文件夹…(选中笔记或文件夹后打开「移动到…」对话框;拖拽的键盘等价物,会同时改写指向它的链接) |
Ctrl+, |
打开设置 |
Ctrl+E |
循环切换视图:编辑 / 阅读 / 知识图谱 |
Ctrl+G |
直接切到知识图谱 |
Ctrl+= / Ctrl+- / Ctrl+0 |
图谱:放大 / 缩小 / 适配窗口(图谱视图内可直接按 + / - / 0) |
Esc |
图谱:关闭卡片预览(无预览时回到空态) |
Alt+1 / Alt+2 / Alt+3 |
把焦点所在的标签(模块或笔记)搬到主区左侧 / 右侧 / 下方(独占新格;模块给它的家尺寸) |
Alt+← / Alt+→ |
在同一格的标签条里前移 / 后移当前标签 |
| 右键 | 任意模块的右键菜单:文件树行 / 标签页 / 模块标签 / 图谱卡片(↑↓ 走位、Enter 执行、Esc 关闭) |
Ctrl+B |
显示 / 隐藏侧栏 |
Ctrl+Shift+L |
显示 / 隐藏链接面板(反向链接 / 出链) |
Ctrl+Shift+T |
显示 / 隐藏标签面板(标签与 Frontmatter 属性) |
Ctrl+Shift+O |
显示 / 隐藏大纲面板(当前笔记的标题树) |
Ctrl+Shift+E |
聚焦文件过滤框(Ctrl+Shift+F 让给了全文搜索) |
Ctrl+Alt+E / Ctrl+Alt+W |
展开 / 折叠全部目录 |
Ctrl+Alt+R |
重新扫描 Vault |
Ctrl+Alt+T |
切换主题 |
Ctrl+Alt+A |
关于(版本 + 当前文档磁盘统计) |
Ctrl+F |
编辑器内搜索(CodeMirror 面板;Enter / F3 跳下一个,Ctrl+G 已被"打开图谱"占用) |
Ctrl+Space |
唤出链接补全(等价于敲下 [[;在代码块与 frontmatter 里不弹) |
PageUp / PageDown |
图片灯箱:上一张 / 下一张(方向键留给"平移大图") |
macOS 上 Ctrl 自动换成 Cmd(Mod)。命令表在 src/app/builtin-commands.ts,是未来命令面板与插件 API 的数据源。
两点按键相关的设计取舍:
- 界面缩放不走浏览器缩放:窗口关掉了 WebView 的缩放热键(
zoomHotkeysEnabled: false), 否则Ctrl+=/Ctrl+-/Ctrl+0会被 WebView 抢去缩放整个界面 —— 那正是图谱缩放的三个键位, 而且界面上没有任何"按错了怎么恢复"的提示。字号请用设置页的界面字号 / 编辑器字号(会持久化)。 - 注册过的组合键优先于编辑器:全局快捷键装在捕捉阶段,因此
Ctrl+G在编辑器里也是"打开图谱" —— 编辑器侧的键位处理(CodeMirror 的Ctrl+K「删到行尾」、搜索面板范围内的Ctrl+G「查找下一个」) 会preventDefault(),冒泡阶段的全局监听收到时已经晚了(实测症状:焦点在编辑器里按Ctrl+G打不开图谱)。Mod+K/Mod+P/Mod+Shift+F由面板自己的监听独占;Ctrl+F(编辑器内搜索)不是注册命令, 仍归 CodeMirror,搜索面板里用Enter或F3跳下一个。
| 命令 | 结果 |
|---|---|
cargo test -p mn-core |
186 个单元测试 + 16 个集成测试通过(另有 1 个性能基准默认忽略) |
cargo test -p mn-index |
169 个单元测试 + 10 个集成测试(tests/persisted_index.rs:索引跨会话复用的不变式)通过(另有 5 个基准默认忽略)(链接解析/歧义消解/增量更新/构建取消、重命名与跨目录移动的链接精确改写、移动时改写自身相对链接、目录改名/目录移动的整棵子树改写、标签索引与组合过滤、整库导出的计划(链接 href 相对本页、悬空为 null、与反链同口径、两次调用逐字节相同)、FTS5 搜索与恶意查询、内容哈希二级令牌(落盘与同字节检出)、图谱组装与截断、落盘索引的复用与写穿透、路径后缀表) |
cargo test -p mimenote |
177 个宿主单元测试通过(IPC 错误映射、路径解析、建笔记、重命名、移动、目录改名/移动与整棵子树的条目缓存替换、标签增删(含冲突令牌/幂等/BOM 与 CRLF 保真/写失败降级)、标签重命名/合并(预演不落盘、层级、合并去重、外部改动跳过、只读文件写失败继续、重试幂等)、标签层级编辑、组合过滤、整库导出(落盘契约、输出目录在 Vault 内被拒、非空且无标记被拒、不许写到输出目录之外、原子写无残留、createdDirs、图片白名单与跳过清单、JSON 键名契约)、标签、搜索、图片授权、图片附件写入、图谱、片段、写锁、启动参数、文件变更监听)(另有 3 个忽略) |
pnpm test |
103 个测试文件 / 1763 个测试通过(领域层纯函数 + store 集成 + 组件行为) |
pnpm test:e2e:ui |
65 个用例通过(系统 Edge,真实 Chromium,约 30 秒;静态服务器带上与 tauri.conf.json 同一份 CSP) |
pnpm test:e2e:ui:smoke |
5 个用例通过(同一底座的稳定子集,约 5 秒;PR 门禁) |
pnpm test:e2e:app |
34 个用例通过(真实 release 二进制 + 真实磁盘;其中「外部改动自动同步」2 条、「标签面板增删标签」1 条、「标签重命名/合并」1 条、「标签层级编辑」3 条、「大文档预览的 Worker」2 条、「回收站恢复」2 条、「窗口拖动区与窗口按钮」1 条) |
pnpm test:e2e:app:smoke |
5 个用例通过(真实 release 二进制,约 5 秒;本地发布前手动门禁 —— GitHub 托管 runner 起不了带窗口的 WebView2) |
pnpm check |
以上三类一次跑完,全绿 |
pnpm typecheck |
无错误(TypeScript 严格模式 + noUncheckedIndexedAccess) |
cargo clippy --workspace --all-targets -- -D warnings |
无告警 |
cargo fmt --all --check |
无差异 |
cargo run -p mn-core --release --example scan_bench |
1 万文件 + 100 目录:143 ms(预算 800 ms) |
cargo test -p mn-index --release -- --ignored bench_move_directory |
1000 篇目录搬迁:50 篇被引用 2.0 s / 700 个文件被改写 8.3–10.4 s(搬树本身 2 ms,瓶颈是逐文件 fsync) |
cargo test -p mn-index --release -- --ignored --nocapture bench_optimize_after_incremental |
1 万篇 / 30 万行的库跑 20 轮增量后:段数 11–18 → 1,搜索查询平均 76.9 ms → 45.8 ms(1.68×;另一批次 2.2×),合并自身 876–937 ms;文件大小不变(ADR-0008「后续修订」第 4 节) |
cargo test -p mn-index --release -- --ignored --nocapture bench_resolve_dangling_links |
4000 篇库 + 2000 条互不相同的悬空链接:解析 0.0471 → 0.0005 ms/条(96.7×);graph_data(4000 节点 / 6000 边)69.8 ms,其中悬空解析只占 1.4 ms(架构 §8 第 16 条) |
cargo test -p mimenote --release -- --ignored --nocapture bench_watch_latency |
1 万笔记 Vault 的一次外部改动:落盘 → 事件 502–510 ms(静默期 500ms)/ 重扫 107 ms / 复用构建 105 ms(复用 9999 篇)→ 宿主侧端到端 ≈ 714 ms(ADR-0016) |
cargo test -p mimenote --release -- --ignored --nocapture bench_watcher_overhead |
监听常驻开销(配合外部脚本采样):4 个句柄 + 2 个线程 + 约 0.5 MB 工作集,停掉后回到基线(ADR-0016) |
pnpm --filter @mimenote/desktop exec tauri build --no-bundle # → target/release/mimenote.exe(4.5MB)启动后日志(%LOCALAPPDATA%\app.mimenote.desktop\logs\mimenote.log):
INFO mimenote_lib] Mimenote 0.1.0 启动(mn-core 0.1.0,离线模式:无遥测、无出站请求)
INFO mimenote_lib] 日志文件:C:\Users\<你>\AppData\Local\app.mimenote.desktop\logs\mimenote.log
INFO mimenote_lib::commands] IPC 握手成功:app 0.1.0 / mn-core 0.1.0 / tauri 2.11.5
出现 "IPC 握手成功" 这一行意味着三件事同时成立:WebView 渲染成功、前端 JS 执行成功、 IPC 通道可用(CSP 与能力声明都没有拦住)。这是排查"白屏"类问题最快的信号; 门闸页底部也会显示同一份运行环境信息。
前端测试覆盖的重点:
tests/note-store.test.ts:自动保存、切换文档前落盘、保存期间继续输入、外部修改冲突(不覆盖 / 重新加载 / 强制覆盖)、CRLF 保真tests/vault-watch.test.ts:外部改动自动同步的前端一侧(ADR-0016)—— 收到事件就重扫条目表(新文件进树)、不弹"已重扫"这类背景噪音提示、当前笔记无脏自动重载(带提示)、有脏进冲突态且一个字节都不写、无关文件的改动不动编辑器、自己保存过的笔记不被当成外部改动、磁盘上被删掉时给明确提示、浏览器预览模式下订阅静默降级tests/vault-flow.test.ts:打开 Vault、树结构、新建(重名避让)、删除(取消 / 确认 / 目录连带后代)、重扫tests/rename.test.tsx:改名 + 全库链接改写、条目表原地更新、改名前的落盘顺序、updateLinks=false、重名/非法名拒绝、文件夹改名会打开改名框(走dir_rename)、对话框交互与 Esc 取消、自链接重读tests/drag-drop.test.ts:拖拽落点的纯函数判定(文件夹 / 另一篇笔记的目录 / 空白区域 = Vault 根;已经在该目录 → 不可放置并给原因;笔记与文件夹可拖、附件不行)、文件夹拖进自己/后代 → 无效并说明原因(含项目2这类同前缀兄弟目录的段感知判定)、载荷过手(kind前缀)与路径形状校验(越界与绝对路径一律拒收)tests/dir-move.test.tsx:目录改名 / 目录移动的完整链路 —— 整棵子树真的搬到新前缀(深层文件也在)、全库指向子树里每一篇的链接被改写成新位置、条目表整棵子树换路径(一篇都不少)、搬到不存在的目录会自动创建、搬进自己的后代被拒绝且一个字节都不动、目标同名目录被拒(绝不合并)、按条目类型分派(笔记走单篇链路 / 文件夹走目录链路)、拖拽(可拖、无效落点有原因、拖到空白 = 移到根)、F2 / F6 对话框、store 收敛(正在编辑的这一篇原地换路径并保留撤销历史 / 标签页整棵子树换前缀且顺序不乱 / 子树之外的被改写文件自动重读 / 搬迁前先落盘 / 展开状态跟着换前缀)tests/move-note.test.tsx:跨目录移动的完整链路 —— 三种落点都真的搬了文件、同名冲突给可读提示且一个字节都不改、悬停反馈(valid/invalid)、键盘路径(命令 + 对话框、输入不存在的目录会创建、Esc 取消、附件被拒)、移动后的 store 收敛(当前文档原地换路径/自链接重读/其他被改写的文件重读/后台标签被剪掉/移动前先落盘)tests/editor-live-preview.test.tsx:所见即所得的装饰规则(光标在外隐藏标记 / 进入露原文、标题级别、引用、列表、任务复选框写回、围栏与分隔线)、wikilink 与图片 widget(含![[图.png]]与![[笔记.md]]的分流)、只对视口出装饰、原子区间与隐藏装饰一一对应tests/graph.test.tsx:图谱布局纯函数(文件夹树与嵌套容器、装箱不重叠、网格索引与视口裁剪、入链虚线/出链实线分类、悬空虚影)+组件行为(卡片、单击预览、双击打开、文件夹收起展开);含 1 万节点的规模用例tests/graph-drag.test.tsx:拖拽手感(6 条)—— 拖动时卡片的世界矩形真的跟着光标走(不是弹回环上的原位)、命中测试用当前矩形(按"画着的那张"点下去抓住的必须正是它)、拖到另一张上时对方被推开且拖动过程中零重叠、往邻居反方向拖时邻居也被弹簧拉着动(这条路径上硬碰撞完全没参与 ⇒ 证明交互真的给力场加热了)、拖动中的那张画在最上层、"松开卡片"之后位置真的被张力拉走tests/task-list.test.ts:任务列表(19 条)—— 判据([ ]/[x]/[X]、*/+/1.三种符号、空任务项、嵌套、- **[x]** 手写与行中间的[x]不算)、渲染(复选框长在li里、标记不再是可见文字、内部属性不进 HTML)、净化边界(input单独放开但只有禁用的 checkbox 活下来)tests/editor-list-mark.test.tsx:所见即所得的列表标记(25 条)—— 无序-/*/+同一个符号、嵌套•/◦/▪、有序序号从列表的起始值算起(1. 1. 1.→1. 2. 3.、3.开头 →3. 4.、有序嵌在无序里内层重新起算)、序号栏宽按该列表最宽的序号、光标进入该行露原文、任务项行为回归、原子区间与eq()tests/settings.test.tsx:应用菜单(分类、快捷键、键盘导航、执行命令、Esc/外部点击关闭)与设置页(分区、字号落到 CSS 变量并持久化、自动保存延迟真的改变写盘时机、索引状态与重扫)tests/preview-images.test.tsx:两段式渲染与授权链路、加载失败回退占位、越界不发请求、灯箱(打开/缩放/关闭/焦点归还)tests/tags.test.tsx:frontmatter 与行内标签抽取(代码块/标题行/#123/a#b不抽)、属性保序与类型标注、全库标签计数、面板渲染与"点标签 → 点笔记 → 打开"tests/tag-edit.test.tsx:面板里加/删标签的完整链路 —— 提交后磁盘上的 frontmatter 真的变了(其余内容一字不动)、面板与全库概览跟着更新、内存文本与令牌对齐(否则下次自动保存会覆盖标签)、一次输入多个(逗号分隔/#前缀/含空格的标签)、加已存在的标签不写盘、删到一个不剩保留tags:字段、没有 frontmatter 时补区块且不吞正文首行、行内标签只读(带「正文」角标 + 点×只给提示且一个字节都不写)、脏状态下先落盘再改标签、冲突进既有横幅那套语义、写失败(只读 Vault)时界面状态不变tests/palette.test.tsx:Ctrl+K/Ctrl+P开关、子序列过滤与高亮、键盘选择与执行、置灰项、结果上限、焦点归还、Esc 不冒泡tests/search.test.tsx:Mock 搜索(大小写不敏感、命中行号、片段裁剪、排序);面板的防抖只发一次请求、竞态丢弃、结果高亮、回车打开、错误态tests/line-jump.test.tsx:命中行跳转(行号夹取与退化:0 / 负数 / 超末行 / 空文档;派发的事务里选区落在行首、滚动策略是"视口正中"、不进撤销历史;搜索面板回车真的把光标落到命中行、同一篇的多条命中各跳各的行、阅读视图下先切回编辑视图;跳转不改文档、不动未保存状态、不触发写盘)tests/keymap.test.tsx:全局快捷键的分发阶段(编辑器先preventDefault的事件仍能被注册过的组合键拿到、未注册的键原样放行、输入框里与模态层里都不抢键、面板的三个键位由面板独占不重复执行、when为 false 的命令不被绕过)—— 对应"Ctrl+G 在编辑器里被 CodeMirror 的查找下一个吞掉"这个真实缺陷tests/outline.test.ts/tests/outline-panel.test.tsx/tests/outline-view.test.ts:大纲解析(围栏代码块与 frontmatter 里的伪标题不算、CRLF、行内标记剥除、缺中间层级时的归一化缩进)、过滤与折叠的纯函数(默认全亮、只渲染选中级别但下标不重编号、缩进按完整列表、三种层级的收起归属、收起项仍留三角、看不见的折叠项不生效、越界折叠下标被忽略)与面板行为(编辑视图点标题 → 光标落行且正文不变;阅读视图 → 滚到对应标题并高亮;图谱视图 → 先切回编辑视图;面板开关命令与持久化;按 Vault 根记住级别过滤且不碰别的偏好;当前章节被过滤/折叠藏起来时只交代行号、高亮不漂移;折叠重挂即恢复完整树;空态与一键还原)tests/paste-url-link.test.tsx:把 URL 粘到选中的文字上变成 Markdown 链接(这条能力由@codemirror/lang-markdown的pasteURLAsLink提供,测试是钉住它而不是我们的实现):选中文字 + 地址 → 包成[文字](url)且那几个字仍然选着、www.自动补https://、mailto:、没有选区时地址原样插入、粘普通文本按默认粘贴处理、选区在行内代码里或跨越语法节点时不加链接tests/tag-rename.test.tsx:标签重命名 / 合并的对话框与链路(18 条)—— 入口与Esc/回车、预览不落盘、确认后磁盘与面板都变、层级只换前缀段、合并去重、代码块与行内代码里的#旧不动、幂等重试、空名拒绝、跳过清单如实汇报 + 警示提示、报错降级、纯文案函数tests/tag-move.test.tsx:标签的层级编辑(17 条)—— 入口只在全库概览那一行(⇥)、输入框初值 = 它现在挂在哪儿、父标签候选取不到它自己;预览不落盘 → 确认后 frontmatter 与正文一起换键、留空 = 提回顶层(#甲/子→#子)、「移到顶层」一键清空、回车走主按钮;四条非法移动各有一条用例(挂到自己 / 挂到自己的后代 / 目标已被占用并指向「重命名」/ 已经在那里了),并断言磁盘一个字不动、理由是原话而不是"路径不合法";连同子标签开关两侧;面板上展开着的那一节跟着挪(甲/子→父/甲/子,与它无关的标签不受影响);Mock 与 Rust 同口径(只换祖先不动名字、父标签首尾/容忍)tests/tag-filter.test.ts/tests/tag-filter-tree.test.tsx:按标签过滤文件树(34 条)—— 纯函数(子标签展开/多选语义/祖先补齐/计数/条件指纹)与集成(真实控件 + 真实文件树:无选中即不过滤、只留命中与祖先、计数 M/N、Esc回全量、失败不收窄、标签消失点名、与文本过滤叠加、过滤期间空白落点被拒)tests/site-paths.test.ts:静态站点的路径与 URL 算术(14 条)—— 页面命名(.md/.markdown/大小写不敏感、非笔记与隐藏文件不给页面)、同名撞路径时按顺序加-2并如实报改名清单、URL 编码(CJK/空格/#/%编码、unreserved 一个不动、十六进制大写)、相对链接(同目录 / 跨目录../层数 / 同页退化成#片段/ 没锚点时给文件名而不是#)、图片固定在assets/<原路径>下tests/site-export.test.tsx:整库导出静态站点(17 条)—— 页面是完整 HTML 且 frontmatter 不进正文、标题来自计划(与图谱卡片同口径)、双链变相对链接而悬空的变不可点文字、[[甲#小节]]带片段且标题真的生成id、图片引assets/而不内嵌data:、整站一份样式表且每页按深度引用、索引页列出全部笔记与需要知道的四类问题、标记文件最后写且含全部文件名、按批读(进度与让出主线程的前提)、同一 Vault 两次导出逐字节相同、取消时不写索引页与标记(目录里没有东西自称完成)、写盘失败不谎报、输出目录在 Vault 里被拒且一个文件都不写、索引未就绪时在选目录之前就拦下、没开 Vault 说清楚、空 Vault 不导空站点、对话框第三个选项在索引构建期间置灰并说明原因tests/demo-vault.test.ts:示例 Vault 的完整性守卫(10 条)—— frontmatter 闭合、wikilink 都能解析、图片都在 Vault 内且存在、README 与实际文件不漂移,以及"文档承诺的内容还在"(多级标题、尺寸语法、本轮新加的演示段落);匹配前先剥掉代码块与行内代码,否则会逼着文档别写例子tests/live-preview-table.test.tsx:所见即所得里的表格渲染(48 条)—— 纯函数(对齐/缺列补齐/多列截断/空单元格/转义竖线/CJK/单元格图片的授权与占位/HTML 注入/引用块与列表里的表/规模护栏)、装饰判据(渲染态 vs 整块露原文、块首块尾边界、视口外不算)、widget(eq重建判据、图片事件让给灯箱)、与「格式化表格」共存(格式化后渲染结果逐字不变、一次撤销回原文)tests/preview-render-hydration.test.tsx/tests/preview-link-annotation.test.tsx/tests/preview-worker.test.ts/tests/preview-render-cost.test.ts:大文档预览的四块改动(4 + 2 + 8 + 3 条)—— 就地补图(授权回来后只替换占位节点、其余 DOM 节点的对象身份不变(这是"没有整篇重建"的硬证据)、批上限与去重、失败转终态占位且不再请求、补出来的<img>与整篇渲染的逐字节相同);链接补类名线性化(归一化次数量级而不是计时 —— 计时断言在 CI 上会抖;已解析/悬空/歧义三种类名与 title 逐条一致、重复目标取第一次);Worker 通道(旧requestId丢弃、换笔记后旧结果一个字节都不写进 DOM、构造失败 /onerror回退同步且只 warn 一次、小于门槛根本不建 Worker、环境里没有Worker时走同步、data-mn-render标出走的是哪条路);代价分布实测(解析 < 净化、就地补图 ×10 < 整篇重渲染、线性 < 二次方 —— 都是比较式断言,机器忙时两侧一起变慢,结论不会被负载翻掉)tests/trash-dialog.test.tsx:回收站对话框(9 条)—— 空态、列出(名字/原路径/相对时间)、恢复后内容逐字回来 + 列表移除 + 成功提示、原位置被占用时绝不覆盖且指出「恢复为…」、「恢复为…」新建目录并如实汇报、孤儿记录禁用恢复、读台账失败给原因与重试、关闭后不残留、删除确认框写明可恢复tests/window-controls.test.tsx:窗口按钮(住在主叶标签条右端)(5 条)—— 没有窗口 API 时整组不渲染、三个按钮可访问、分别调用最小化/关闭、点最大化后图标变「还原」、窗口从外部被最大化(双击标签条 /Win+↑)后图标要跟上tests/preview-code-copy.test.tsx:阅读视图代码块的复制按钮(挂载数量、语言标签、复制内容去掉尾随换行、"已复制/复制失败"反馈与复原、重渲染不叠加按钮)tests/window-title.test.tsx:窗口标题(无笔记 / 有笔记 / 未保存圆点 / 只取文件名 / 非 Tauri 运行时静默跳过)tests/tabs.test.tsx:多标签页(打开成标签、点标签切换时当前文档与树选中项同步、切走前先落盘、光标与滚动位置记忆、关闭当前接相邻、Ctrl+W/Ctrl+Alt+←→/Ctrl+1..9的命令接线、按 Vault 根持久化与恢复、条目表变化时剪枝)tests/export.test.tsx:自包含 HTML 的生成(正文净化管线、图片内嵌成data:、主题令牌取成字面值、占位元素、大文档提示、打印容器的挂载与清理)tests/editor-input.test.tsx:列表输入语义(Enter 续行与有序列表顺延编号、空项退出列表、Backspace 去标记、Tab/Shift+Tab 升降级、代码区间内不接管)tests/assets.test.ts:图片路径解析的拒绝面(越界..、外部 scheme、Windows 非法字符与保留设备名、空地址)tests/attachments.test.ts:粘贴/拖入图片的命名与落盘规则 —— 扩展名推断(MIME 优先、image/tiff这类"是图片但不支持"直接拒)、通用名/无扩展名换成带时间戳的名字、同名追加1/2绝不覆盖、相对路径与地址里的空格转义、附件目录设置值的归一化;以及 Mock 与宿主逐条对齐的白名单/三道上限/去重/目录自动创建/UNSUPPORTED_MEDIA新错误码tests/editor-attachments.test.tsx:真造paste/drop事件走一遍接线 —— 粘图片落盘 + 插入相对链接 + 光标落到图片下一行 + 一次撤销;纯文本粘贴不触发附件链路(默认路径一个字都不变);拖入非图片明确拒绝且一个 IPC 都不发;设置里的附件目录真的被用上;插图后dirty与保存流水线正常、落盘失败时不插入链接tests/markdown.test.ts:XSS 防护(script、事件属性、javascript:、data:text/html、iframe、style)tests/tree.test.ts/tests/virtual-list.test.ts:树构建与排序、过滤保留祖先、虚拟窗口计算与滚动定位tests/eol.test.ts/tests/stats.test.ts/tests/commands.test.ts/tests/theme.test.ts:换行与 BOM 往返、统计口径、快捷键解析、主题令牌完整性
用仓库自带的示例 Vault:examples/demo-vault/。
-
pnpm tauri:dev→ 应用启动,显示 Vault 门闸页 -
「打开文件夹作为 Vault」→ 选择
examples/demo-vault→ 文件树出现,状态栏显示条目数与扫描耗时 -
点击
项目/设计文档.md→ 编辑器载入内容并直接以所见即所得方式显示(标题、引用、代码块、任务列表就地渲染);按Ctrl+E切到"阅读"看渲染后的整篇(表格、代码块、引用都在) -
输入文字 → 状态栏短暂显示「保存中…」→「已保存 hh:mm:ss」并给出写入耗时(应 < 50ms)
-
切换主题(状态栏右下角下拉)→ 编辑器与预览颜色立即跟随,无需重启
-
调色板按钮 → 停用/启用
example.css片段,观察预览标题/引用的变化 -
冲突验证:用记事本打开
README.md并改几个字保存 → 回到应用里输入一个字 → 顶部出现冲突横幅 → 选「用我的内容覆盖」或「丢弃我的修改并重新加载」 -
原子写验证:保存时观察 Vault 目录,正常情况下不应残留
.mimenote-*.tmp -
删除验证:选中
附件/示例说明.txt按Delete→ 二次确认 → 文件出现在.mimenote/trash/,台账.mimenote/index.jsonl多一行 -
重命名验证:选中
项目/设计文档.md按F2→ 输入新名字回车 → 文件树里的行改名;打开项目/路线图.md看预览里的[[设计文档]]是否已跟着改成新名字(用git status/编辑器看磁盘内容也行) -
标签与属性验证:按
Ctrl+Shift+T打开标签面板 → 当前笔记的#标签与 frontmatter 属性列出来;点某个标签 → 展开"哪些笔记用了它" → 点笔记直接打开。改标签:在输入框里填新标签回车(或用逗号一次填两个)→ frontmatter 里应多出这一项、面板与「全库标签」立刻跟上;点某个 frontmatter 标签的×→ 它从磁盘上消失。正文里的#标签带「正文」角标,点它的×只会提示"请到正文里删"(面板不动那个文件) -
命令面板 / 快速切换验证:
Ctrl+P输入"设计"回车 → 打开对应笔记;在编辑器里按Ctrl+K(应弹出面板,而不是删掉一行后半截)→ 输入"主题"回车 → 主题切换 -
全文搜索验证:
Ctrl+Shift+F输入"原子写"→ 结果里应出现项目/设计文档.md(带行号与片段)→ 回车打开它,光标应落在命中那一行(该行被滚到视口中间并闪一下,直接打字就写在那一行);中文与多词(如"原子 写")都应命中;删掉.mimenote/cache/search.db后重开 Vault 应自动重建 -
本地图片验证:打开
项目/设计文档.md→ 预览顶部应显示出图片(附件/示例图片.png)。若显示占位方块,说明该图没拿到授权(文件不存在、路径越界、或扩展名不在白名单里) -
大 Vault 验证:把示例 Vault 复制成多份或生成 1 万个小文件,打开后滚动文件树应保持顺滑(DOM 行数恒定),
Ctrl+P输入时也不应卡顿 -
所见即所得验证:打开
项目/设计文档.md,编辑器里应直接看到渲染后的标题/引用/代码块/任务列表,而不是#与反引号(表格、缩进代码块、脚注、HTML、公式按设计不做装饰,原样显示);把光标移进某段粗体(**…**)应立刻露出星号,移开又隐藏;任务列表- [ ]可以直接点 -
知识图谱验证:
Ctrl+G(在编辑器里按也一样,不会被 CodeMirror 的"查找下一个"抢走)→ 每篇笔记是一张卡片、文件夹自动成组;点卡片右侧出现正文预览;路线图.md的卡片被选中时,指向它的线是虚线、它指出去的线是实线;点文件夹标题可收起/展开;拖动空白处平移、Ctrl+滚轮缩放、0适应窗口;双击卡片回到编辑器;回编辑器改一行并等自动保存 → 图谱卡片上的入度/出度与连线自己跟上(不需要手动刷新) -
设置与菜单验证:文件树格子右下角的应用菜单按钮 → 按分类列出全部命令(带快捷键);
Ctrl+,打开设置 → 改编辑器字号应立即生效、阅读视图字号只影响阅读视图正文(编辑器字号不动)、改自动保存延迟后输入不再频繁写盘;「Vault」分区能看到索引进度与重扫按钮 -
多标签页验证:依次打开两篇笔记 → 主区域顶部出现两个标签,当前那篇高亮;点另一个标签切过去(编辑器内容与路径都跟着换,切换前未保存的内容已落盘);
×或中键关掉当前标签 → 自动接到相邻标签;Ctrl+W关当前标签(有未保存修改会二次确认);关掉应用再重开同一 Vault → 上次的标签列表与当前那篇都回来了 -
导出验证:打开
项目/设计文档.md→Ctrl+Shift+S导出 HTML 到桌面 → 关掉网络、直接用浏览器打开那个文件:图片也在(内嵌成data:URL)、样式与主题一致、没有外部请求;再按Ctrl+Shift+P→ 系统打印对话框里选"另存为 PDF" → 产物是同一份正文(打印结束后应用界面里不应残留打印容器) -
拖拽整理验证:把
项目/设计文档.md拖到另一个文件夹上(例如日记)→ 悬停时目标文件夹整行高亮(若拖到自己所在的项目上则标成不可放);松手后树里出现在新目录、原目录不再有它;打开项目/路线图.md看预览里的[[设计文档]]是否已改成../日记/设计文档(用git status/编辑器看磁盘内容也行);把当前打开的那篇拖走 → 编辑器路径立即跟着变、内容不动;再拖回项目应能改回原样。用键盘做同一件事:选中笔记按F6(或Ctrl+K→ 「移动到文件夹…」)→ 输入日记/子目录回车 → 目录不存在时会自动创建(不用先手工建文件夹) -
粘贴截图验证:打开任意一篇笔记,用系统截图工具(
Win+Shift+S)截一张图 → 在编辑器里Ctrl+V→ 图片立刻出现在光标处(光标会自动落到图片下面一行)、状态栏短暂显示保存;文件树里附件/下多出一个文件(截图工具给通用名时是粘贴图片 2025-01-01 123456.png,git status也能看到);按一次Ctrl+Z应能把这次插入整条撤销。再连粘三张:三张文件名互不相同(不会互相覆盖)。从资源管理器拖一张.png到编辑区 → 插到鼠标释放的位置;拖一个.pdf进来 → 应弹出"只接受图片文件"的提示且什么都不写。把设置页(Ctrl+,→ 编辑器 → 附件目录)改成assets再粘一张 → 文件应落在assets/下、笔记里写的是../assets/…;清空该输入框 → 图片直接落在 Vault 根目录。往编辑器里粘一段文本(例如从别处复制一行命令)→ 行为应与以前完全一样 -
大纲面板验证:打开一篇有多个标题的长笔记(例如把
项目/设计文档.md复制几份、加几个#/##)→Ctrl+Shift+O打开右侧大纲:标题按层级缩进、随输入实时更新(新敲一个##会立刻出现);在代码块里写# 伪标题应不出现在大纲里;点一条 → 编辑视图里光标落到那一行(正文一个字没变,Ctrl+Z不会撤销"跳转");按Ctrl+E切到阅读视图再点另一条 → 正文滚到那个标题并高亮一下。过滤与折叠:点掉顶部的H3开关 → 列表里立刻没有三级标题了(缩进不跳);把开关全点掉 → 出现"当前过滤条件下没有标题。"与「显示全部级别」;点条目左边的三角收起一节 → 它下面更深的标题一起消失、三角仍在(再点即展开);把光标移到被收起的某一节里(或按Ctrl+E滚到那里)→ 面板顶部应出现"当前章节(第 N 行)没有显示在列表里",且没有别的条目被高亮成"当前";换一篇笔记再回来 → 折叠恢复成全展开(折叠刻意不持久化),而级别过滤按 Vault 记住(关掉应用重开仍在) -
索引复用验证:打开一个大 Vault(或把示例 Vault 复制成一万篇)→ 第一次打开状态栏有"索引中"的过程;关掉应用再重开同一个 Vault → 应几乎立刻可用(
<Vault>/.mimenote/cache/search.db被复用,不再重读全部文件);随后只改一篇笔记并保存 → 下次打开仍然很快;把search.db删掉 → 下次打开回到"整库重建"(属预期:缓存是可丢弃的派生数据) -
阅读视图复制代码:
Ctrl+E切到阅读视图 → 鼠标移到某个代码块上,右上角出现"复制"按钮与语言标签 → 点一下变成"已复制"(再粘到别处验证内容正确、末尾不多个空行) -
窗口标题与顶部空间验证:打开一篇笔记 → 系统窗口标题(任务栏 / Alt+Tab)显示
笔记名 — Mimenote;在编辑器里改一个字(未保存)→ 标题多一个•;等自动保存完成 → 圆点消失。顶部空间(ADR-0038):窗口顶部没有独立标题栏 —— 最上面那条 36px 的带子就是标签条(文件树那格与主叶各一条),正文/文件树从窗口最顶边开始,比从前多出一整条标题栏的高度。拖动:按住任意标签条的空白处(标签之间的缝、标签上方那几像素、最后一个标签右边)拖动,窗口要跟着走;双击标签条空白处 → 窗口最大化/还原;拖到屏幕上沿也应贴边最大化。窗口按钮:主叶标签条右端有三个按钮 —— 点—最小化、点▢最大化(图标随之变成"还原"样式)、点✕关闭窗口(关闭前若有未保存内容,走既有的确认链路);把标签开很多(Ctrl+点击文件树多开几篇)→ 标签在滚动区里横向滚动,三个按钮不会被挤走。让按钮状态与窗口保持一致:用Win+↑或双击标签条最大化 → 那个按钮应自己变成"还原"(状态是从真实窗口读回来的,不是本地开关)。业务控件下移:库名与"多少篇/多少条目/扫描耗时"在底部状态栏(那里是唯一一份);导出在文件树工具栏的最右(图标按钮「切换 Vault」右边),点它能打开导出对话框;"我在看什么"(当前笔记/附件路径)在状态栏最左,切到阅读视图与图谱视图 → 它仍然在。把窗口拖窄 → 状态栏的统计与路径先走省略,主叶标签条里的标签仍然可点、可滚动,窗口按钮仍然完整 -
目录改名 / 目录移动验证:选中
项目文件夹按F2→ 改名框预填的是目录名(不带扩展名)→ 输入工程回车 → 文件树里整棵子树都换了前缀(工程/子项目/细节.md这类深层文件也在);打开工程/子项目/细节.md看预览里的链接是否仍然解析得到(悬空会画成虚线);再把工程拖到树的空白区域(或按F6输入空目录)→ 应提示"已经在 Vault 根目录"(无效落点,什么都不做)。把工程拖到它自己的子目录上 → 悬停时该行标红并提示"不能把文件夹移动到它自己的子目录里";同理把文件夹拖到它自己那一行上→ 提示"不能把文件夹移动到它自己里面"。改完记得把名字改回项目(见已知限制) -
外部改动自动同步验证(ADR-0016):打开
examples/demo-vault→ 在资源管理器里新建一个测试同步.md→ 约半秒后文件树里出现它(不要按Ctrl+Alt+R);再在记事本里改一改项目/设计文档.md保存 → 若它当前没有被编辑,编辑器里的正文自己变成新内容并提示"已在磁盘上更新,已重新加载";接着在应用里输入几个字(不要保存)→ 再用记事本改同一个文件 → 出现冲突横幅(而不是自动覆盖);点「丢弃我的修改并重新加载」应看到记事本里的内容。最后验证"自己写的文件不算外部改动":反复输入并等自动保存 → 状态栏正常显示"已保存",文件树不闪、不重扫(日志里没有一行"检测到外部改动",见%LOCALAPPDATA%\app.mimenote.desktop\logs\mimenote.log);关掉 Vault(或换一个 Vault)后,再改旧 Vault 里的文件不应该有任何反应(旧监听已停) -
标签重命名 / 合并验证:先造两篇笔记,一篇 frontmatter 写
tags: [旧名]、正文里写#旧名,另一篇同样写#旧名,再在其中一篇的代码块里放一行#旧名。点标签面板里旧名旁边的✎→ 输入新名→ 应看到预览"这会改 N 篇"(这一步还没落盘:用git diff或记事本确认磁盘没变)→ 确认 → 两篇文件的 frontmatter 与正文都变成新名,而代码块里的那行没动;面板与「全库标签」里旧名消失、新名出现。合并:把新名改成另一篇已有的标签 → 同一篇里重复的提及会被去掉。跳过如实汇报:先改之前用记事本动一下其中一篇(制造外部改动)→ 结果里应明确说"X 篇没改(磁盘被外部改动)",而不是只报成功。层级:给父改名为母(「连同子标签」默认开)→父/子应变成母/子 -
文件树按标签过滤验证:文件树头部点标签选择器 → 勾一个标签 → 树里只剩用了它的笔记(祖先目录仍在,计数显示"只显示 M / 共 N 篇");勾两个标签(多选是并集,界面上写着"含任意一个");选中
父而「含子标签」开着时,父/子的笔记也在;按Esc或点「清除」→ 立刻回到全库。过滤期间拖一篇笔记到树的空白区域 → 应被明确拒绝并说明原因(这是刻意的:空白处等于"移到根目录",而根目录在收窄视图里看不见);拖到另一行上仍然正常。排除:在选项行末尾点「排除」→ 选中的那一类被剔出去("不含 #xxx" 会以胶囊形式单独列出来,点 × 可单独取消);同一个标签点「排除」会从"含"移到"不含"(互斥)。再叠一个文本过滤词 → 两个条件是"与"的关系(只在可见行之间导航,虚拟列表也按过滤后的行数算) -
回收站恢复验证:在文件树里选中一篇笔记按
Delete→ 确认框里应写明"可在「回收站」里恢复" → 确认。然后Ctrl+K搜「回收站」回车:列表里能看到它(名字、原路径、"刚刚"、大小)→ 点「恢复」→ 文件应逐字回到原位置、文件树里重新出现那一行、列表里不再有它。再删一次,这次先手工把同名文件建回来(在应用里新建一篇同名的,或用记事本写一个)→ 点「恢复」→ 应明确报"原位置已经有别的文件了"并提示用「恢复为…」,磁盘上那个占位文件一个字节都不能变;点「恢复为…」把路径改成归档/拿回来.md回车 → 应成功并提示"顺手建了 1 个目录",树里在归档下出现它。最后试整目录:删掉一个文件夹 → 恢复 → 树里整棵回来(宿主会做一次静默重扫) -
标签层级编辑验证:先造三篇笔记:一篇 frontmatter 写
tags: [甲, 别的]、正文里写#甲;一篇写tags: [甲/子];再在其中一篇的代码块里放一行#甲。Ctrl+Shift+T打开标签面板 → 在「全库标签」里找到甲→ 点它旁边的⇥→ 对话框标题是"调整标签层级"、输入框初值是空的(顶层标签没有父标签)→ 输入父回车 → 预览应说**"这会改 2 篇笔记"(甲与甲/子各一篇;这一步还没落盘,用记事本确认磁盘没变)→ 点「确定移动」→ 两篇文件的 frontmatter 与正文都变成父/甲/父/甲/子,而代码块里的那行没动**;面板与「全库标签」里甲消失、父/甲与父/甲/子出现。提回顶层:点父/甲旁边的⇥→ 输入框里预填着父→ 点「移到顶层」(输入框清空)→ 回车 → 预览 → 确定 → 它变回甲。非法移动:再点甲旁边的⇥,父标签里填甲自己 → 回车 → 应停在输入阶段并弹出一条错误提示(原文是"不能把标签挂到它自己下面",不是"路径不合法"),磁盘一个字节不变;再试填甲/子(它自己的子标签)应报"会造出改不完的层级";先建一个名为父/甲的标签再移动甲→ 应报"「父/甲」已经是一个标签了;要合并请用「重命名」"。最后验证面板跟着挪:展开父/甲/子那一节,然后把它上面的父/甲移到母下 → 展开的那一节应变成母/甲/子,而不是空着 -
整库导出静态站点验证:先在一个不在 Vault 里、也不在同步盘里的位置建一个空目录(例如
D:\站点测试)。Ctrl+Alt+S(或文件树工具栏「导出」→ 第三个选项)→ 选那个目录 → 看着进度跑完("正在导出 N / M 篇…",可以点「停止导出」中途停下)。然后用文件管理器打开index.html(双击,走file://):索引页按目录分组列出所有笔记;点一篇进去,正文排版与应用里的阅读视图一致(断网也一样);点正文里的[[双链]]能跳到另一篇;页面底部的「反向链接」能点回来源;指向不存在笔记的链接是灰色不可点的文字(悬停有说明);图片都在(assets/目录里),且index.html与每篇 HTML 里没有任何data:image/(图片是复制的,不是内嵌的);整站没有 JavaScript(用记事本打开任一 HTML,搜<script应为 0 处)。确定性:什么都不改,再导出一次到同一个目录 → 目录里的文件内容逐字节不变(index.html也一样;只有mimenote-export.json里的时间戳会变)。拒绝:把输出目录选成 Vault 里的某个目录 → 应被明确拒绝(提示里说清"会触发重扫、还会被同步盘整站上传"),Vault 一个字节不变;选一个非空且不是我们导出的目录 → 也被拒绝(避免几千个文件散进用户自己的目录)。取消:跑到一半点「停止导出」→ 应提示"已经写出 N 个文件、这次没有写索引页与标记文件",并且那个目录里确实没有index.html -
隐藏
.md验证:打开一篇笔记 → 逐处确认没有扩展名:窗口顶部标签页上写着设计(不是设计.md)、状态栏最左是项目/设计、左侧文件树那一行也是设计、Ctrl+P输入设计列出的候选同样不带.md、反链面板里的来源名、任务栏/Alt+Tab 的窗口标题都一致。全名没丢:把鼠标停在这三处(标签页 / 状态栏路径 / 文件树行)→ 提示里是完整文件名(项目/设计.md)。不该隐藏的地方:Ctrl+Shift+S导出的 HTML、Ctrl+Alt+S的静态站点里,页面文件名与站内链接仍然是设计.md→设计.html的真实映射;打开移动对话框(F6)时"将移动到:"那一行给的是带.md的真实路径;把它移进一个不存在的目录,磁盘上的文件名一个字符都没变。附件不受影响:附件/图.png在文件树里仍然带.png(去掉就认不出是什么文件了)。最后在编辑器的[[补全里敲设计→ 候选仍然能选中那一篇(补全早就是这个口径) -
附件预览验证:在文件树里点一张图片(例如
附件/图.png)→ 主区应换成查看器:图片真的显示出来(不是占位方块、不是空白),信息行有"宽 × 高 · 大小 · 路径",右上角一个「实际大小 / 适应窗口」开关(点一下按原始像素显示、再点回适应窗口)。状态栏最左应变成那张图(不是之前那篇笔记)。回到笔记有三条路,随便试一条:点文件树里的一篇笔记 / 点状态栏的编辑按钮 / 换一个 Vault —— 查看器都该关掉。不该发生的事:看一张图不该在标签栏冒出标签(标签栏只装笔记);点一个.txt/.zip附件仍然只是选中它(不提供预览)。浏览器预览(pnpm dev)里点图片会显示一句"浏览器预览模式读不到本地文件"的说明 —— 那是预期,真实渲染请在桌面应用里看
见 docs/architecture.md §8/§9。当前最主要的几条:
- 所见即所得是"渲染视图",不是富文本:文档始终是纯 Markdown,装饰只在光标不在该处时隐藏标记。表格、缩进代码块、脚注、引用式链接、HTML、数学公式不做装饰(原样显示,见 ADR-0009);frontmatter 只是淡色不隐藏(保留可编辑与可 diff)
- 重命名与跨目录移动(拖拽整理)都已交付:改名保持"最小 diff"(裸名链接仍是裸名),跨目录移动则把链接一律改成相对新位置的路径(
[[乙]]→[[子/乙]]),因为裸名靠"同目录优先"消歧、换了目录可能落到另一篇同名笔记上;移动还会改写被移动笔记自身正文里的相对链接(随新位置重算,见 ADR-0012)。目录重命名与目录移动也已交付(ADR-0015):整棵子树的路径与全库链接一起改,复用同一条搬迁链路与同一个 DTO;文件夹可拖、F2/F6对文件夹同样可用,拖进自己的后代是无效落点。目录搬迁没有进度与取消(1000 篇实测 2–10 s,只给一次 toast,成本与被引用的篇数成正比)。仍然没做:引用式定义行([id]: ../附件/图.png)里的相对目标不改写;带反斜杠或越出 Vault 根的目标跳过;被移动笔记里的裸名 wikilink([[乙]])不动 —— 它按文件名主干解析,同名笔记多时移动后可能改指另一篇。 新文件名里含#/^时,指向它的链接不会被改写(wikilink 语法无法表达这种目标),宿主会记日志并跳过 - 标签现在是可写的:加/删(
note_set_tags,只动 frontmatter)、全库重命名/合并(tag_rename:frontmatter 的tags/tag+ 正文行内#标签一起改,跳过代码块与行内代码)、文件树按标签过滤(宿主的tag_filter一次算完:含任意一个 / 不含任何一个 / 可带后代)、层级编辑(tag_move:挂到某个父标签下或提回顶层,复用重命名那条写路径)都已交付,判同只有一处(normalize_tag)。仍然要知道的边界:① 正文行内#标签在"加/删"时是只读的(那等于改正文;面板标出「正文」角标并提示"请到正文里删");② 重命名/合并批量写盘没有进度与取消(约 8 ms/篇:100 篇 ≈ 1 s、700 篇 ≈ 5–6 s),单篇失败只进跳过清单、整批不回滚(回滚要把已写文件再写一遍,风险更大),有跳过时界面会如实说明并建议重试(重试幂等);③ 缩进代码块(4 空格)里的#标签仍会被当成标签改(继承抽取器的既有口径);④ 文件树的标签过滤是只读收窄:查询失败时整轮不收窄(宁可不过滤,也不给"少了几篇"的假收窄),过滤期间"拖到树的空白区域 = 移到根目录"被禁用(列表短、空白大,误拖会静默搬家);⑤ 过滤条件变了才会重新取数,所以刚做过层级编辑/重命名的那些标签要等下一次重扫或手动「重新过滤」才反映到树上(树收窄的依据是打开时那份命中集合,不是实时倒排);⑥ 参数口径:面板显示的是tags与tag合并后的列表,而加/删的写入目标是tags(没有tags时才写tag)—— 所以两者同时存在时tag:里的标签点×删不掉,面板会明确说"它来自tag:字段"(重命名/合并会改这两个字段里的任何一个);⑦ 层级编辑仍缺三样:标签面板上的拖拽成树、多选一起挂到某个父标签下、父标签输入的前缀补全(现在只有datalist的原生提示)。搜索与反向链接回车会跳到命中行(app/actions.ts的openNoteAt),但出链不定位(链接写在哪一行 ≠ 目标笔记里的哪一行,#锚点也没有"锚点 → 行号"的接口) - 图谱的自动刷新是"保留视角"的一档:保存成功与索引就绪都会触发一次刷新(画布正显示时),但刷新不重建手工拖动过的位置;手工拖动会覆盖自动布局(「重新自动排布」一键复位);折叠状态不持久化(每次默认全展开)
- 删除走 Vault 内回收站(不做
unlink),未对接系统回收站。恢复的界面已交付(ADR-0018):菜单 / 命令面板 → 「回收站」,可「恢复」到原位置或「恢复为…」换个名字放回去;那里已经有别的文件时绝不覆盖(记录与文件都留在回收站里),孤儿记录(台账里有、回收站里没有)会标出来并禁用恢复。仍然要知道的边界:回收站没有"永久删除"与"清空" —— 本轮只做"拿回来",清空要用户自己去文件管理器里做(之后台账会出现孤儿记录,界面已如实标出);恢复整目录走一次静默重扫(1 万笔记约 0.1–0.3 s,复用索引缓存),单篇是就地更新、不重扫;不做自动改名(原位置被占用时必须手改名字 —— 自动改名会让"我明明恢复了、怎么找不到"变成新的困惑) - 冲突检测基于 mtime(同毫秒内两次独立修改理论上有漏检窗口);索引复用也基于同一把钥匙(
(path, mtime, size)),因此"同毫秒 + 同字节数"的外部改动同样会漏检 —— 要兜住得引入内容哈希(M5) - 大文档预览(ADR-0020):1 MiB 的中文笔记实测(jsdom,看占比)—— 解析约 9%、DOMPurify 净化约 46%、
innerHTML落地约 42%、全篇querySelectorAll约 5%。因此:净化与 DOM 落地只能在主线程(DOMPurify 在没有window的环境里连sanitize都没有定义,实测 threwTypeError;DOM 构建更是主线程独占),Worker 只承担解析那一段(超过 1 MiB 才启用,data-mn-render标出走的是哪条路)。本轮真正消掉的是两处放大:图片授权不再触发整篇重渲染(就地补图)、wikilink 补类名从二次方降到线性。仍未做分块 + 视口窗口化 —— 它才是消灭十万级 DOM 元素(5 MiB 文档约 24 万个元素)的办法,会打破大纲定位/灯箱/代码块挂按钮三处"整篇都在 DOM 里"的前提,留待下一轮。另外:编辑器装饰按视口计算,但光标移动也会重算("进入即露原文"的必然代价);大纲面板跟着正文重算(useDeferredValue降级,输入不被它拖慢)。"当前章节"高亮两个视图里都成立:编辑视图取"最后一个不晚于光标行的标题"(光标行由编辑器在装配层节流上报,只有行号真的变化才写入),阅读视图取"视口顶部最后一个标题"(滚动监听是 passive 的,计算落在 rAF 里,序号没变就不回调);阅读视图没有挂预览时不猜(没有依据就不高亮) - 图片粘贴/拖入只支持图片:非图片整批拒绝(不做部分成功)、不做出站请求(网络图片仍是占位元素)、SVG 走
<img>但不做转码(4K 截图按原样落盘);多光标时只在主光标处插入一次 - E2E 未覆盖:多窗口、插件(M4)、以及"标签/搜索/图谱在超大 Vault 下的表现"(功能已覆盖,规模未覆盖)
- 外部改动自动同步(文件监听)的边界(ADR-0016):判定"是不是外部改动"用的是磁盘状态 vs 条目表(
mtime+ 字节数),所以同一毫秒内、字节数又完全相同的外部改动会漏判(与冲突检测/索引复用同一把钥匙,要兜住得引入内容哈希);条目表被max_entries截断的超出部分每来一次事件都会触发一次重扫;应用自己改名/搬迁时被改写的那些文件(updated_links)会在条目表里留下旧 mtime,换来一次"多余但无害"的重扫(不弹提示);同步盘的中转文件里,.开头的隐藏项与Thumbs.db这类忽略名不触发重扫,而同步过来的正常文件(含xxx (冲突副本).md)会被当成新增(这正是用户期待的"同步下来了");网络盘 / UNC 路径下通知完整性由对端决定(部分 NAS 不发通知),此时退化为"可能要等一次手工重扫",不会报错;外部改动之后当前笔记的反链面板要等它自己的刷新时机才更新(图谱与搜索是索引驱动的,会自己跟上)。监听起不来只记一条日志 ——Ctrl+Alt+R的手工重扫始终是兜底 - 搜索索引(
.mimenote/cache/search.db)会长得比想象中大,而且它就躺在 Vault 里:一个真实的 4267 篇 / 50 万行 Vault 实测 207 MB(正文合计只有 22 MB)—— 体积来自"索引建在行上"(50 万个文档)与 FTS5 默认的位置表,不是文本重复(lines_fts是 external content,文本只存一份),也不是段膨胀(那个库里 FTS5 只有 1 个段)。它是可丢弃的派生数据(删掉下次打开自动重建),所以:Vault 在同步盘里时,建议在同步客户端里排除.mimenote/(否则每次重建都会让同步客户端上传一份 200 MB 级的文件);应用自身不受影响 —— 扫描器与文件监听都跳过.mimenote/。段合并已按实测接进增量收尾(段数 ≥ 8 时合并一次,查询快 1.68–2.2×,代价约 0.9 s 在后台线程上);体积层面的缩小方向(detail=none会牺牲短语查询、按篇索引要动"排名后取行号"的主线)见 ADR-0008「后续修订」第 3、4 节,本轮未做 - 静态站点(ADR-0019)的边界:输出目录必须选在 Vault 之外(写进去会触发应用重扫,而 Vault 在同步盘里时还会把整站上传);站内没有搜索(整站零 JavaScript:
file://下浏览器禁止fetch(),而 FTS5 库实测 207 MB 也不适合随站点分发 —— 需要时用Ctrl+F或索引页);不做增量重导出(每次都整库重渲染;计划本身是 O(索引) 且不读文件);从不删除旧文件(Vault 变小之后旧页面会留下,结果与索引页点名,想彻底重来就先删掉那个目录);图片先写页面、后复制,中途中断会留下短暂死图;标题口径取"frontmattertitle?? 文件名主干"(与图谱卡片同口径,能在不读文件的前提下算出来),因此可能与单篇导出差一级(单篇还多一条"正文首个 H1"回落);锚点只认标题纯文本(同名标题的链接落到第一处);单个页面文件离开站点目录后样式会丢(样式是整站共享的一份assets/site.css);大 Vault 仍是主进程分片渲染(每批 24 篇让出一次主线程,进度可见、可取消 —— 真正的离屏渲染是 M5 的计划)
| 指标 | 目标 | 状态(M3) |
|---|---|---|
| 冷启动到可交互 | ≤ 1500 ms | 未建立自动基准(M5) |
| Vault 扫描(1 万文件 + 100 目录) | ≤ 800 ms | 实测 120–147 ms(cargo run -p mn-core --release --example scan_bench,M2 末复测) |
| 打开 1MB Markdown | ≤ 100 ms | 状态栏显示每次读取耗时,可直接观察 |
| 输入延迟 | ≤ 16 ms | 结构性保证:输入路径零 IO、编辑器不因文本变化重渲染、预览 useDeferredValue;命令面板按键路径最坏 3–6 ms(1 万笔记,见 features/palette/match.ts) |
| 主线程单任务 | ≤ 8 ms | Rust 侧所有文件 IO 走 spawn_blocking |
| 保存(本地) | ≤ 50 ms | 状态栏显示每次写入耗时(含 fsync) |
| 文件树滚动 | 60 fps | 固定行高虚拟列表,DOM 行数 = 可视行 + overscan,与总数无关 |
| 全文搜索查询 | 交互可接受 | 见下表(合成基准:1 万笔记 / 29.1 万行) |
| 知识图谱渲染 | 交互可接受 | 1 万节点:布局 46 ms(一次性)、网格索引 7 ms、视口裁剪 0.09 ms/帧(当帧只渲染约 60 张卡片);平移/缩放只改一层 CSS transform |
| 大文档阅读视图(1 MiB / 65549 个元素) | 未定目标(一次性渲染) | jsdom 各阶段占比:解析 ≈9%、净化 ≈46%、innerHTML 落地 ≈42%、全篇扫描 ≈5%;因此净化与 DOM 留在主线程、解析在 >1 MiB 时进 Worker,另两处放大(图片授权重渲染、链接补类名二次方)已消除(ADR-0020) |
图谱数据(graph_data) |
一次 IPC 可接受 | 1 万笔记 / 2 万条链接:组装 55–85 ms、报文 1.0 MB(节点上限 8000 —— 几千篇的真实 Vault 因此能完整映射,超出才按度数截断);含 5% 悬空链接时 163–364 ms(悬空解析是 resolve_target 的全库后缀兜底,见 mn-index 注释) |
全文搜索实测(cargo test -p mn-index --release -- --ignored --nocapture,合成 Vault:1 万笔记、每篇约 29 行、共 29.1 万行,FTS 库 113.8–114.7 MB):
| 项 | 实测 |
|---|---|
| 索引构建(与链接/标签同一次遍历,后台、可取消) | 链接+标签 5.5 s;含 FTS5 10.3 s(其中 FTS 自身约 2.8 s,快于链接那一轮) |
| 查询:真实场景(命中约 100 行) | 3 ms |
| 查询:命中 1000 行 | 66–79 ms |
| 查询:命中 29 万行(查询词在每一行里都出现) | 0.9–1.2 s |
单独的全量计数 COUNT(*) |
12–17 ms(不是瓶颈) |
| 索引体积 | ≈ 被索引文本的 2.7 倍(中文逐字 token 的固有比例) |
慢的那一档是排序的固有成本:bm25() 要给全部命中打分,ORDER BY score LIMIT 50 也必须先算完
(已实测 CROSS JOIN / CTE+LIMIT / 去掉 ORDER BY / 加大页缓存 / mmap 变体,都在同一量级,
说明没有便宜的优化空间;拆解显示打分 + JOIN 回行表 + 三键排序占约 90%)。面板对请求做了 150ms 防抖 +
竞态丢弃,不会因为连续输入堆积查询;索引未就绪时面板显示"正在构建"提示而不是报错。
索引整体跨会话复用(ADR-0008「后续修订」+ ADR-0014):链接、标签、FTS5 落进同一个缓存库、
共用同一份 (路径, mtime, 字节数) 判定键。Vault 没变时一份笔记文件都不读:
| 场景(1 万笔记 / 30.1 万行 / 缓存库 117 MB,release) | 打开 Vault 耗时 |
|---|---|
| 旧行为(每次整库重建) | 9.8–10.5 s(链接/标签 4.9–5.1 s + 搜索 4.4–4.7 s) |
| Vault 没变(复用 10000 篇) | 0.25–0.34 s(对账 0.17–0.23 s + 装载 0.04–0.05 s,写 0 行) |
| 改了 1 篇(复用 9999 篇) | 0.26–0.31 s |
代价是写路径多了一点:冷启动整库重建多写约 3 万行(+5%)、每次保存多一次小事务(+0.68 ms/篇)。
<Vault>/.mimenote/cache/search.db 损坏时自动重建,锁冲突/磁盘满/只读只降级、不删缓存。
仍未做:文件监听(notify)触发的增量、内容哈希二级令牌(M5)。
扫描过程中发现并修掉的一个真实性能坑:用 fs::symlink_metadata(path) 逐文件取元数据,
1 万文件要 2227ms;改用 DirEntry 的缓存元数据后降到 145ms(14 倍)。
细节与约束见 docs/architecture.md §6。
| 现象 | 原因 / 处理 |
|---|---|
| 窗口一片空白,提示"无法访问 127.0.0.1" | 用 cargo build --release 而不是 Tauri CLI 构建的:生产二进制里被写入 devUrl。改用 pnpm tauri:build(见"构建"一节) |
| 点开笔记后编辑区是空白 | 已修复(CodeMirror 实例漏创建)。若再出现,跑 pnpm test:e2e:ui 一眼就能定位 |
| E2E 报"找不到应用二进制" | 先跑 pnpm --filter @mimenote/desktop exec tauri build --no-bundle(release 才内嵌前端资源) |
| E2E 应用层在非 Windows 上被跳过 | WebView2 的远程调试只在 Windows 上存在;这是显式跳过而不是"通过" |
pnpm tauri:dev 起不来,pnpm dev 正常 |
检查 1420 端口是否被占用(vite.config.ts 与 tauri.conf.json 都约定 127.0.0.1:1420) |
保存报 IO 错误 |
目标目录可能是 OneDrive 同步目录或只读;日志文件里有系统错误码(detail 字段) |
| 提示"文件已被外部修改" | 这是保护:别处改过同一文件。选择覆盖或重新加载(见 ADR-0004) |
| 界面上看不到本地图片 | M1 已知限制,图片渲染为占位元素,M2 引入 asset: 协议 |
| 需要看日志 | %LOCALAPPDATA%\app.mimenote.desktop\logs\mimenote.log(超过 2MB 会自动重建) |
- 默认离线:运行时无任何网络请求;CSP 为
default-src 'self'、script-src 'self',connect-src仅允许 Tauri IPC。 - 能力最小化:
capabilities/default.json只放开core:default与dialog:allow-open;文件访问全部经过自定义 IPC 命令与路径防护。 - 路径防护:拒绝
..、绝对路径、盘符/UNC、Windows 保留名与 ADS;逐级检查符号链接,目标必须仍在 Vault 内。 - 不破坏用户数据:写入原子替换、删除进回收站、冲突必须人工决策。
- XSS 防护:Markdown 关闭 raw HTML + DOMPurify 二次净化(有专门的攻击载荷测试)。
- 用户样式片段只从用户自己的 Vault 目录读取,不加载远程 CSS。
见 docs/dependencies.md:逐个说明理由、许可证,以及刻意没有引入的依赖及其替代实现方式。
- 提交前跑
pnpm check(类型 + 前端测试 + Rust 测试)与pnpm lint:rust。 - 新增 IPC 命令必须:
async fn+spawn_blocking+ 返回IpcError+ 在src/ipc/types.ts补类型 + 在docs/architecture.md§3.1 登记。 - 新增依赖必须在
docs/dependencies.md登记理由与许可证。 - 架构相关改动请追加 ADR(
docs/adr/),不要悄悄改变分层约定。
本实现基于以下已确认的假设(来自需求澄清):
- Windows 优先,代码保持跨平台(路径与文件 API 全部使用跨平台语义,未写死
\)。 - 技术栈:Tauri 2 + Rust + React + TypeScript + Vite + CodeMirror 6 + Zustand;SQLite FTS5 在 M2 引入。
- 第三方插件推迟到 M4,M1 只提供内置扩展点(命令、快捷键、主题、CSS 片段、编辑器扩展)。
- 不做同步/协作,只保证纯 Markdown 且可被 Git 管理。
- 目标规模 1 万笔记,使用上文默认性能预算。
MIT。