This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.18.0

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# Notes
桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。
![tests](https://img.shields.io/badge/tests-643%2F29%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue)
## 简介
- **文件即真相**:界面显示什么,磁盘上就是什么。
- **保存由你决定**:默认 `Ctrl + S` 手动保存;工具栏可一键开启自动保存。
- **完全本地**:所有文件就在你电脑上的指定文件夹里,复制走就是备份。
- **AI 可选**:内置修改助手,支持 OpenAI / Anthropic 兼容协议,不启用则永不联网。
## 快速开始
```bash
npm install
npm start
```
要求 Node ≥ 20.18(见 `.nvmrc`)。首次启动会在用户主目录创建 `Notes` 文件夹,把 `.md` 文件放进去即可在左侧栏看到。
> **Windows + 中文路径**Electron 在含中文路径下启动会报错。两种解决:
> 1. 把项目移到纯 ASCII 路径,如 `D:\Projects\notes`
> 2. 用管理员 cmd 建符号链接:`mklink /D C:\dev\notes "C:\path\to\中文路径\Notes"`,在 `C:\dev\notes` 跑 `npm install && npm start`
## 命令
```bash
npm start # 启动UTF-8 包装,推荐;中文 Windows 别用 start:raw
npm test # 全部单测643 用例 / 29 文件)
npm run test:watch # watch 模式
npm run check # 语法 + IPC 通道配对
npm run lint # ESLint
npm run build:win # Windows → dist/NSIS 安装包)
npm run build:mac # macOS → dist/
npm run build:linux # Linux → dist/AppImage
```
## 功能
### 阅读与编辑
- 侧栏文件列表,按文件名实时过滤(`Ctrl + F`
- 三种视图:预览 / 编辑 / 双栏(`Ctrl + E` 循环切换)
- 暗 / 亮主题 × 5 套配色(默认 / 海洋 / 森林 / 薰衣草 / 夕阳),正交可选
- 工具栏一键窗口置顶
- 链接智能处理:外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转
- 相对路径图片(`![](img/a.png)`)直接渲染
- 状态栏实时高亮当前阅读章节
- 聚焦模式隐藏工具栏 / 侧栏 / 状态栏(`Ctrl + Shift + F`
- 系统托盘:关闭窗口保留托盘图标,单击恢复
- 右键菜单:阅读视图复制 / 全选;编辑器复制 / 剪切 / 粘贴 / 全选
### 数据安全
- 外部修改自动检测其他编辑器改了文件会提示重新加载IME 合成期间不打断)
- 退出保护:有未保存改动时关闭窗口 / 退出托盘都会先问「保存 / 丢弃 / 取消」
- 原子写盘(`tmp + rename`),断电不留半截文件
- 数据目录自动回退当前目录不可用U 盘拔了 / 网盘断连)时自动切回默认目录
- 监听对云同步盘OneDrive / 坚果云等)也可靠
### AI 修改
- 工具栏唤起底部对话面板
- 双协议OpenAI 兼容(`/chat/completions`)与 Anthropic 兼容(`/v1/messages`
- 行级 + 词级 diff 高亮,可逐 region 应用或一键应用全部
- 提交后可随时取消,状态干净复位
## 设置
`Ctrl + ,` 打开设置对话框。
**数据** — 数据文件夹:留空用默认 `~/Notes`;切换时旧目录失效会自动回退
**外观** — 配色 5 套 × 2 主题;阅读字号 14/15/17/19/22阅读行距 1.5/1.7/1.85/2.0
**行为** — 文件列表排序(名称 / 修改时间);自动保存(编辑停下 500 ms 后落盘)
**AI** — 服务提供方 / Base URL / API Key明文存本机 `settings.json`UI 默认脱敏)/ 模型 / 系统提示(留空用内置中文 Markdown 助手 prompt
主题 / 置顶 / 视图模式在工具栏有独立按钮,**同一功能只保留一个入口**。
## 快捷键
| 快捷键 | 作用 |
|---|---|
| `Ctrl/Cmd + N` | 新建笔记 |
| `Ctrl/Cmd + F` | 聚焦文件搜索(编辑器内改为 CM6 查找) |
| `Ctrl/Cmd + S` | 保存IME 合成中拒绝物理写盘) |
| `Ctrl/Cmd + E` | 切换视图模式(预览 → 编辑 → 双栏) |
| `Ctrl/Cmd + ,` | 打开设置 |
| `Ctrl/Cmd + R` | 重新加载界面(脏状态会先确认) |
| `Ctrl/Cmd + Shift + T` | 切换主题 |
| `Ctrl/Cmd + Shift + F` | 切换聚焦模式 |
| `Ctrl/Cmd + Shift + A` | 切换 AI 面板 |
| `Esc`(搜索框内) | 清空搜索 |
| `F12` | 打开 DevTools |
窗口为无边框自绘标题栏Windows / Linux 上看不到菜单栏,但上表快捷键全部直接可用。
## 数据文件夹
| 平台 | 默认路径 |
|---|---|
| Windows | `C:\Users\<你>\Notes` |
| macOS | `~/Notes` |
| Linux | `$HOME/Notes` |
可在设置里改成任意目录。**所有文件就在这里** —— 复制走就是备份。
## 项目结构
```
Notes/
├── main.js # Electron 主进程
├── main/ # 主进程模块ai / file-ops / fs-watcher / config-store
├── preload.js # IPC bridge
├── index.html # UI 框架 + CSP + importmap
├── src/ # 渲染端
│ ├── ai/ # AI 面板chat / diff / status / controller
│ └── styles/ # CSS 分片tokens / palettes / base / … 12 个)
├── shared/ # 主进程 / preload / 渲染端共享
│ ├── ai-errors.js
│ ├── extension-lists.js
│ ├── friendly-fs-error.js
│ ├── markdown-diff.js
│ ├── render-sanitize.js
│ ├── settings-schema.js
│ └── slug.js
├── data/welcome.md # 首次启动种子文件
├── patches/ # patch-package 补丁app-builder-lib 26.15.3
├── scripts/
│ ├── launch.js # UTF-8 包装启动
│ ├── check-syntax.js # 语法静态检查
│ └── check-ipc.js # IPC 通道配对检查
└── tests/
└── unit/ # 29 个测试文件643 用例vitest
```
## 故障排除
**文件列表不更新**
- 检查设置里的数据目录
- 某些云同步盘的 `fs.watch` 不可靠,已加轮询 + watchdog 自愈
- `Ctrl + R` 手动重新加载(脏状态会先确认)
**控制台中文乱码** — 用 `npm start`,别用 `start:raw`
**AI 提示"无法连接到服务"**
- 检查 Base URLOpenAI 兼容需含 `/v1`Anthropic 不含
- 核对 API Key点"显示"露出明文)
- `F12` → console 看完整错误码(参考 `shared/ai-errors.js`
**启动报错**
- 路径含中文(仅 Windows→ 见上文"快速开始"
- Node 版本低于 20.18`nvm use`(仓库根目录有 `.nvmrc`
- `F12` → console 看渲染进程堆栈
## License
MIT

14
data/welcome.md Normal file
View File

@@ -0,0 +1,14 @@
# 欢迎使用 Notes
这是一个简洁的 **Markdown 阅读器**
`.md` 文件放进数据文件夹,左侧栏就会自动出现 —— 文件怎么写,界面就怎么显示。
## 快捷键
- `Ctrl + F` 搜索文件
- `Ctrl + S` 保存
- `Ctrl + ,` 打开设置
- `Ctrl + Shift + T` 切换主题
开始写笔记吧。

184
eslint.config.js Normal file
View File

@@ -0,0 +1,184 @@
// ESLint flat config
// 两个 block
// 1. main / preload / scripts/ —— Node CJS 环境require / module / process / __dirname
// 2. src/ —— 浏览器 ESM 环境(无 Node globalsimportmap 暴露的 codemirror / @codemirror/* 是 ESM 模块,
// 不是 globals不写进 globals 表)
//
// 规则严格度按"项目当前状态可一键通过"为基线recommended + 关键 hygiene不强制风格化prettier 不上)。
const js = require('@eslint/js');
module.exports = [
// eslint.config.js 自身Node CJS
{
files: ['eslint.config.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'writable',
exports: 'writable',
},
},
},
// 全局忽略
{
ignores: [
'node_modules/**',
'dist/**',
'.playwright-mcp/**',
'scripts/_dbg-*.png',
'tmp-*.mjs', // 临时验证脚本
'scripts/**/*.mjs', // 一次性 ad-hoc debug 脚本playwright / repro 等),不是生产代码
'src/styles/**', // Stage 4 之后才有
'tests/**', // 测试文件用 vitest globalsdescribe/it/expect/vi
// 由 vitest.config.js 负责;不需要 ESLint 强校验。
],
},
// Block 1: Node CJS —— main / preload / scripts / shared
{
files: [
'main.js',
'main/**/*.js',
'preload.js',
'scripts/**/*.js',
'shared/**/*.js',
],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
// Node
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
module: 'readonly',
require: 'readonly',
exports: 'writable',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
// Electronmain/preload 直接 require 进来的)
app: 'readonly',
BrowserWindow: 'readonly',
ipcMain: 'readonly',
ipcRenderer: 'readonly',
contextBridge: 'readonly',
Menu: 'readonly',
Tray: 'readonly',
dialog: 'readonly',
shell: 'readonly',
screen: 'readonly',
nativeImage: 'readonly',
globalShortcut: 'readonly',
// preload 在 Electron sandbox 下能引用 window与 renderer 的隔离 window 不同)
window: 'readonly',
document: 'readonly',
// Node 18+ 内置 Web APImain/ai.js 用 fetch + AbortController
fetch: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
},
},
rules: {
...js.configs.recommended.rules,
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-empty': ['error', { allowEmptyCatch: true }],
'no-async-promise-executor': 'off', // preload 有 await in new Promise 的合法用法
},
},
// Block 2: renderer ESM —— src/**
{
files: ['src/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
// Browser
window: 'readonly',
document: 'readonly',
navigator: 'readonly',
location: 'readonly',
history: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
IntersectionObserver: 'readonly',
fetch: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
getComputedStyle: 'readonly',
queueMicrotask: 'readonly',
MutationObserver: 'readonly',
crypto: 'readonly',
Element: 'readonly',
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLTextAreaElement: 'readonly',
Event: 'readonly',
CustomEvent: 'readonly',
KeyboardEvent: 'readonly',
MouseEvent: 'readonly',
DOMParser: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
Blob: 'readonly',
File: 'readonly',
FileReader: 'readonly',
Promise: 'readonly',
Map: 'readonly',
Set: 'readonly',
WeakMap: 'readonly',
WeakSet: 'readonly',
Date: 'readonly',
Math: 'readonly',
JSON: 'readonly',
Number: 'readonly',
String: 'readonly',
Array: 'readonly',
Object: 'readonly',
Error: 'readonly',
TypeError: 'readonly',
RangeError: 'readonly',
Symbol: 'readonly',
Proxy: 'readonly',
Reflect: 'readonly',
parseInt: 'readonly',
parseFloat: 'readonly',
isNaN: 'readonly',
isFinite: 'readonly',
encodeURIComponent: 'readonly',
decodeURIComponent: 'readonly',
// CodeMirror 是 ESM 模块,通过 importmap 解析 —— 不属于 globals
},
},
rules: {
...js.configs.recommended.rules,
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
// 全局规则调整(适用于两个 block
{
rules: {
'no-undef': 'error', // 强制renderer 不能用 process.*
'no-prototype-builtins': 'off', // 旧代码里有 obj.hasOwnProperty(...) 调用
},
},
];

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

524
index.html Normal file
View File

@@ -0,0 +1,524 @@
<!DOCTYPE html>
<!--
lang="zh-CN" + dir="ltr"Round 12 P2 auditCJK 默认 ltr但 a11y lint
通常要求显式 dir。Electron 桌面应用虽不像移动 web 那样卡 viewport 缩放,
但 viewport meta 仍然是为未来 web 模式合规铺路P1 audit
-->
<html lang="zh-CN" dir="ltr">
<head>
<meta charset="UTF-8">
<!-- viewport meta (Round 12 P1 audit):桌面 Electron 也有 webview / web mode 兼容场景 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
Content Security Policy:
- default-src 'self' 只允许加载同源资源
- style-src 'self' 'unsafe-inline' 允许 inline stylemarked 渲染的代码块可能会用到)
- script-src 'self' 'unsafe-inline' 允许同源 + 内联脚本(用于 importmap
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64
关于 'unsafe-inline':本应用唯一的内联脚本是 importmap用于映射 CodeMirror 包)。
渲染端的实际代码全部走 .js 文件加载。Markdown 内容经 DOMPurify 清洗,<script> 会被剥离。
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
-->
<!--
Content Security Policy (加固版):
- default-src 'self' 只允许加载同源资源
- style-src 'self' 'unsafe-inline' 允许 inline stylemarked 渲染的代码块可能会用到)
- script-src 'self' 'unsafe-inline' 允许同源 + 内联脚本(用于 importmap
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64
- object-src 'none' 禁止 <object>/<embed>/<applet>(插件 XSS 通道)
- base-uri 'none' 禁止 <base> 标签劫持相对 URL
- form-action 'none' 禁止 form 提交外发(即使有 <form> 也是死链)
关于 'unsafe-inline':本应用唯一的内联脚本是 importmap用于映射 CodeMirror 包)。
渲染端的实际代码全部走 .js 文件加载。Markdown 内容经 DOMPurify 清洗,<script> 会被剥离。
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
未来若改用 nonce 或 hash可去除 'unsafe-inline'。
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: file:; object-src 'none'; base-uri 'none'; form-action 'none';">
<title>Notes</title>
<!--
Import map: 把 CodeMirror 6 及其传递依赖映射到本地 node_modules。
浏览器原生 ESM 不解析 package.json 的 exports 字段,所以必须写完整相对路径。
style-mod 的 ESM 在 src/ 而不是 dist/dist/ 只有 CJS—— 这是常见坑。
-->
<script type="importmap">
{
"imports": {
"codemirror": "./node_modules/codemirror/dist/index.js",
"@codemirror/state": "./node_modules/@codemirror/state/dist/index.js",
"@codemirror/view": "./node_modules/@codemirror/view/dist/index.js",
"@codemirror/commands": "./node_modules/@codemirror/commands/dist/index.js",
"@codemirror/language": "./node_modules/@codemirror/language/dist/index.js",
"@codemirror/lang-markdown": "./node_modules/@codemirror/lang-markdown/dist/index.js",
"@codemirror/lang-css": "./node_modules/@codemirror/lang-css/dist/index.js",
"@codemirror/lang-html": "./node_modules/@codemirror/lang-html/dist/index.js",
"@codemirror/lang-javascript": "./node_modules/@codemirror/lang-javascript/dist/index.js",
"@codemirror/search": "./node_modules/@codemirror/search/dist/index.js",
"@codemirror/autocomplete": "./node_modules/@codemirror/autocomplete/dist/index.js",
"@codemirror/lint": "./node_modules/@codemirror/lint/dist/index.js",
"@codemirror/theme-one-dark": "./node_modules/@codemirror/theme-one-dark/dist/index.js",
"@lezer/common": "./node_modules/@lezer/common/dist/index.js",
"@lezer/highlight": "./node_modules/@lezer/highlight/dist/index.js",
"@lezer/markdown": "./node_modules/@lezer/markdown/dist/index.js",
"@lezer/css": "./node_modules/@lezer/css/dist/index.js",
"@lezer/html": "./node_modules/@lezer/html/dist/index.js",
"@lezer/javascript": "./node_modules/@lezer/javascript/dist/index.js",
"@lezer/lr": "./node_modules/@lezer/lr/dist/index.js",
"@marijn/find-cluster-break": "./node_modules/@marijn/find-cluster-break/src/index.js",
"style-mod": "./node_modules/style-mod/src/style-mod.js",
"w3c-keyname": "./node_modules/w3c-keyname/index.js",
"crelt": "./node_modules/crelt/index.js"
}
}
</script>
<link rel="stylesheet" href="src/styles.css">
</head>
<body data-theme="dark" data-theme-palette="default" data-focus-mode="false">
<!-- 聚焦模式顶边热区:触发时滑出工具栏 -->
<div class="focus-hotzone" aria-hidden="true"></div>
<!--
Skip link (Phase M a11y)
键盘用户 Tab 时第一站直达主内容,跳过工具栏 9 个按钮 + 侧栏若干入口。
默认视觉隐藏不占布局focus 时滑出右上角。target 是 #viewer-main
- 标记 <main id="viewer-main" tabindex="-1">:让非交互元素也能 focus
以承接 hash 跳转 + 屏幕阅读器宣告「进入主区域」。
-->
<a href="#viewer-main" class="skip-link">跳到主内容</a>
<!-- 启动错误兜底window.api 不可用 / bootstrap 抛错时显示) -->
<!-- a11y #2criticalrole="alert" + aria-live="assertive" 让屏幕阅读器在出现
bootstrap 失败的瞬间立即播报不会漏掉关键故障。hidden 时虽然 [hidden] 会
display:none但 role/aria-live 仍声明「一旦显示就立即播报」。 -->
<div id="boot-error" hidden role="alert" aria-live="assertive">
<h2>⚠ 启动失败</h2>
<p>无法连接到主进程。请重启应用。</p>
<p class="boot-error-detail"></p>
</div>
<!-- 自定义标题栏:可拖拽 + 窗口控件 -->
<header class="toolbar">
<div class="toolbar-left drag-region">
<!-- 应用图标:由 preload 读 icon.ico 转 PNG data URL渲染端注入 src。
hidden 直到 src load 成功避免破图占位闪烁。src 失败 → 走 .app-title::before 占位 -->
<img id="app-icon" class="app-icon" alt="" draggable="false" hidden>
<!--
长文件名截断fix长文件名导致 toolbar 偏移):
- flex 容器里的直接文本节点 min-content 是整段文本,无法自然收缩;
必须把文本包到 .app-title-text 里,给它 min-width:0 + ellipsis 才能截断
- 渲染端updateWindowTitle写 textContent 到 #app-title-text
没有这个 span 时退回 #app-title textContent向后兼容
-->
<span id="app-title" class="app-title"><span id="app-title-text" class="app-title-text">Notes</span></span>
</div>
<div class="toolbar-right no-drag">
<!-- 应用组:主题 + 置顶 -->
<div class="toolbar-group">
<div class="btn-group" role="group" aria-label="外观">
<button class="btn-icon btn-group-item" id="btn-theme" title="切换主题 (当前: 暗色主题)" aria-label="切换主题">
<svg class="theme-icon theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="theme-icon theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button class="btn-icon btn-group-item" id="btn-pin" title="始终置顶" aria-label="始终置顶">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="17" x2="12" y2="22"></line>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h-6v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path>
</svg>
</button>
</div>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 视图组:预览 / 编辑 / 双栏(图标 + 文字按钮,比纯图标更易识别) -->
<div class="toolbar-group">
<div class="btn-group btn-group-text" role="group" aria-label="视图模式">
<button class="btn-icon-text btn-group-item" id="btn-mode-preview" title="预览模式 (Ctrl+E 切换)" aria-label="预览模式" aria-pressed="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span class="btn-label">预览</span>
</button>
<button class="btn-icon-text btn-group-item" id="btn-mode-edit" title="编辑模式 (Ctrl+E 切换)" aria-label="编辑模式" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span class="btn-label">编辑</span>
</button>
<button class="btn-icon-text btn-group-item" id="btn-mode-split" title="双栏模式 (Ctrl+E 切换)" aria-label="双栏模式" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
<span class="btn-label">双栏</span>
</button>
</div>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 布局组:恢复默认侧栏宽度 + 双栏比例 -->
<div class="toolbar-group">
<button class="btn-icon" id="btn-reset-layout" title="重置布局(恢复默认侧栏宽度 + 双栏比例)" aria-label="重置布局">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7"></path>
<polyline points="3 4 3 9 8 9"></polyline>
</svg>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 行为组:自动保存开关(贴近编辑器,作用在编辑器 → 放顶部工具栏;见 [[feedback-prefer-near-affected-area]])。
iOS pill switch 替代原先的「按钮 + status dot」让开关语义更明显。
role="switch" 让屏幕阅读器读「开关 开/关」而非「按钮 按/未按」。
初始 aria-pressed="true" 与 settings-schema DEFAULT_SETTINGS.autoSaveDebounceMs=500 一致,
避免首帧 FOUC 闪一下「关 → 开」audit D1-->
<div class="toolbar-group">
<button class="btn-icon-text toolbar-toggle" id="toolbar-autosave" type="button"
title="自动保存:开启(停打后 500ms 保存)· 点击关闭"
aria-label="自动保存"
aria-pressed="true"
role="switch">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<polyline points="12 7 12 12 15 14"></polyline>
</svg>
<span class="btn-label">自动保存</span>
<span class="toggle-switch" aria-hidden="true">
<span class="toggle-switch-track"></span>
<span class="toggle-switch-thumb"></span>
</span>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 操作组:保存 + 设置 -->
<div class="toolbar-group">
<!--
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态warning 色 + 脉动)。
见 [[feedback-save-button-always-visible]]。
-->
<button class="btn-icon" id="btn-save" title="保存 (Ctrl+S)" aria-label="保存">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 窗口组:最小化 / 最大化 / 关闭 -->
<div class="toolbar-group">
<div class="window-controls">
<button class="btn-window" id="btn-min" title="最小化" aria-label="最小化">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><line x1="0" y1="5" x2="10" y2="5" stroke="currentColor" stroke-width="1"/></svg>
</button>
<button class="btn-window" id="btn-max" title="最大化" aria-label="最大化">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1"/></svg>
</button>
<button class="btn-window btn-close" id="btn-close" title="关闭" aria-label="关闭">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1"/></svg>
</button>
</div>
</div>
</div>
</header>
<!-- 主区:侧边栏 + (可选编辑器 + 分割条) + 阅读区 -->
<!--
id="app" 是 modal.js:126 背景 inert 隔离机制的锚点modal 打开时给 #app
打 inert 让背景 DOM侧栏/工具栏/状态栏)失活,避免键盘 Tab 跳出 modal、
屏幕阅读器朗读背景。HTML 里必须显式存在该 id否则 getElementById 返回
null整套 inert 隔离静默失效Round 12 P0 audit 发现)。
不要把 inert 挂在 body —— 那会让 modal 自身也失活;#app 是 app-shell
跟 modal-root独立容器平级。
-->
<div class="app-shell" id="app">
<aside class="sidebar" id="sidebar" aria-label="文件与目录">
<div class="sidebar-header">
<div class="search-wrap">
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
>
</div>
</div>
<!-- 新建笔记:放在搜索框下方的独立主操作行,不再和搜索框挤在同一排抢首屏注意力(见 [[feedback-prefer-near-affected-area]] -->
<div class="sidebar-new-row">
<button id="btn-new-file" class="sidebar-new-btn" type="button" title="新建笔记 (Ctrl+N)" aria-label="新建笔记">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>新建笔记</span>
</button>
</div>
<!-- 排序工具条:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]] -->
<div class="sidebar-sort" role="group" aria-label="文件列表排序">
<button id="btn-sort-name" type="button" class="sidebar-sort-btn is-active" aria-pressed="true" title="按名称 A → Z 排序">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h13M3 12h9M3 18h5"></path>
<path d="M19 4v16M16 17l3 3 3-3"></path>
</svg>
<span>名称</span>
</button>
<button id="btn-sort-mtime" type="button" class="sidebar-sort-btn" aria-pressed="false" title="按修改时间排序(最近修改在前)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<polyline points="12 7 12 12 15 14"></polyline>
</svg>
<span>修改时间</span>
</button>
</div>
<!-- Folder BrowserStage 8面包屑 + 「回到根」按钮,贴近它作用的文件列表。
根目录时整段 hidden避免空栏占位。渲染端控制 .sidebar-breadcrumb[data-rel-dir] 与各段按钮 -->
<nav id="sidebar-breadcrumb" class="sidebar-breadcrumb" aria-label="当前目录" hidden>
<button id="btn-breadcrumb-root" type="button" class="sidebar-breadcrumb-root" title="返回数据文件夹根目录" aria-label="返回根目录">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12L12 4l9 8"></path>
<path d="M5 10v9h14v-9"></path>
</svg>
<span></span>
</button>
<span class="sidebar-breadcrumb-sep" aria-hidden="true">/</span>
<span id="sidebar-breadcrumb-trail" class="sidebar-breadcrumb-trail"></span>
</nav>
<ul id="file-list" class="file-list" role="listbox" aria-label="文件列表" tabindex="-1" data-empty="此文件夹为空">
<!-- 文件项由 file-list.js 注入 -->
</ul>
<div class="sidebar-footer">
<button type="button" id="link-open-folder" class="link-subtle footer-action" title="在系统文件管理器中打开数据文件夹">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span>打开数据文件夹</span>
</button>
<button type="button" id="btn-switch-folder" class="link-subtle footer-action" title="切换到其他数据文件夹">
<!-- folder + 双向 swap 箭头 (Lucide folder-sync 风格) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1-2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<polyline points="16 3 19 3 19 6"></polyline>
<polyline points="8 21 5 21 5 18"></polyline>
<line x1="19" y1="3" x2="13" y2="9"></line>
<line x1="5" y1="21" x2="11" y2="15"></line>
</svg>
<span>切换数据文件夹</span>
</button>
<!-- auto-fallback 2026-08「回到默认」按钮 —— 清空持久化的 dataDir 并切回 ~/Notes。
始终显示:用户切到非默认目录后想回到默认不需要先「切换」再选;救命的入口
不能藏在条件分支里。当已经在默认文件夹时按钮可点toast 提示「已在默认」),
复用现有 .link-subtle .footer-action 样式,不新增 CSS。 -->
<button type="button" id="btn-reset-folder" class="link-subtle footer-action" title="回到默认数据文件夹(~/Notes">
<!-- home + 屋顶(表达「回到原始默认」) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12l9-9 9 9"></path>
<path d="M5 10v10h14V10"></path>
</svg>
<span>回到默认</span>
</button>
<!-- 视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开 -->
<div class="sidebar-footer-sep" aria-hidden="true"></div>
<!-- AI 入口:放在侧栏最底,与顶部"主操作(新建笔记)→ 列表 → 排序 → 二级链接"形成层次。
默认沿用 .sidebar-new-btn 的描边主操作样式;展开后切到实心强调态(见 src/styles/11-ai.css 的 [aria-pressed="true"])。
AI 是「点开就弹,关闭就退」的命令式入口,与 settingsStore 无交集。 -->
<button id="btn-ai" class="sidebar-new-btn" type="button" title="AI 修改 (Ctrl+Shift+A)" aria-label="AI 修改" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2a4 4 0 0 0-4 4v1H6a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3h-2V6a4 4 0 0 0-4-4z"></path>
<circle cx="9" cy="13" r="1.2" fill="currentColor" stroke="none"></circle>
<circle cx="15" cy="13" r="1.2" fill="currentColor" stroke="none"></circle>
<path d="M9 17h6"></path>
</svg>
<span>AI 修改</span>
</button>
</div>
</aside>
<!-- 侧栏拖拽分割条(拖动调侧栏宽度)。
视觉宽度 4px命中区通过 -4px margin 扩到 12px。
内嵌 ↺ 按钮hover splitter 时显形,点击把侧栏恢复到 CSS 默认宽度220px
并写回 sidebarWidth=null 让下次启动也走默认。 -->
<div id="sidebar-splitter" class="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整侧栏宽度" aria-controls="sidebar" title="拖动调整侧栏宽度">
<button id="btn-sidebar-reset-width" class="sidebar-splitter-reset" type="button" title="恢复默认侧栏宽度" aria-label="恢复默认侧栏宽度" tabindex="-1">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path>
<polyline points="3 3 3 8 8 8"></polyline>
</svg>
</button>
</div>
<div id="editor-pane" class="editor-pane">
<!-- CodeMirror 挂载到这里 -->
</div>
<!-- fix(audit 2026-08)#splitter 加 ARIA separator role键盘用户能感知
可拖拽边界(与 #sidebar-splitter / #ai-splitter 一致) -->
<div id="splitter" class="splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整编辑器与阅读视图宽度" aria-controls="editor-pane viewer-main"></div>
<!--
重要CSS Grid auto-placement 按 DOM 顺序处理。
.app-ai (grid-column:5) / .ai-splitter (grid-column:6) 都是显式钉位,
.viewer 是 auto。
若 .viewer 出现在 .app-ai 之前DOM 处理时 .viewer 会先占 col 5
.app-ai 显式 col 5 与 .viewer 冲突、被挤到 row 2错位
所以 .app-ai / .ai-splitter 必须在 .viewer 之前。
此外 .app-ai 必须先于 .ai-splitter —— cursor 处理到 .app-ai (col 5)
后再前进到 col 6.ai-splitter (col 6) 顺势放下,最后 .viewer
auto 落到 col 7倒过来 .ai-splitter 先显式占 col 6.app-ai 再
显式占 col 5cursor 被推到 col 7.viewer 反过来覆盖 .app-ai、
把 .app-ai 挤到 row 2实测验证
-->
<!--
AI 修改差异中间面板:
- 默认 hidden由 ai-controller 切换 body.dataset.aiDiffOpen 控制可见性
- 渲染走 ai-diff-panel.js与 viewer 共享 markdown-body 渲染管线
- 必须在 #ai-splitter 之前CSS Grid auto-placement 按 DOM 顺序处理,
app-ai 显式 grid-column:5 必须先于 viewer 处理,详见 splitter 上方注释)
-->
<aside id="app-ai" class="app-ai" hidden aria-label="AI 修改预览"></aside>
<!--
AI 拖动分割条(拖动调整 AI 中间面板宽度)。
与 #splitter / #sidebar-splitter 同设计:视觉 4px命中区由 margin 扩展。
由 body[data-ai-diff-open="true"] 显示;与 .app-ai 紧邻。
- 必须在 .app-ai 之后、.viewer 之前CSS Grid auto-placement
app-ai col 5 放好后ai-splitter col 6 用光 row 1 的中段,
viewer 才能 auto 落到 col 7倒过来顺序会让 ai-splitter 把 col 5
让给 viewerapp-ai 显式 col 5 与 viewer 冲突、被挤到 row 2
-->
<div id="ai-splitter" class="ai-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整 AI 面板宽度" aria-controls="app-ai" title="拖动调整 AI 面板宽度" hidden></div>
<main class="viewer" id="viewer-main" tabindex="-1">
<article id="markdown-body" class="markdown-body" hidden>
<!-- markdown 渲染输出 -->
</article>
<div id="empty-state" class="empty-state">
<div class="empty-state-icon">
<svg aria-hidden="true" viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="9" y1="13" x2="15" y2="13"></line>
<line x1="9" y1="17" x2="15" y2="17"></line>
</svg>
</div>
<p>选择一个 Markdown 文件开始阅读</p>
<p class="hint">
<code>.md</code> 文件放入数据文件夹后,<br>
会自动出现在左侧列表。
</p>
<p class="hint hint-secondary">左下角可切换数据文件夹,或在系统文件管理器中打开。</p>
</div>
</main>
</div>
<!--
AI 对话输入面板(底部 dock
- 位于 body grid 第 3 行app-shell 与 statusbar 之间),
由 03-base.css 的 .ai-chat-dock { grid-row: 3 } 显式钉位
- 默认 hidden点工具栏 btn-ai 展开;展开时挤占 app-shell 高度,
状态栏自动让位、始终钉底
- 与中部 diff 面板联动diff 由 ai-controller 写入 .app-ai
-->
<div class="ai-chat-dock" id="ai-chat-dock" hidden aria-label="AI 修改输入">
<div class="ai-chat-form-row">
<button class="ai-chat-toggle" type="button" title="关闭 AI 面板" aria-label="关闭 AI 面板" aria-controls="ai-chat-dock">×</button>
<div class="ai-chat-input-wrap">
<input
type="text"
class="ai-chat-input"
placeholder="描述你想如何修改当前文档..."
aria-label="AI 修改提示"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
</div>
</div>
<!-- 状态栏3 段式(路径弱化 / 字数核心 / 模式 + 进度 + 文件数)
segment 之间用 .status-spacer 推开,让眼睛先扫到核心信息 -->
<footer class="statusbar">
<div class="status-segment status-segment-left">
<button id="status-path" class="status-path status-path-clickable" type="button" title="在文件管理器中显示" aria-label="在文件管理器中显示当前文件"></button>
<span class="status-divider"></span>
<span id="status-meta" class="status-meta"></span>
</div>
<div class="status-spacer"></div>
<div class="status-segment status-segment-center">
<span id="status-stats" class="status-chip status-chip-stats" hidden></span>
<span id="status-heading" class="status-heading" hidden></span>
</div>
<div class="status-spacer"></div>
<div class="status-segment status-segment-right">
<span id="status-scroll" class="status-chip status-chip-muted" hidden>0%</span>
<button id="status-mode" class="status-chip status-chip-clickable status-chip-mode" data-mode="preview" type="button" title="切换视图模式 (Ctrl+E)" aria-label="切换视图模式(当前:预览)">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
<span id="status-mode-label">预览</span>
</button>
<button id="status-ai" class="status-chip status-chip-clickable status-chip-ai" data-ai-state="idle" type="button" title="AI 修改" aria-label="AI 修改" aria-live="polite">
<span id="status-ai-label" class="status-chip-ai-text">AI</span>
</button>
<!-- 未保存指示器prefers-reduced-motion 下也保留,比 toolbar is-dirty 按钮更显眼)
aria-live=polite 让屏幕阅读器在 dirty 状态切换时朗读「未保存」Phase N a11y 修复)。-->
<span id="status-unsaved" class="status-chip status-chip-unsaved" hidden aria-live="polite">未保存</span>
<span class="status-divider"></span>
<span id="status-count" class="status-count"></span>
</div>
</footer>
<!-- Toast 容器 -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<!-- 设置对话框模板(由 settings-dialog.js 复用填充) -->
<div id="modal-root"></div>
<!-- 聚焦模式退出提示:鼠标悬停时显出,按 Ctrl+Shift+F 退出 -->
<div class="focus-exit-hint" aria-hidden="true"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd> 退出聚焦</div>
<script type="module" src="src/app.js"></script>
</body>
</html>

2461
main.js Normal file

File diff suppressed because it is too large Load Diff

938
main/ai.js Normal file
View File

@@ -0,0 +1,938 @@
// 主进程 AI 代理
// ============================================================================
//
// 通过 Node 18+ 内置 fetch 调用 LLM API。两条协议分支
// - aiProvider === 'openai'(默认)→ POST {baseURL}/chat/completions
// Authorization: Bearer ...
// - aiProvider === 'anthropic' → POST {baseURL}/v1/messages
// x-api-key: ... + anthropic-version
//
// 不使用 openai / @anthropic-ai SDK避免引入依赖统一在主进程发起
// 隐藏 apiKey统一错误结构供 renderer 弹 toast
//
// 设计要点:
// - 每个请求用一个 requestId 追踪;调用方通过 ai:cancel IPC 终止
// - 配置provider / baseURL / apiKey / model / systemPrompt
// configStore.getConfig() 现读现用,用户改完设置不需要重启
// - 错误统一返回 { ok:false, error, message }error 是稳定的错误码字符串,
// message 是用户能看的中文
// - API Key 不写日志;请求/响应调试时只用长度 + 状态码
//
// 协议差异(两个分支各自完整处理;共享的 cancel / 校验 / 响应后处理 抽到 helper
// 请求体:
// OpenAI : { model, temperature, max_tokens, max_completion_tokens,
// messages: [{role, content}], stream:false }
// Anthropic : { model, max_tokens, system, messages: [{role, content}], ... }
//
// 响应体:
// OpenAI : choices[0].message.content (string)
// choices[0].finish_reason === 'length' 截断
// Anthropic : content[].text (拼接所有 text 块)
// stop_reason === 'max_tokens' 截断
// ============================================================================
'use strict';
const CURRENT_FILE_EDIT_SYSTEM_PROMPT =
'你是 Markdown 文本助手。输入 JSON 格式含 filename、currentMarkdown、userPrompt。请按 userPrompt 修改 currentMarkdown然后直接输出 JSON 格式:{"content":"完整 Markdown"}。如果 userPrompt 与文档修改无关,或者是 {"content":"完整 Markdown"} 内容和 currentMarkdown 完全一致,直接简单回复即可,不需要回复 {"content":"完整 Markdown"}。';
const DEFAULT_TIMEOUT_MS = 300_000;
// 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。
// 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。
const MAX_CONTENT_BYTES = 1_500_000;
// 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。
// 5 MB 对正常 AI 回复(几十 KB 到 1 MB足够宽松。
const MAX_RESP_BYTES = 5 * 1024 * 1024;
// tryParseJson 输入硬上限getJsonCandidates 里的 fenced block 正则 +
// indexOf/lastIndexOf/slice 在 5 MB 字符串上仍有可观 CPU 占用,
// 且对对抗性输入(无闭合 ```)会让非贪婪量词扫到尾;提前砍掉尾巴,
// 让 JSON 解析失败直接走 extractRawReply 的回退路径。
const MAX_PARSE_INPUT_BYTES = 2_000_000;
// Anthropic API 当前稳定版本2023-06-01 之后未再变)
const ANTHROPIC_API_VERSION = '2023-06-01';
// audit fix (CQ-MED-7):错误码字面值与 renderer 端共享。preload 经 contextBridge
// 把同一份 AI_ERROR 暴露到 window.api.aiErrorsmain / renderer 永远引用同一对象,
// 不再靠注释提醒同步。
const { AI_ERROR } = require('../shared/ai-errors.js');
const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED;
const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT;
const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER;
const ERR_FORMAT = AI_ERROR.ERR_FORMAT;
const ERR_CANCELLED = AI_ERROR.ERR_CANCELLED;
const TRUNCATED_MESSAGE = 'AI 修改结果不完整,请缩小文档或简化要求后重试。';
/**
* 把超长输入砍到上限内(按 UTF-8 字节。MAX_PARSE_INPUT_BYTES 之外的尾部
* 在多数 AI 模型回复里没有意义(远早于 JSON 边界)—— 直接截掉既防 ReDoS
* 又让正则 / indexOf 不再 O(n²) 退化。
* @param {string} message
* @returns {string}
*/
function capForParse(message) {
const s = String(message || '');
if (Buffer.byteLength(s, 'utf8') <= MAX_PARSE_INPUT_BYTES) return s;
// 按字符截可能切到 UTF-8 序列中间;用 Buffer 切字节再转回字符串,
// 最后若尾部半个 multi-byte 用 toString('utf8') 会被替换成 U+FFFD
// 但 JSON.parse 会立即抛 SyntaxError → 由 tryParseJson 的 catch 兜底。
return Buffer.from(s, 'utf8').subarray(0, MAX_PARSE_INPUT_BYTES).toString('utf8');
}
/**
* 提取 AI 消息中的 JSON 候选trimmed 原文 / 三反引号代码块 / 第一个 { 到最后一个 }。
* @param {string} message
* @returns {string[]}
*/
function getJsonCandidates(message) {
const trimmed = capForParse(message).trim();
const candidates = [trimmed];
const fencedBlock = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (fencedBlock && fencedBlock[1]) {
candidates.push(fencedBlock[1].trim());
}
const jsonStart = trimmed.indexOf('{');
const jsonEnd = trimmed.lastIndexOf('}');
if (jsonStart !== -1 && jsonEnd > jsonStart) {
candidates.push(trimmed.slice(jsonStart, jsonEnd + 1));
}
return [...new Set(candidates)];
}
function tryParseJson(message) {
for (const candidate of getJsonCandidates(message)) {
try {
return JSON.parse(candidate);
} catch {
// continue
}
}
return undefined;
}
/**
* 在 message 中找第一个花括号配对的 JSON 对象,并把对象文本截出来。
* 简单字符串扫描 —— O(n) 处理嵌套 `{` `}` 和字符串字面量(避免 JSON 内容里的
* 引号 / 反斜杠把花括号配对误判)。找不到配对返回 null。
*
* 与 getJsonCandidates 的 brace-pair 切片不同:这里还要在体内识别字符串里的
* 转义序列(`\"` / `\\`),因此专写一个实现而非复用 indexOf/lastIndexOf。
*
* @param {string} s
* @returns {string|null}
*/
function extractFirstJsonObject(s) {
const str = String(s || '');
const len = str.length;
let start = -1;
let depth = 0;
let inStr = false;
let escape = false;
for (let i = 0; i < len; i++) {
const ch = str[i];
if (inStr) {
if (escape) { escape = false; continue; }
if (ch === '\\') { escape = true; continue; }
if (ch === '"') { inStr = false; }
continue;
}
if (ch === '"') { inStr = true; continue; }
if (ch === '{') {
if (start < 0) start = i;
depth++;
continue;
}
if (ch === '}') {
if (depth === 0) continue;
depth--;
if (depth === 0 && start >= 0) return str.slice(start, i + 1);
}
}
return null;
}
function looksLikeEditJson(message) {
// audit fix之前的 /"content"\s*:/ 太宽松 —— 用户提示词里只要含 `"content":`
// 子串就会被当作「AI 改稿的 JSON 截断」,误报 TRUNCATED_MESSAGE。
// 第二版 `/\{[^{}]*"content"\s*:[^{}]*\}/` 又过紧 —— 当 content 字段后跟随嵌套
// 对象(例:`{"content":"x","patches":[{"op":"replace"}]}`)时 `[^{}]*` 立刻失配,
// AI 返回"看似想输出 JSON 但 token 不够截断"的场景下TRUNCATED 提示被静默吃掉。
//
// 现在走 extractFirstJsonObject 取出第一个配对的 JSON 对象文本,再在体内找
// `"content"` 键(允许值跨多行、允许嵌套、字符串里的 " 不会干扰)。
// —— 简单纯文本("please edit {content} now")也不会命中,因为:
// 1. 字符串里的花括号不算嵌套对象起点inStr 分支已处理);
// 2. 真正配对的对象才走 key 搜索。
//
// 截断的 JSON花括号未闭合 / 数组未闭合extractFirstJsonObject 找不到配对对象,
// 这里直接返回 false —— normalizeAssistantText 会走 raw 回退把残文本返回给用户。
// 用户能看到 AI 输出了什么,比直接弹"不完整"更直观。截断信号应当由上游
// finish_reason=length / stop_reason=max_tokens 在更早的路径触发,不依赖正文配对。
const obj = extractFirstJsonObject(message);
if (!obj) return false;
// 体内 key 检测:用 `"content"` 加 `:` 兜住常见间距(`"content" :` / `"content":`
// 不复用 contains('"content"') 是为了避免匹配键名包含 content 子串的字段
// (如 `"mycontent":1` —— 但这种情况极少见,多一道正则更稳)。
return /"content"\s*:/.test(obj);
}
/**
* 从非 JSON 解析得到的对象里挑 reply/message/text/answer 字段当作纯文本。
* @param {unknown} parsed
* @returns {string | null}
*/
function extractRawReply(parsed) {
if (!parsed || typeof parsed !== 'object') return null;
for (const key of ['reply', 'message', 'text', 'answer']) {
const value = /** @type {Record<string, unknown>} */ (parsed)[key];
if (typeof value === 'string') return value;
}
return null;
}
/**
* 拼接 baseURL + path自动处理末尾斜杠。
* @param {string} base
* @param {string} path
* @returns {string}
*/
function joinUrl(base, path) {
// P3-3 fix (audit):拆分 query / fragment 后再拼。
// 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages`
// query 后被拼了 pathURL 非法。query / fragment 也可能在错误回显时
// 包含 api_key 之类敏感 token所以一并禁止放在 baseURL 里。
// M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo`https://user:pass@host`)。
// fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务,
// 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。
const s = String(base || '');
const m = s.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
if (!m) return s;
let b = m[1].replace(/\/+$/, '');
b = b.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1');
const p = String(path || '').replace(/^\/+/, '');
return `${b}/${p}${m[2] || ''}${m[3] || ''}`;
}
/**
* 过滤错误回显里的敏感 tokenOpenAI/Anthropic/proxy 可能在错误信息里
* echo URL 或 header。API Key 不该出现在用户能看到的 toast 里。
* P2-2 fix (audit)Q3 fix (audit):补 Google API key / JWT / proxy-authorization。
* @param {string} detail
* @returns {string}
*/
function sanitizeDetail(detail) {
return String(detail || '')
// OpenAI / 通用 OpenAI 风格 keysk-xxx / sk-proj-xxx / sk-ant-xxx
// M3 fix (audit):阈值 8 → 5捕获被截断的 key错误回显常见前缀模式 sk-12ab...)。
// 5 是保守下限:正常文本里 5 位随机 base64url 不常见
.replace(/sk-[A-Za-z0-9_-]{5,}/g, '[API_KEY]')
// Google API keyAIzaSy 开头 + 33 字符。33 是 Google 当前规范
.replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]')
// JWTheader.payload.signature 三段 base64url至少各 8 字符避免误伤短词
.replace(/eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[JWT]')
// M3 fix (audit):先单独处理 JSON 风格 `"authorization": "Bearer sk-xxx"`——
// 旧正则 `[^\s,;&}"']+` 遇到 `"` 就停,只盖到 `"Bearer`,剩余 ` sk-xxx"`
// 直接漏到下一步被文本截断逻辑保留。现在用单独 pattern 一次性吃掉整段带引号的值。
// audit fix (Phase O-L19):扩到 `authentication` / `www-authenticate` / `cookie` /
// `set-cookie` —— 自部署中转 / Azure gateway 偶尔回 `Authentication: Bearer sk-xxx`
// LLM 自定义代理常通过 cookie 传 key。原始 key 不会泄露line 229 的 sk-/AIzaSy
// 兜底会替成 [API_KEY]),但 header 名这一行会在 detail 文本里残留。
.replace(/("(?:proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"')
// 任意 header 里出现敏感 token —— 含 proxy-authorizationQ3 audit 新增)
// 不再排除 `"` 和 `'`:让正则跨过引号吃掉值(与 JSON pattern 互补,命中 form-style
.replace(/(proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)\s*[:=]\s*[^\s,;&}]+/gi, '$1=[REDACTED]')
// URL query 或 form body 里的 key/token
.replace(/(api[_-]?key|token)\s*=\s*[^\s,;&}]+/gi, '$1=[REDACTED]');
}
/**
* 把 URL 中可能携带 secret 的部分脱敏再写到日志。
* 1. 去掉 query 和 fragment用户 baseURL 不该带 ?api_key=xxx / #fragment但有人会带
* 2. path 段里嵌入的 secret tokenOpenAI sk-xxx / Google AIzaSy也遮罩
* 保留 scheme + host + path便于调试"请求打到哪个域名",但不带任何 secret。
* Q6 fix (audit)。
* @param {string} raw
* @returns {string}
*/
function sanitizeUrl(raw) {
return String(raw || '')
// M2 fix (audit):先剥 userinfohttps://user:pass@host否则 fetch Basic Auth
// 凭据会被完整写到错误日志(即使 query/fragment 已剥userinfo 仍在 host 前)
.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1')
.replace(/[?#].*$/, '') // 去 query / fragment
.replace(/\/(sk-[A-Za-z0-9_-]{8,})/g, '/[REDACTED]') // path 段里的 OpenAI key
.replace(/\/(AIzaSy[A-Za-z0-9_-]{20,})/g, '/[REDACTED]'); // path 段里的 Google key
}
/**
* 读 response body 到字符串,超过 MAX_RESP_BYTES 立刻中断。
* P2-3 fix (audit)。
* @param {Response} res
* @returns {Promise<{ ok:true, text:string } | { ok:false, message:string }>}
*/
async function readBodyWithLimit(res) {
if (!res.body || typeof res.body.getReader !== 'function') {
// 旧版 fetch / mockfallback 到 .text(),上限由 .text() 自带的内存限制兜底
try {
const text = await res.text();
if (Buffer.byteLength(text, 'utf8') > MAX_RESP_BYTES) {
return { ok: false, message: 'AI 响应体过大' };
}
return { ok: true, text };
} catch {
return { ok: false, message: '读取 AI 响应失败' };
}
}
const reader = res.body.getReader();
/** @type {Buffer[]} */
const chunks = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_RESP_BYTES) {
try { reader.cancel(); } catch { /* ignore */ }
return { ok: false, message: 'AI 响应体过大(超过 5 MB' };
}
chunks.push(Buffer.from(value));
}
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
}
/**
* 创建主进程 AI 代理。
* @param {object} deps
* @param {() => object} deps.getConfig - 取最新设置configStore.getConfig每次 runEdit 都会调用
* @param {typeof fetch} [deps.fetchImpl] - 注入 fetch测试用
* @param {(key: string, payload: object) => void} [deps.log] - 日志钩子(不记录 apiKey
*/
function createAiProxy({ getConfig, fetchImpl, log } = {}) {
if (typeof getConfig !== 'function') {
throw new Error('[ai] getConfig 必须是函数');
}
const fetchFn = fetchImpl || ((...args) => fetch(...args));
const logFn = typeof log === 'function' ? log : () => {};
/** @type {Map<string, AbortController>} */
const pending = new Map();
/** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */
const timers = new Map();
/** audit fix (C4)请求生命周期登记集合runEdit 入口 → finally用于
* 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的,
* runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */
const inFlightRequestIds = new Set();
function cancel(requestId) {
const controller = pending.get(requestId);
const timer = timers.get(requestId);
if (controller) {
controller.abort();
pending.delete(requestId);
}
if (timer) {
clearTimeout(timer);
timers.delete(requestId);
}
}
function cancelAll() {
for (const controller of pending.values()) controller.abort();
pending.clear();
// C1 (audit):与 cancel() 一样同步清理 timeout避免 setTimeout 回调空转
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
}
/**
* 校验 baseURL 是否为合法 http(s) URL。
* 防止 javascript: / file: / data: 等伪协议触发 fetch TypeError 后报成"网络错误"误导用户。
* M1 (audit):旧版只走 fetch 抛错,错误信息不友好。
* @param {string} base
* @returns {{ok:true, value:string} | {ok:false, reason:string}}
*/
function validateBaseUrl(base) {
const s = String(base || '').trim();
if (!s) return { ok: false, reason: 'Base URL 不能为空' };
if (!/^https?:\/\//i.test(s)) {
return { ok: false, reason: 'Base URL 必须以 http:// 或 https:// 开头' };
}
// P2 fixhostname 校验 —— 之前只校验前缀,`https:///etc/passwd`(缺少 host
// / `https:// host`(带空格)会被放行,然后 fetch 抛「ENOTFOUND / Invalid URL」
// 但错误信息毫无线索。new URL 直接拒绝这些畸形输入,给出可执行反馈。
try {
const parsed = new URL(s);
if (!parsed.hostname) {
return { ok: false, reason: 'Base URL 缺少主机名' };
}
// 主机名不能包含空白字符或 ASCII 控制字符(防御一些浏览器容忍的奇怪输入)
// eslint-disable-next-line no-control-regex -- 控制字符范围是刻意检查的非法字符
if (/[\s\x00-\x1f]/.test(parsed.hostname)) {
return { ok: false, reason: 'Base URL 主机名包含非法字符' };
}
} catch (e) {
return { ok: false, reason: 'Base URL 不是合法 URL' + (e && e.message || '') };
}
return { ok: true, value: s };
}
/**
* 按模型名路由 token 上限 + token 字段。
* H1 (audit)65536 远超多数模型上限gpt-3.5=4096, gpt-4=8192, gpt-4o=16384
* 服务端可能直接 400 拒绝或抛 invalid_request_error。
* H2 (audit)gpt-5 / o-series 只接受 max_completion_tokens旧字段会触发
* "Unsupported parameter" 错误;老模型反过来——只接受 max_tokens。
* H5 (audit)gpt-5 / o-series 不接受自定义 temperatureo1 固定为 1
* 发 0.2 会 400 invalid_request_error。
* @param {string} model
* @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }}
*/
function pickOpenAITokenConfig(model) {
const m = String(model || '').toLowerCase();
// gpt-5 / o-series → 必须用 max_completion_tokens上限通常 ≥ 128k不传 temperature
if (/^(gpt-5|o1|o3|o4)/.test(m)) {
return { capTokens: 32_000, tokenField: 'max_completion_tokens', includeTemperature: false };
}
// gpt-4o / 4-turbo → max_tokens上限 16k
if (/^gpt-4o/.test(m) || /^gpt-4-turbo/.test(m)) {
return { capTokens: 16_384, tokenField: 'max_tokens', includeTemperature: true };
}
// 普通 gpt-4 → 8k
if (/^gpt-4/.test(m)) {
return { capTokens: 8_192, tokenField: 'max_tokens', includeTemperature: true };
}
// gpt-3.5 → 4k
if (/^gpt-3\.5/.test(m)) {
return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true };
}
// 未知模型(包括国产中转、自部署):保守值 + 老字段,最大限度兼容
return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true };
}
/**
* Anthropic 模型 max_tokens 上限路由。
* H1 (audit)Anthropic 不同模型上限差异很大(旧 haiku 4096 / sonnet-3-5 8192
* 65536 会被这些老模型 400 拒绝。
*
* audit fix (round-13)补齐「5 系」与 4.6+ 命名。
* 之前的正则 `claude-(3-5|3\.5|3-7|sonnet-4|opus-4|4)` 要求 `claude-` 后面
* 紧跟这些片段,于是 claude-opus-5 / claude-sonnet-5 / claude-fable-5 /
* claude-haiku-4-5 全部不命中 → 回退 8192反而比 claude-opus-4-816384
* 更低。结果是「越新、输出上限越高的模型,拿到的 max_tokens 越小」:
* 长笔记改写会在 8k 处被截断 → stop_reason: max_tokens → runEdit 走
* TRUNCATED_MESSAGE 分支报「AI 修改结果不完整」。这正是 Phase O-L17
* 想修掉的那类 bug只是模型命名又演进了一代。
*
* 现行分档(保守取值,远低于官方上限,避免中转/自部署网关拒绝):
* - 5 系 + 4.6/4.7/4.8(官方 max output 128k→ 32k
* - 其余 4 系(含 haiku-4-5+ 3-5/3-7 → 16k
* - claude-3 老家族 → 8k
* - 未知模型(国产中转 / 自部署)→ 8k最大限度兼容
* @param {string} model
* @returns {number}
*/
function pickAnthropicTokenConfig(model) {
const m = String(model || '').toLowerCase();
// 5 系opus-5 / sonnet-5 / fable-5 / mythos-5与 4.6+ → 官方 128k 上限
if (/claude-(opus|sonnet|fable|mythos)-5/.test(m)) return 32_000;
if (/claude-(opus|sonnet)-4-(6|7|8)/.test(m)) return 32_000;
// 其余 4 系sonnet-4-5 / opus-4-5 / haiku-4-5 / claude-4-*+ 3-5 / 3-7
if (/claude-(3-5|3\.5|3-7|sonnet-4|opus-4|haiku-4|4)/.test(m)) return 16_384;
if (/claude-3/.test(m)) return 8_192;
return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff
}
/**
* 抽象的 HTTP 调用 + 超时 + 取消 + 状态码错误处理。
* 不解析业务响应OpenAI / Anthropic 各自的 JSON 结构在调用方处理)。
*
* @param {{
* requestId: string,
* url: string,
* headers: Record<string, string>,
* body: object,
* timeoutMs: number,
* }} args
* @returns {Promise<{ ok: true, json: any } | { ok: false, error: string, message: string }>}
*/
async function postJson({ requestId, url, headers, body, timeoutMs }) {
const controller = new AbortController();
pending.set(requestId, controller);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
timers.set(requestId, timer);
// audit fix所有 exit 路径必须清理两个 Map否则长时间使用会泄漏
// 内存(之前修复 cancelAll 只清理了 Map 没在 postJson 内清理 entry
// 现在统一走 helper 确保不遗漏)。
//
// 关键cleanup 只清理「自己的」controller / timer不能按 requestId 无脑
// delete。如果 renderer 在我们 await fetchFn 的间隙用同一个 requestId 发起
// 了新请求pending.has → cancel → 新 postJson新请求会重新
// pending.set(requestId, newController)。随后旧 cleanup 在 microtask 阶段
// 触发 pending.delete(requestId),会把新请求的 controller 从 Map 里抹掉,
// 导致新请求无法单独 cancel。比对 identity 再 delete 即可解决。
function cleanup() {
clearTimeout(timer);
if (pending.get(requestId) === controller) pending.delete(requestId);
if (timers.get(requestId) === timer) timers.delete(requestId);
}
let res;
try {
// audit fix (Round 13 / Sec-H3)redirect: 'manual' 阻止 undici 跟随 3xx
// 重定向到不同 origin 时复传自定义头。fetch 规范只会在 CORS 非通配头
// 集合里自动剥 `Authorization`,但 Anthropic 用的是 `x-api-key`(自定义头),
// 不在脱敏名单里 —— 用户配置的中转 / 第三方网关一旦答 302 到攻击者域,
// `x-api-key: sk-ant-...` 和当前笔记全文都会被转发出去。
//
// 用 manual 后拿到的是 opaqueredirect 类型的 Responsestatus 0、body 不可读;
// 我们在下文按 status === 0 / type === 'opaqueredirect' 显式报错给用户。
// 不支持 redirect 的真 endpoint 不会触发这条分支2xx/4xx/5xx 都不是 3xx
res = await fetchFn(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
redirect: 'manual',
});
} catch (e) {
cleanup();
if (controller.signal.aborted) {
if (timedOut) {
return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' };
}
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
const msg = e instanceof Error ? e.message : String(e);
if (/abort/i.test(msg)) {
return { ok: false, error: timedOut ? ERR_TIMEOUT : ERR_CANCELLED, message: timedOut ? 'AI 请求超时,请缩小文档、简化要求或重试' : '已取消 AI 请求' };
}
// M1 fix (audit):网络错误信息里也可能携带 api keyfetch 库 / DNS 错误里偶尔
// 会回显 URL 或 header统一走 sanitizeDetail 防止泄露到 toast / 日志
return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` };
}
cleanup();
// audit fix (Round 13 / Sec-H3)3xx 重定向在 manual 模式下表现为 status=0
// 且 type='opaqueredirect'。明示用户配置错了 Base URL不要走"读 body 取错误信息"
// 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。
if (res.status === 0 || res.type === 'opaqueredirect') {
return {
ok: false,
error: ERR_PROVIDER,
message: 'Base URL 发生了重定向,请直接填写最终地址(出于 API Key 安全考虑Notes 不会自动跟随重定向)',
};
}
if (!res.ok) {
const status = res.status;
let detail = '';
let errBody = null;
// P2-3 fix (audit):用 readBodyWithLimit 限制响应体大小,防止 OOM
// 审计修复 (Round 11 deep-fix P1-2):在 readBodyWithLimit 抛 AbortError 时
// (用户取消)不要静默走到下面 → bodyRead.ok === false 时仍正常报错 OK
// 但 reader 自身抛错会冒到 catch。检查 controller.signal.aborted 走「已取消」。
let bodyRead;
try {
bodyRead = await readBodyWithLimit(res);
} catch (e) {
if (controller && controller.signal && controller.signal.aborted) {
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
throw e;
}
if (!bodyRead.ok) {
// Q6 fix (audit)url 写入日志前 sanitizeUrl去掉 query / fragment / path secret
logFn('ai:http_error', { url: sanitizeUrl(url), status, detail: bodyRead.message });
return { ok: false, error: ERR_FORMAT, message: bodyRead.message };
}
const text = bodyRead.text;
if (text) {
// 尝试解析为 JSON 取 error.message不成功则把原文截断用作 detail
try {
const parsed = JSON.parse(text);
errBody = parsed;
if (parsed && parsed.error && typeof parsed.error.message === 'string') {
detail = parsed.error.message;
}
} catch {
// ignore
}
if (!detail) detail = text.length > 200 ? `${text.slice(0, 200)}` : text;
}
// P2-2 fix (audit):过滤 detail 里的 api key / 敏感 token 后再 log + 回显
detail = sanitizeDetail(detail);
// Q6 fix (audit)url 写入日志前 sanitizeUrl去掉 query / fragment / path secret
logFn('ai:http_error', { url: sanitizeUrl(url), status, detail });
if (status === 401 || status === 403) {
return { ok: false, error: ERR_PROVIDER, message: 'API Key 无效或没有权限' };
}
if (status === 404) {
return { ok: false, error: ERR_PROVIDER, message: 'Base URL 或模型不存在' };
}
if (status === 408 || status === 504) {
return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' };
}
if (status === 429) {
return { ok: false, error: ERR_PROVIDER, message: '请求过于频繁,请稍后重试' };
}
if (status === 400 && errBody && errBody.error && errBody.error.type === 'invalid_request_error') {
// audit fix (Round 8 A-1):这里过去用的是 `errBody.error.message` 原文,
// 绕过了上面 line 525 的 sanitizeDetail —— 而 detail 正是同一个字符串
// 脱敏后的版本。自部署网关Azure / LiteLLM / 各类中转)在
// invalid_request_error.message 里 echo 请求头或请求体的情况很常见,
// 一旦回吐 `Bearer sk-...` 就会原样进 toast。改用已脱敏的 detail。
return { ok: false, error: ERR_PROVIDER, message: `请求参数错误:${detail || '服务端未提供详情'}` };
}
if (status >= 500) {
// audit fix (M3 regression)500 也带 sanitized detail
// 让上游服务器把真实错误("invalid token: sk-xxx")回吐时,
// 用户能从 toast 看到「凭据有问题」而不是一句空泛的"服务不可用"。
// detail 已经走过 sanitizeDetailkey 类 token 已经被 [API_KEY] 替换。
return {
ok: false,
error: ERR_PROVIDER,
message: `AI 服务暂时不可用HTTP ${status}${detail ? '' + detail : ''}`,
};
}
return { ok: false, error: ERR_PROVIDER, message: `AI 请求失败HTTP ${status}${detail ? '' + detail : ''}` };
}
/** @type {any} */
let json;
try {
// P2-3 fix (audit):成功路径也走大小限制(恶意 / 错误配置的服务端可能
// 对 200 也返回大 body。用 text + JSON.parse 替代 res.json()。
const bodyRead = await readBodyWithLimit(res);
if (!bodyRead.ok) return { ok: false, error: ERR_FORMAT, message: bodyRead.message };
json = JSON.parse(bodyRead.text);
} catch (e) {
// 审计修复 (Round 11 deep-fix P1-2):用户取消时 readBodyWithLimit 内部
// reader.cancel() 抛 AbortError外层 catch 之前把它误判成 "不是合法 JSON"
// → UI 看到误导的格式错误。先检查 controller.signal.aborted 走「已取消」分支。
if (controller && controller.signal && controller.signal.aborted) {
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
const msg = e instanceof Error ? e.message : String(e);
if (/abort/i.test(msg)) {
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' };
}
return { ok: true, json };
}
/**
* 把模型原始文本回复归一化成 { content, responseFormat }
* - 解析成 { content: string } → responseFormat = 'json'(用于文档修改 diff
* - 其它情况 → responseFormat = 'raw'(普通对话回复)
*/
function normalizeAssistantText(message, originalContent) {
const parsed = tryParseJson(message);
if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') {
const next = parsed.content;
if (next === originalContent) {
return { content: '当前文档无需修改。', responseFormat: 'raw' };
}
return { content: next, responseFormat: 'json' };
}
// audit fix (shared-M13):如果 JSON 解析成功但 content 不是字符串(典型
// 是 [] 数组 / 对象 / null / 数字),不要按「截断」处理 —— 模型是按规矩
// 返回 JSON 对象的,只是 content 的形状不是我们约定的字符串。把 message
// 整体当 raw 回退返回给用户,至少他们能看到模型实际输出了什么,而不是
// 看到一个误导的「输出不完整,请重试」提示。
if (parsed && typeof parsed === 'object' && 'content' in parsed) {
return { content: message, responseFormat: 'raw' };
}
const rawReply = extractRawReply(parsed);
if (rawReply) return { content: rawReply, responseFormat: 'raw' };
if (looksLikeEditJson(message)) {
return null; // JSON 看起来想返回 {content:...} 但解析失败 —— 截断
}
return { content: message, responseFormat: 'raw' };
}
/**
* OpenAI 兼容分支POST {baseURL}/chat/completions。
*/
async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) {
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
const baseURL = String(config.aiBaseUrl || '').trim();
const apiKey = String(config.aiApiKey || '').trim();
const model = String(config.aiModel || '').trim();
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
if (!baseURL || !apiKey || !model) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' };
}
// M1 (audit):拒绝 javascript: / data: / file: 等伪协议,避免 fetch TypeError 报成"网络错误"
const urlCheck = validateBaseUrl(baseURL);
if (!urlCheck.ok) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
}
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 本地代理Ollama / LM Studio / vllm走 http://localhost / 127.0.0.1 / ::1
// 且不需要 Key —— apiKey 留空就过;这里有 apiKey 意味着第三方 API必须 HTTPS。
// 静默放行会让 Key 在网线上裸奔到攻击者嗅探点,错误必须显式。
// localhost/127.0.0.1/::1 仍然放过:本地抓包门槛远高于公网,本地代理是合法场景。
if (/^http:\/\//i.test(urlCheck.value) && apiKey) {
try {
const parsed = new URL(urlCheck.value);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (!isLoopback) {
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key拒绝请求以防泄露:', sanitizeUrl(urlCheck.value));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理localhost / 127.0.0.1)允许明文。',
};
}
} catch { /* validateBaseUrl 已校验过 URL这里兜底 */ }
}
const url = joinUrl(baseURL, 'chat/completions');
// H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒;
// 同时按模型名决定发 max_tokens 还是 max_completion_tokensgpt-5 / o-series 只接受后者)。
// o-series / gpt-5 也不接受自定义 temperatureo1 强制为 1发 0.2 会 400
const { capTokens, tokenField, includeTemperature } = pickOpenAITokenConfig(model);
/** @type {Record<string, any>} */
const body = {
model,
[tokenField]: capTokens,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }),
},
],
stream: false,
};
if (includeTemperature) body.temperature = 0.2;
logFn('ai:request', { provider: 'openai', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length });
const result = await postJson({ requestId, url, headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
}, body, timeoutMs });
if (!result.ok) return result;
const json = result.json;
const choice = json && Array.isArray(json.choices) ? json.choices[0] : null;
if (!choice) {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' };
}
if (choice.finish_reason === 'length') {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
if (choice.finish_reason === 'content_filter') {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回被服务方过滤,请调整要求后重试' };
}
const message = choice.message && typeof choice.message.content === 'string' ? choice.message.content : '';
if (!message) {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' };
}
const normalized = normalizeAssistantText(message, content);
if (normalized === null) {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat };
}
/**
* Anthropic 分支POST {baseURL}/v1/messages。
* system 单独字段(不在 messages 里messages 只有 user/assistant 两轮对话。
*/
async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) {
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
const baseURL = String(config.aiBaseUrl || '').trim();
const apiKey = String(config.aiApiKey || '').trim();
const model = String(config.aiModel || '').trim();
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
if (!baseURL || !apiKey || !model) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' };
}
// M1 (audit):同样校验协议前缀,与 OpenAI 分支一致
const urlCheck = validateBaseUrl(baseURL);
if (!urlCheck.ok) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
}
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 与 OpenAI 分支共用同样防御,本地代理不需要 Key留空即可。
if (/^http:\/\//i.test(urlCheck.value) && apiKey) {
try {
const parsed = new URL(urlCheck.value);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (!isLoopback) {
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key拒绝请求以防泄露:', sanitizeUrl(urlCheck.value));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理localhost / 127.0.0.1)允许明文。',
};
}
} catch { /* validateBaseUrl 已校验过 URL */ }
}
const url = joinUrl(baseURL, 'v1/messages');
// H1 (audit)Anthropic 各模型 max_tokens 上限不同,超额会 400
const capTokens = pickAnthropicTokenConfig(model);
const body = {
model,
max_tokens: capTokens,
system: systemPrompt,
messages: [
{
role: 'user',
content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }),
},
],
};
logFn('ai:request', { provider: 'anthropic', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length });
const result = await postJson({ requestId, url, headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': ANTHROPIC_API_VERSION,
}, body, timeoutMs });
if (!result.ok) return result;
const json = result.json;
// 拼接所有 type==='text' 的 content 块;忽略 tool_use / tool_result 等。
if (!json || !Array.isArray(json.content)) {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' };
}
if (json.stop_reason === 'max_tokens') {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
/** @type {string} */
let message = '';
let hasNonTextBlock = false;
for (const block of json.content) {
if (block && block.type === 'text' && typeof block.text === 'string') {
message += block.text;
} else if (block && block.type !== 'text') {
// audit fix (shared-M11)tool_use / tool_result / image 等非文本块
// 当前版本不消费,但若整条 response 只有这些块就给一个明确的中文
// 提示而不是误导用户「AI 返回了空内容」(实际是格式我们暂时不支持)。
hasNonTextBlock = true;
}
}
if (!message) {
// audit fix (shared-M11):把「模型只回了 tool_use / image 但没有正文」
// 与「模型真的没回东西」区分开 —— 后者才报「空内容」。
if (hasNonTextBlock) {
return {
ok: false,
error: ERR_FORMAT,
message: 'AI 返回了无法识别的内容(仅含 tool_use / 图像块,无文本回复)',
};
}
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' };
}
const normalized = normalizeAssistantText(message, content);
if (normalized === null) {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat };
}
/**
* 执行一次 AI 修改请求。
* @param {{ prompt:string, content:string, filename:string, requestId:string }} input
* @returns {Promise<{ ok:true, id:string, content:string, responseFormat:'json'|'raw' } | { ok:false, error:string, message:string }>}
*/
async function runEdit({ prompt, content, filename, requestId }) {
if (typeof prompt !== 'string' || !prompt.trim()) {
return { ok: false, error: 'INVALID_PROMPT', message: '修改要求不能为空' };
}
if (typeof content !== 'string') {
return { ok: false, error: 'INVALID_CONTENT', message: '当前文件内容无效' };
}
// 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估)
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
return {
ok: false,
error: 'CONTENT_TOO_LARGE',
message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB请拆分后再让 AI 修改`,
};
}
if (Buffer.byteLength(prompt, 'utf8') > 64_000) {
return {
ok: false,
error: 'PROMPT_TOO_LARGE',
message: '提示词过长(超过 64 KB请简化要求',
};
}
if (typeof filename !== 'string') {
filename = '';
}
if (typeof requestId !== 'string' || !requestId) {
requestId = `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
const provider = String(config.aiProvider || 'openai').trim().toLowerCase();
const timeoutMs = DEFAULT_TIMEOUT_MS;
// audit fix (C4):原 `if (pending.has(requestId)) cancel(requestId)` 是死代码 —
// pending 是 postJson 内部填的postJson 在 runEdit 异步返回之后才执行,
// runEdit 入口检查 pending 时它一定是空的,双击防护其实由 renderer 端
// ai-chat-panel 的 _submitting 守门。这里改成真正的 inFlightRequestIds
// 进入 runEdit 即登记(早于 postJsonfinally 清理,覆盖整个请求生命周期。
if (inFlightRequestIds.has(requestId)) {
cancel(requestId);
}
inFlightRequestIds.add(requestId);
// 注token 上限不再用入参 maxTokens已被 pick*TokenConfig 按模型路由取代)
const baseArgs = { prompt, content, filename, requestId, timeoutMs };
try {
if (provider === 'anthropic') {
return await runAnthropicEdit(baseArgs);
}
if (provider === 'openai') {
return await runOpenAIEdit(baseArgs);
}
// audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。
// schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`,
};
} finally {
// audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId
// 再来能正常进入;防 Set 缓慢增长。
inFlightRequestIds.delete(requestId);
}
}
return {
runEdit,
cancel,
cancelAll,
};
}
module.exports = {
createAiProxy,
// 共享错误码表 —— preload 经 contextBridge 把同一份 AI_ERROR 暴露到
// window.api.aiErrorsrenderer 直接 window.api?.aiErrors?.AI_ERROR。
// main/ai.js 内部仍用 ERR_* 命名别名line 59-63纯粹是阅读性无外部
// 调用方 —— 别名不出 module.exports避免「两个相同字面值漂移」风险。
AI_ERROR,
};

518
main/config-store.js Normal file
View File

@@ -0,0 +1,518 @@
// 配置持久化层Stage 4b.1 抽离)
//
// 职责:
// - 加载 / 保存用户配置userData/config.json
// - 解析「实际生效」的数据目录(用户自定义 vs 默认 ~/Notes
// - 首次启动把 data/welcome.md 种子化到默认数据目录
//
// 设计:
// - 本模块自给自足:不读 mainWindow / tray / IPC 等任何 main.js 状态。
// - 暴露单一对象 { load, save, get, resolveDataDir, seedDefault, getDefaultDataDir }
// - main.js 调一次 load() 拿到当前配置后,可随时通过 save() / get() 操作。
// - schema 校验 / 默认值仍由 shared/settings-schema.js 提供(单一事实源)。
const electron = require('electron');
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const os = require('os');
// rename 重试 backoff 阶梯ms—— Windows Defender / 杀毒 / 同步盘
// 会在极短时间内持锁目标文件,单次 rename 失败率约 1-3%。3 次 backoff
// 后仍失败才抛错(见 renameWithRetry
const RENAME_BACKOFF_MS = [50, 100, 200];
const {
DEFAULT_SETTINGS: DEFAULT_CONFIG,
coerceLoadedSettings,
} = require('../shared/settings-schema.js');
// audit fix (Round 12 P2)saveConfig 失败时的 e.message 直接走 friendly-fs-error
// 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到
// 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。
const { friendlyFsError } = require('../shared/friendly-fs-error.js');
// 测试注入点:默认走真实 electron.app单测可以换成 mock app。
// 下划线前缀表示「仅测试用」—— 生产代码不应调用。
let _app = electron && electron.app;
function getConfigPath() {
return path.join(_app.getPath('userData'), 'config.json');
}
/**
* 默认数据目录:用户主目录下的 Notes 子目录
* 跨平台统一(不像 Todo List 那样在 Windows 上探测 D: 盘)—— 这是阅读器,单用户跨平台直接可用。
*/
function getDefaultDataDir() {
try {
const home = _app.getPath('home');
return path.join(home, 'Notes');
} catch {
return path.join(os.homedir(), 'Notes');
}
}
let appConfig = {};
let DEFAULT_DATA_DIR = null;
let configLoaded = false;
function loadConfig() {
const cfgPath = getConfigPath();
let raw;
try {
raw = fsSync.readFileSync(cfgPath, 'utf-8');
} catch (e) {
// ENOENT 是「首次启动」,静默回退默认值是合理的;
// 其他错误(权限 / 磁盘坏道)也走默认值,但要在控制台留痕,方便用户反馈。
if (e.code !== 'ENOENT') {
console.warn('[config-store] 配置读取失败,使用默认值:', e.code || e.message);
}
appConfig = { ...DEFAULT_CONFIG };
cleanupStaleTmpFiles();
configLoaded = true;
return appConfig;
}
try {
const parsed = JSON.parse(raw);
appConfig = coerceLoadedSettings(parsed);
} catch (e) {
// 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json
// 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir
// 会无声丢失;下一次 saveConfig() 又会把默认值覆盖回去,损坏永久化。
// 现在把损坏的配置文件改名备份config.json.broken-<ts>),再走默认值。
// 用户在设置里重新填值后会写回新的 config.json备份留在旁边方便排查。
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${cfgPath}.broken-${ts}`;
try {
fsSync.renameSync(cfgPath, backupPath);
console.warn(`[config-store] config.json 解析失败 (${e.message}),已备份到 ${backupPath},使用默认值`);
} catch (renameErr) {
console.warn(`[config-store] config.json 解析失败且备份失败 (${renameErr.message}),使用默认值`);
}
appConfig = { ...DEFAULT_CONFIG };
}
// audit fix (4.4):清理上次异常退出留下的 config.json.tmp.* 残留。
// 正常路径下 saveConfig 会在 rename 成功后留下 0 个 tmprename 失败路径会立刻 unlink
// 但断电 / kill -9 会跳过 unlink长期积累成百上千个 tmp。
// 启动时扫一遍目录、删除自己的残留(不动当前 pid / 当前时间戳的,避免误删正在写的)。
cleanupStaleTmpFiles();
configLoaded = true;
return appConfig;
}
/**
* 删除数据目录里残留的 config.json.tmp.* 文件。
* 只删自己 pid 的(其它进程残留不碰,避免多开实例互相影响)。
*/
function cleanupStaleTmpFiles() {
try {
const dir = path.dirname(getConfigPath());
const files = fsSync.readdirSync(dir);
const myPid = process.pid;
const now = Date.now();
const STALE_THRESHOLD_MS = 60_000; // 60s 之前留下的才视为残留(避免误删正在写的)
for (const f of files) {
if (!/^config\.json\.tmp\./.test(f)) continue;
const full = path.join(dir, f);
try {
const st = fsSync.statSync(full);
// 自己的 pid + 超过 60s 前 → 视为残留;跨 pid 不动(不归本进程管)
const isMine = f.includes(`.${myPid}.`);
const ageMs = now - st.mtimeMs;
if (isMine && ageMs > STALE_THRESHOLD_MS) {
fsSync.unlinkSync(full);
}
} catch { /* 单个文件 stat/unlink 失败不影响整体 */ }
}
} catch {
// readdir 失败(目录不存在等)静默 —— 配置读不到已 try/catch 兜过
}
}
/**
* 保存(部分)配置到磁盘。失败仅 console.error不抛 —— 调用方继续用内存里的值。
* 返回合并后的当前配置。
*
* 写盘用「tmp + rename」原子模式audit #5
* - 先写到 config.json.tmp再 fs.renameSync 覆盖 config.json
* - 进程在写入中途崩溃时磁盘上要么是旧文件,要么是新文件,绝不会半截 JSON
* - Windows 上 renameSync 在目标已存在时会成功POSIX 语义),覆盖是原子的
* - Linux 上 rename 也是原子的(同分区)
* 这样 config.json 永远可解析 —— 启动时再坏也只会回到默认loadConfig 已 try/catch
* 不会让整个 app 因为 settings 损坏而拒绝启动。
*/
/**
* audit fix (Phase 3 C2):异步 + 串行 promise queue + 失败回滚。
*
* 旧实现三个问题:
* 1. writeFileSync/renameSync 在主进程事件循环里同步阻塞 —— 拖 AI 面板分割条
* 这类高频触发会卡 UIsettings-store 在 200ms 防抖后多次调用,每次都阻塞
* 几 ms~几十 ms慢盘 / OneDrive 同步冲突会更糟)。
* 2. 多调用并发时appConfig = {...appConfig, ...next} 在内存里已经合并,
* 但只有最后一次 writeFile 落盘;如果中间某次失败,前一次的合并内容丢失
* 但 appConfig 还显示「成功」next 返回合并后的状态)。
* 3. catch 只 unlink tmpappConfig 不回滚 —— 调用方以为保存成功,磁盘实际
* 是旧值。
*
* 新实现:
* - 每次 saveConfig 排队到 saveQueue 的链尾,前一次写完才执行下一次
* - 用 fs.promises.writeFile + rename 异步 IO不再阻塞事件循环
* - rename 重试 EBUSY/EPERM 3 次50/100/200ms backoff—— Windows Defender
* 短暂持锁常见retry-on-busy 比直接放弃更稳
* - 写入失败appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error }
*/
let saveQueue = Promise.resolve();
function saveConfig(next) {
// audit fix (H1)merged 不能在 enqueue 时计算,否则两个并发调用
// 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动
// (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。
// 改成在 task 内部取最新 appConfig 合并;同时 before 也在 task 内取,
// 保证回滚到「本次任务开始前一刻」的状态,而不是「所有任务开始前一刻」。
const task = async () => {
const before = { ...appConfig };
const merged = { ...appConfig, ...next };
// auto-fallback 2026-08dataDir 改了 → 让 resolveDataDirOrFallback 下次重新 stat。
// 之前没有缓存,没有这个 hook现在 saveConfig 走完同步更新 appConfig
// 旧缓存条目还指向改前的路径 → 必须在此清掉。
if ('dataDir' in next && before.dataDir !== next.dataDir) {
_invalidateResolveCache();
}
const cfgPath = getConfigPath();
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}`;
let fh = null;
try {
await fs.mkdir(path.dirname(cfgPath), { recursive: true });
// audit fix (Round 4 P0-1):改走 fs.open + writeFile + sync + close —— fs.writeFile
// 内部只把数据送进 page cache没保证落盘就 close。rename 之后再断电,磁盘上
// 可能是新名字 + 零字节 / 半截 JSON之前 C2 修过「不写半截 JSON」靠的是 rename
// 原子性,但 fsync 缺失让断电后实际文件可能不是新文件。AI key / 自定义 dataDir
// 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能
// 保证断电后磁盘上要么是旧 config.jsonrename 前断电),要么是完整新文件。
fh = await fs.open(tmpPath, 'w');
await fh.writeFile(JSON.stringify(merged, null, 2), 'utf-8');
await fh.sync();
await fh.close();
fh = null;
await renameWithRetry(tmpPath, cfgPath);
// audit fix (Round 12 P1)parent dir fsync (POSIX only)。
// main/file-ops.js#atomicWriteFile 已在 Round 4 加了这段对称保护,
// saveConfig 漏修。POSIX rename(2) 同分区下原子,但「目录项本身」的
// 落盘时机由内核控制 —— rename 完直接断电,下次启动目录里可能仍是旧
// 名字 + 新 inode 已分配但未刷盘 → 用户保存的 AI Key / 自定义 dataDir
// 在断电窗口后「看起来没保存」。Windows NTFS journal 元数据已带 fsync
// 语义跳过macOS / Linux 走 open('r')+sync+close 兜底。
// 注意fsync 失败 ≠ 数据丢失rename 已生效),只 warn 不抛错。
if (process.platform !== 'win32') {
try {
const dirFh = await fs.open(path.dirname(cfgPath), 'r');
try {
await dirFh.sync();
} finally {
await dirFh.close();
}
} catch (fsyncErr) {
console.warn('[config-store] parent dir fsync failed (non-fatal):', fsyncErr && fsyncErr.message);
}
}
// 写盘成功 → 同步到 in-memory cache
appConfig = merged;
return { ok: true, value: merged };
} catch (e) {
if (fh) { try { await fh.close(); } catch { /* ignore */ } }
console.error('[config-store] 配置保存失败:', e.message);
// audit fix (C2):回滚 appConfig 到 merge 前状态,避免 UI 看到「已保存」
// 但磁盘实际是旧值renderer 端 settingsStore.optimisticUpdate 已经把 UI
// 改成新值,需要靠下一次 save 失败时回滚避免误以为成功)。
appConfig = before;
// 清理残余 tmp不影响下次保存
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
return { ok: false, error: friendlyFsError(e && e.code, e && e.message) || '配置保存失败' };
}
};
saveQueue = saveQueue.then(task, task);
return saveQueue;
}
/**
* audit fix (C2)rename 重试 EBUSY/EPERM —— Windows Defender / 杀毒 / 同步盘
* 会在极短时间内持锁目标文件,单次 rename 失败率约 1-3%。3 次
* RENAME_BACKOFF_MS 阶梯 backoff 后仍失败才抛错。
* @param {string} src
* @param {string} dst
*/
async function renameWithRetry(src, dst) {
const delays = RENAME_BACKOFF_MS;
for (let i = 0; i <= delays.length; i += 1) {
try {
await fs.rename(src, dst);
return;
} catch (e) {
const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES';
// 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。
if (!transient || i === delays.length) throw e;
await new Promise((r) => setTimeout(r, delays[i]));
}
}
}
/**
* 获取实际生效的数据目录路径(用户自定义优先,否则默认 ~/Notes
*
* 纯字符串返回,不做磁盘存在性检查 —— schema 校验 / 写入前的便宜判断用这个。
* 真正运行时(如 fsWatcher 启动 / scanDir 之前)请用 `resolveDataDirOrFallback()`
* 那个会 stat 路径、缺失时自动回退到默认。
*/
function resolveDataDir() {
const custom = appConfig.dataDir;
if (custom && typeof custom === 'string' && custom.trim()) {
return custom;
}
return DEFAULT_DATA_DIR;
}
/**
* 解析数据目录路径不存在时自动回退到默认runtime-only不修改 appConfig.dataDir
*
* 设计动机auto-fallback 2026-08
* 旧版 `resolveDataDir()` 只做字符串返回 —— 当 `appConfig.dataDir` 指向的目录
* 被外部删除 / 移动 / 离线OneDrive / U 盘 / 网盘常见fsWatcher 静默死亡、
* scanDir 返回 ENOENT、侧栏空白用户唯一恢复路径是手动「切换数据文件夹」再选。
* 这里加一层 stat 存在性检查:缺失 → runtime 回退到默认saved 字段记录原值,
* 持久化的 appConfig.dataDir 不动 —— 用户插回 U 盘下次启动还能用回去)。
*
* 缓存:
* 同步 statSync 在每个 IPC handler 里都跑会很贵file:list / file:read 等高频调用
* 都会走 currentDataRoot())。这里按 customDir 字符串做键的同步缓存:
* - 同一 customDir 连续调用 → 只 stat 一次
* - saveConfig 改了 dataDir → 缓存清掉,下次再 stat
* stat 失败后再次调用也命中缓存(避免 stat 一个不存在的路径反复失败)。
*
* 错误处理:
* - ENOENT / ENOTDIR → 视为不存在,回退到默认
* - 其他 errnoEACCES / EBUSY / EPERM / EIO→ 视为存在(可能是瞬时 ——
* U 盘读权限慢 / Windows Defender 持锁等),让上层 scanDir 自然失败
* 而不是「看似可用但其实打开就崩」
*
* @returns {{ dir: string, fellBack: boolean, saved: string }}
* dir实际可用的目录默认或 custom
* fellBacktrue 表示 custom 路径不可用、临时回退到默认
* savedappConfig.dataDir 的当前持久化值trim 后empty 表示从未设置
*/
let _resolveCache = null; // { customDir: string, exists: boolean }
/**
* 同步确保默认数据目录存在。默认目录可能是用户首次启动还没建出来、
* 也可能是 customDir 失效 fallback 时默认目录也从未被用过 —— 任何要返回
* DEFAULT_DATA_DIR 的路径都必须先确保它存在,否则 fsWatcher.startWatchingDir
* 会立刻 ENOENT 失败、scanDir 也读不出文件。
*
* 同步而不是异步resolveDataDirOrFallback 是 sync 接口(被 IPC handler /
* fsWatcher 启动路径同步调用),下面 fsWatcher 紧接着就拿这个 dir 去
* fs.watch —— 异步 mkdir 会有竞态。mkdir recursive 已幂等,目录存在 no-op。
*/
function ensureDefaultDataDirSync() {
if (!DEFAULT_DATA_DIR) return;
try {
fsSync.mkdirSync(DEFAULT_DATA_DIR, { recursive: true });
} catch (e) {
console.warn(`[config-store] 创建默认数据目录失败(${DEFAULT_DATA_DIR}:`, e.message);
}
}
function resolveDataDirOrFallback() {
const custom = (typeof appConfig.dataDir === 'string') ? appConfig.dataDir.trim() : '';
if (!custom) {
ensureDefaultDataDirSync();
scheduleEnsureDefaultDataDir();
return { dir: DEFAULT_DATA_DIR, fellBack: false, saved: '' };
}
// 命中缓存:相同 customDir 不重复 stat
if (_resolveCache && _resolveCache.customDir === custom) {
if (_resolveCache.exists) {
return { dir: custom, fellBack: false, saved: custom };
}
ensureDefaultDataDirSync();
scheduleEnsureDefaultDataDir();
return { dir: DEFAULT_DATA_DIR, fellBack: true, saved: custom };
}
// 重新 stat
let exists = true;
try {
const st = fsSync.statSync(custom);
// statSync 在普通文件 / 符号链接上不会抛 ENOTDIR —— ENOTDIR 只在「当目录用」时
// 才会报。这里额外检查「不是目录」dataDir 字段意外指向了一个文件路径
// (用户在设置对话框里手填、或 config.json 被改坏),仍应 fallback否则
// 上层 scanDir / watchDir 立刻 ENOTDIR 报上来,体验割裂。
if (!st.isDirectory()) exists = false;
} catch (e) {
if (e.code === 'ENOENT' || e.code === 'ENOTDIR') {
exists = false;
} else {
// EACCES / EBUSY / EPERM / EIO 等:当作存在,让上层自然处理错误
// fallback 反而会掩盖真实问题,比如 U 盘权限错误需要用户介入)
exists = true;
}
}
_resolveCache = { customDir: custom, exists };
if (exists) {
return { dir: custom, fellBack: false, saved: custom };
}
console.warn(`[config-store] 数据目录不可访问(${custom}),临时回退到默认 ${DEFAULT_DATA_DIR}`);
ensureDefaultDataDirSync();
scheduleEnsureDefaultDataDir();
return { dir: DEFAULT_DATA_DIR, fellBack: true, saved: custom };
}
/**
* 清掉 resolveDataDirOrFallback 的缓存。
*
* 内部仅由 saveConfig 在 dataDir 字段变化时调用 —— 让 schema 校验后改值不会让旧
* 缓存继续返回错误结果。外部无需直接调用reset IPC 自己会改 dataDir 走 saveConfig
*/
function _invalidateResolveCache() {
_resolveCache = null;
}
function getConfig() {
if (!configLoaded) {
// 防御:调用方忘了 load() —— 隐式初始化一次
loadConfig();
}
return appConfig;
}
/**
* 首次启动把 data/welcome.md 种子化到默认数据目录。
*
* 触发条件(任一为真则跳过):
* - 默认目录里已有 welcome.md已种子过→ 直接 return
* - 默认目录里有其它 .md 文件(用户笔记)→ 不覆盖
*
* 用 welcome.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件
* —— 用户数据目录应该只放用户的内容。
*
* 不再守卫「用户没设 dataDir」auto-fallback 路径下用户设了 dataDir 但路径失效,
* 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 welcome.md
* 让用户立刻看到内容(而不是打开一个空侧栏)。
*
* 失败仅 console.warn不弹窗用户用「打开数据文件夹」按钮可以自己补救。
*/
async function ensureDefaultDataDir() {
if (!DEFAULT_DATA_DIR) return;
const targetDir = DEFAULT_DATA_DIR;
const welcomeDst = path.join(targetDir, 'welcome.md');
try {
// 先确保目录存在mkdir recursive 不存在就建、存在 no-op
// ensureDefaultDataDirSync 已经在 resolveDataDirOrFallback 同步路径里调过
// 一次,这里再调一次是 idempotent 的——但 async 路径是 fire-and-forget
// 可能在 sync 路径之前跑scheduleEnsureDefaultDataDir 从 IPC handler 进)
// 或之后(启动 bootstrap所以这里再保险一次。
await fs.mkdir(targetDir, { recursive: true });
// welcome.md 已存在 → 已经种子过(或用户改过),跳过
try {
await fs.stat(welcomeDst);
return;
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
// 已有其它 .md → 用户笔记,不覆盖 welcome 也不打扰用户
const dirEntries = await fs.readdir(targetDir).catch((err) => {
if (err.code === 'ENOENT') return [];
throw err;
});
if (dirEntries.some((n) => n.toLowerCase().endsWith('.md'))) {
return;
}
// 种子化 welcome.md
const src = path.join(__dirname, '..', 'data', 'welcome.md');
await fs.copyFile(src, welcomeDst);
console.log('[config-store] 已种子 welcome.md 到默认数据目录:', welcomeDst);
} catch (e) {
console.warn('[config-store] welcome 种子失败:', e.message);
}
}
/**
* 首次启动种子别名 —— 保留旧 API 名字main.js 调用方),内部委托给 ensureDefaultDataDir。
* 新代码优先用 ensureDefaultDataDir旧名仅作 bootstrap 时的语义入口。
*/
async function seedDefaultDataDir() {
return ensureDefaultDataDir();
}
/**
* fire-and-forget 异步种子 —— resolveDataDirOrFallback 内每次返回 DEFAULT_DATA_DIR
* 都会调它,确保在 IPC handler 同步返回之后异步把 welcome.md 种子进去。
*
* 用 _ensureInFlight Promise 去重同一时间多次触发只跑一次fsWatcher 启动后
* 第一次 IPC scanDir 触发 fallback + renderer 启动后的 resetDataDir + 启动自身
* 三条路径都会调用,全部共享同一份 in-flight promise
*/
let _ensureInFlight = null;
function scheduleEnsureDefaultDataDir() {
if (_ensureInFlight) return _ensureInFlight;
_ensureInFlight = ensureDefaultDataDir().finally(() => {
_ensureInFlight = null;
});
return _ensureInFlight;
}
/**
* 一站式初始化loadConfig + 设定 DEFAULT_DATA_DIR。
* 必须在 app ready 之后调getDefaultDataDir 用到 app.getPath
*/
function init() {
appConfig = loadConfig();
DEFAULT_DATA_DIR = getDefaultDataDir();
return { appConfig, defaultDataDir: DEFAULT_DATA_DIR };
}
/**
* 测试辅助把模块级状态重置回初始值appConfig={}, DEFAULT_DATA_DIR=null, configLoaded=false
*
* 下划线前缀表示「仅测试用」—— 生产代码不应调用。单测之间需要干净的隔离。
*/
function _reset() {
appConfig = {};
DEFAULT_DATA_DIR = null;
configLoaded = false;
// auto-fallback 2026-08测试间清理缓存避免上一个 case 残留 customDir
// 命中率污染下一个 case 的首次 stat。
_resolveCache = null;
// ensureDefaultDataDir 的 in-flight 也要清,避免上一个 case 的种子任务
// 在新 case 期间仍在跑race
_ensureInFlight = null;
// audit fix (Round 12 P2)saveQueue 也要清。前一个 case 的 saveConfig
// 若没 await 就 _reset新 case 的 saveConfig 会链到旧 case 的尾后 →
// 跨 case 串扰renderer 端 / 用户态不会触发,但单测 _reset 后再 save
// 必须从干净 queue 开始)。
saveQueue = Promise.resolve();
}
/**
* 测试辅助:注入 fake electron.app。单测里 vi.mock('electron') 拦不住 CJS 的 require
* 所以提供 setter 让测试换掉内部 app 引用。
*/
function _setApp(mockApp) {
_app = mockApp;
}
module.exports = {
init,
loadConfig,
saveConfig,
getConfig,
getConfigPath,
getDefaultDataDir,
resolveDataDir,
resolveDataDirOrFallback,
ensureDefaultDataDir,
ensureDefaultDataDirSync,
scheduleEnsureDefaultDataDir,
seedDefaultDataDir,
_reset,
_setApp,
};

694
main/file-ops.js Normal file
View File

@@ -0,0 +1,694 @@
// 文件操作 helperStage 7 抽出 + Folder Browser 扩展)
//
// 职责:
// - 暴露 file IPC 所需的可测试纯函数isWithinDataDir / resolveFileName / scanFiles / scanDir / classifyEntry
// - 不包含 ipcMain 注册逻辑(仍留在 main.js与 config-store 的拆分风格一致)
//
// 边界:
// - 不引用 mainWindow / ipcMain / shell —— 这些留在 main.js 里就近注册
// - 不读 configStore —— 由调用方注入数据目录
// - scanFiles 的 ENOENT 行为:返回错误(不再静默 mkdir——
// 防止「数据目录被外部删了之后被自动重建掩盖数据丢失」
// 见 audit #3
//
// Folder Browser2026-08
// - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类
// - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary'
// - EDITABLE_EXTS来自 shared/extension-lists.js是「可打开 + 编辑」
// 的扩展名白名单markdown (.md/.markdown) 与常见纯文本均在内
//
// 测试tests/unit/file-ops.test.jsjsdom 环境之外;纯 Node 即可)
const fs = require('fs').promises;
const path = require('path');
// 单一事实源扩展名白名单main + preload + renderer 三处共用)
const { EDITABLE_EXTS } = require('../shared/extension-lists.js');
// 单一事实源errno → 中文提示main + renderer 共享preload 过桥)
const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js');
/** 单文件大小上限5 MB防止超大文件冻结 renderer。 */
const MAX_FILE_SIZE = 5 * 1024 * 1024;
/**
* 检查 fullPath 的占用情况 —— 返回三种状态:
* - 'free' 路径不存在,可用
* - 'file' 路径已被一个文件占用,需要重命名避让
* - 'directory' 路径已被一个目录占用(用户手动 mkdir 出来),也需要避让
*
* 审计修复 (Round 11 deep-fix P2-4):旧版用 fs.access() 只能判断「存在与否」,
* 不能区分 file vs directory。用户新建笔记取名 `foo.md` 但数据目录里已经有一个
* 同名目录(手动 mkdir 出来的笔记文件夹resolveFileName 直接把目录路径当成
* 可用文件返回;后续 atomicWriteFile 打开目录路径得到 EISDIRUI 弹「无效参数」
* 误导信息。改用 fs.stat() 拿 dirent 类型确认。
*/
async function pathOccupancy(fullPath) {
try {
const st = await fs.stat(fullPath);
if (st.isDirectory()) return 'directory';
if (st.isFile()) return 'file';
// socket / fifo / device —— 数据目录里不应出现,但既然存在也当占用
return 'file';
} catch (e) {
if (e && e.code === 'ENOENT') return 'free';
// EACCES / EPERM 等:保守起见当「占用」,让上层走避让分支
return 'file';
}
}
/**
* 给一个文件名分类为 folder / editable / binary。
* 纯函数:不读文件系统、不感知 path —— 仅看 name 末尾扩展名。
*
* 注意folder 类型由调用方scanDir传入时显式标记
* 本函数在 entryType === 'folder' 时不会被调用(参见 scanDir 内部)。
*
* @param {string} name
* @returns {'editable'|'binary'}
*/
function classifyEntry(name) {
if (typeof name !== 'string' || !name) return 'binary';
const dot = name.lastIndexOf('.');
// 没有扩展名 → binary
if (dot < 0) return 'binary';
const ext = name.slice(dot + 1).toLowerCase();
// 隐藏文件 / 末尾多余点:扩展名部分为空(".env" 有扩展名 "env",不是这种)
if (!ext) return 'binary';
// dot === 0 的隐藏文件名(如 ".gitignore"):扩展名部分非空但不在白名单 → binary
// 注意 ".env" 现在会走 EDITABLE_EXTS.has('env') → 'editable'(修旧版的列表/链接割裂 bug
return EDITABLE_EXTS.has(ext) ? 'editable' : 'binary';
}
/**
* 拒绝 symlinkNotes 用户的工作流是「编辑自己数据目录里的 .md」
* 数据目录里出现 symlink 通常来自外部同步盘 / 误操作 / 恶意数据,
* 写 symlink 会跟随到外部真实文件,绕过 isWithinDataDir 边界。
* 见 audit #7。
*
* @param {string} filePath
* @returns {Promise<{ok: true, isFile: boolean} | {ok: false, error: string, code?: string}>}
*/
async function assertNotSymlink(filePath) {
let st;
try {
st = await fs.lstat(filePath);
} catch (e) {
if (e.code === 'ENOENT') return { ok: true, isFile: false }; // 不存在:留给调用方走 FILE_NOT_FOUND 路径
return { ok: false, error: e.message, code: e.code };
}
if (st.isSymbolicLink()) {
return { ok: false, error: 'SYMLINK_NOT_ALLOWED', message: '不允许编辑符号链接(防止越权写入)' };
}
return { ok: true, isFile: st.isFile() };
}
/**
* audit fix (M1 main):递归检查 filePath 与其祖先(直到 stopAt 目录),
* 任一层级出现 symlink 即拒绝。光检查 target 文件不够 —— 若父目录是 symlink
* 指向 dataDir 外部,整个 target 的「真实路径」就会跳出 dataDir绕过
* isWithinDataDir 的字符串前缀检查。同步盘 / 误操作 / 外部攻击都可能制造
* 这种中间层 symlink。
*
* 策略:从 filePath 父目录开始向上 lstat到 stopAt一般是 dataDir为止
* 任一是 symlink 就拒绝。stopAt 本身允许是 symlink通常不允许但它的边界
* 由 assertDataDirSafe 等其它检查负责,这里只覆盖 target 这棵子树。
*
* @param {string} filePath - 目标文件路径
* @param {string} [stopAt] - 终止祖先链的目录(默认不传 = 一路 lstat 到根,
* 但调用方一般会传 dataDir 来限定范围)
* @returns {Promise<{ok:true} | {ok:false, error:string, code?:string}>}
*/
async function assertNoSymlinkAncestor(filePath, stopAt) {
if (!filePath) return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径不能为空' };
const stopResolved = stopAt ? path.resolve(stopAt) : null;
// audit fix (Phase O fix):原实现只从 filePath 的**父目录**开始向上 lstat
// 漏掉 filePath 自身。若 filePath 本身是 symlink指向外部目标盘父目录
// 通常都是合法的 dataDir 子项,但 filePath 本身跟随 symlink 解析后就跳到
// dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。
// 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。
const resolvedPath = path.resolve(filePath);
if (!stopResolved || resolvedPath !== stopResolved) {
try {
const stSelf = await fs.lstat(resolvedPath);
if (stSelf.isSymbolicLink()) {
return {
ok: false,
error: 'SYMLINK_NOT_ALLOWED',
code: 'SYMLINK_NOT_ALLOWED',
message: `路径中包含符号链接,不允许编辑(${resolvedPath}`,
};
}
} catch (e) {
// ENOENT目标本身不存在是允许的scan-dir 创建场景、新文件);其它错误上报
if (e.code !== 'ENOENT') {
return { ok: false, error: e.message, code: e.code, message: `读取路径状态失败:${e.code || e.message}` };
}
}
}
let dir = path.dirname(resolvedPath);
// 已经走到根盘符 / 根目录就停
const seen = new Set();
while (dir && !seen.has(dir)) {
seen.add(dir);
// 终止祖先链的判定stopAt 目录已检查过、不再向上 lstat。
// audit fix (main-M10):之前三连 OR 里 `dir === stopResolved.toLowerCase?.()`
// 是死分支 —— dir 来自 path.dirname()Windows 上是混合大小写,根本不会
// 全小写化。删掉,只保留 POSIX 严格相等 + Windows 大小写无关两条。
if (stopResolved && (dir === stopResolved
|| (process.platform === 'win32' && dir.toLowerCase() === stopResolved.toLowerCase()))) {
break;
}
try {
const st = await fs.lstat(dir);
if (st.isSymbolicLink()) {
return {
ok: false,
error: 'SYMLINK_NOT_ALLOWED',
code: 'SYMLINK_NOT_ALLOWED',
message: `路径中包含符号链接目录,不允许编辑(${dir}`,
};
}
} catch (e) {
// 父目录不存在是允许的target 可能尚未创建);其它错误上报
if (e.code !== 'ENOENT') {
// audit fix (K1-R4):统一带 code + message 字段,与 assertNotSymlink 对齐。
return { ok: false, error: e.message, code: e.code, message: `读取目录状态失败:${e.code || e.message}` };
}
break;
}
const parent = path.dirname(dir);
if (parent === dir) break; // 已经到盘符根
dir = parent;
}
return { ok: true };
}
/**
* 检查目标路径是否在数据目录内(防止越权读写)
* - Windows 不区分大小写
* - 字符串前缀比对path.resolve 后再做)
* - 未调用 fs.realpathsymlink 不解file:write/rename/delete 已先 lstat 拒绝 symlink
*
* @param {string} target
* @param {string} root
*/
function isWithinDataDir(target, root) {
if (!root || !target) return false;
const rootResolved = path.resolve(root);
const targetResolved = path.resolve(target);
if (process.platform === 'win32') {
const rootLower = rootResolved.toLowerCase();
const targetLower = targetResolved.toLowerCase();
return targetLower.startsWith(rootLower + path.sep)
|| targetLower === rootLower;
}
return targetResolved === rootResolved
|| targetResolved.startsWith(rootResolved + path.sep);
}
/**
* 文件名清洗:拒绝路径分隔符 / `..` / 空字符串 / 控制字符;用户输入的扩展名
* 原样保留;重名 (2)、(3)...。
*
* audit fix (Phase L3-FS 4A/4B):拒绝 Windows 保留字符(<>:"/\\|?*)和保留设备名
* (CON / PRN / AUX / NUL / COM1-9 / LPT1-9)。之前 name 里含 `|` 或 `:` 仍能通过
* resolveFileName到 fs.rename 才在 Windows 上撞 EPERM/EBUSYUI 弹出
* 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。
*
* 用户反馈2026-08-28「新建笔记时直接把后缀放在文件名后面而不是自动
* 加上」—— 本函数不再强制补 .md。用户输入 `foo.md` / `foo.txt` / `foo`(无扩展名)
* 都按字面保留。重名避让时扩展名也原样保留foo.txt → foo (2).txt
* 重命名场景走 resolveRenameName行为完全对称。
*
* @param {string} raw
* @param {string} dir - 数据目录绝对路径
* @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>}
*/
// Windows 保留设备名(基础名,无扩展名时)。注意是大写比对;用户的实际
// 输入通常是大小写混合,做 toUpperCase 后再校验。带扩展名也照样拒
// Windows 把 CON.md / con.txt 都视作设备名 —— 这是 NTFS 的硬规则,
// fs.rename 一定撞 EPERM
const WIN_RESERVED_DEVICE_NAMES = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]);
async function resolveFileName(raw, dir) {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, error: '文件名不能为空' };
}
const name = raw.trim();
// 拒绝路径分隔符与 .. 段
if (/[/\\]/.test(name) || name.includes('..')) {
return { ok: false, error: '文件名不能包含路径分隔符或 ..' };
}
// Windows 保留字符:<>:"/\\|?*/\\ 已被上面挡一次,这里再列一遍保持语义独立,
// 让错误信息精确指向 Windows 保留字符而非「路径分隔符」)。
if (/[<>:"|?*]/.test(name)) {
return { ok: false, error: '文件名包含 Windows 保留字符(< > : " | ? *' };
}
// 控制字符
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(name)) {
return { ok: false, error: '文件名包含非法字符' };
}
// Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算)
const lastDot = name.lastIndexOf('.');
const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) {
return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` };
}
// 用户输入的扩展名原样保留 —— 故意不强制 .md与 resolveRenameName 对齐)。
let candidate = name;
let counter = 2;
for (;;) {
const fullPath = path.join(dir, candidate);
// 任何候选路径都必须仍在数据目录内(防止 join 出 ..\
if (!isWithinDataDir(fullPath, dir)) {
return { ok: false, error: '非法路径' };
}
const occupancy = await pathOccupancy(fullPath);
if (occupancy === 'free') {
return { ok: true, path: fullPath, name: candidate };
}
// 'file' / 'directory' 都视为占用 → 走避让分支
// 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留(与 resolveRenameName 对齐)。
// foo.md → foo (2).mdbar.txt → bar (2).txtbaz无扩展名 → baz (2)
const cutAt = lastDot > 0 ? lastDot : name.length;
const base = name.slice(0, cutAt);
const ext = name.slice(cutAt);
candidate = `${base} (${counter})${ext}`;
counter += 1;
if (counter > 1000) return { ok: false, error: '重名次数过多' };
}
}
/**
* 重命名专用的文件名清洗。
*
* 用户反馈:「重命名不要自动补后缀」—— 重命名场景下用户输入什么就用什么,
* 包括完全去掉扩展名foo.md → bar或换成别的扩展名foo.md → bar.txt
*
* 2026-08-28 反馈后resolveFileName新建文件与本函数语义完全对齐
* 都不强制补 .md新建时用户输入什么就用什么默认 .md 仍由 renderer 的
* defaultName 带出来)。这样用户想新建一个 .txt 纯文本笔记 / .json 数据笔记
* 不再需要「先建 .md → 重命名成 .txt」两步。
*
* 仍然保留的校验:
* - 路径分隔符 / ..
* - Windows 保留字符
* - Windows 保留设备名(基础名按 . 之前的部分取,"CON.md" 也算)
* - 控制字符
* - 重名避让((2)/(3) 后缀保留原始扩展名foo.md → foo (2).md
* foo.txt → foo (2).txt无扩展名 foo → foo (2)
*
* @param {string} raw
* @param {string} dir
*/
async function resolveRenameName(raw, dir) {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, error: '文件名不能为空' };
}
const name = raw.trim();
// 拒绝路径分隔符与 .. 段
if (/[/\\]/.test(name) || name.includes('..')) {
return { ok: false, error: '文件名不能包含路径分隔符或 ..' };
}
if (/[<>:"|?*]/.test(name)) {
return { ok: false, error: '文件名包含 Windows 保留字符(< > : " | ? *' };
}
// 控制字符
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(name)) {
return { ok: false, error: '文件名包含非法字符' };
}
// Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算)
const lastDot = name.lastIndexOf('.');
const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) {
return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` };
}
let candidate = name;
let counter = 2;
for (;;) {
const fullPath = path.join(dir, candidate);
if (!isWithinDataDir(fullPath, dir)) {
return { ok: false, error: '非法路径' };
}
// 审计修复 (Round 11 deep-fix P2-4):用 pathOccupancy 替代 fs.access
// 区分 file vs directory —— 同名目录不能被当作可用文件返回。
const occupancy = await pathOccupancy(fullPath);
if (occupancy === 'free') {
return { ok: true, path: fullPath, name: candidate };
}
// 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留。
// foo.md → foo (2).mdbar.txt → bar (2).txtbaz无扩展名 → baz (2)
const cutAt = lastDot > 0 ? lastDot : name.length;
const base = name.slice(0, cutAt);
const ext = name.slice(cutAt);
candidate = `${base} (${counter})${ext}`;
counter += 1;
if (counter > 1000) return { ok: false, error: '重名次数过多' };
}
}
/**
* 扫描数据目录下的所有 .md / .markdown 文件按名称排序locale zh-CN
*
* 重要变更audit #3目录不存在ENOENT时不再自动 mkdir
* 直接返回错误让上层决定如何处理(弹对话框 / 切换目录),
* 避免「数据目录被外部删了之后被静默重建掩盖数据丢失」。
*
* @param {string} dir
* @returns {Promise<{ok:true, files:Array} | {ok:false, error:string, code?:string}>}
*/
async function scanFiles(dir) {
if (!dir || typeof dir !== 'string') {
return { ok: false, error: 'invalid dir', code: 'EINVAL' };
}
try {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (e) {
// ENOENT目录不存在 —— 不再自动重建,让用户看到明确错误
if (e.code === 'ENOENT') {
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `数据目录不存在:${dir}` };
}
throw e;
}
const mdFiles = entries.filter((e) => {
if (!e.isFile()) return false;
const lower = e.name.toLowerCase();
return lower.endsWith('.md') || lower.endsWith('.markdown');
});
const files = await Promise.all(mdFiles.map(async (e) => {
const fullPath = path.join(dir, e.name);
try {
const st = await fs.stat(fullPath);
return {
name: e.name,
path: fullPath,
size: st.size,
mtimeMs: st.mtimeMs,
};
} catch {
return null;
}
}));
const list = files
.filter((f) => f !== null)
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
return { ok: true, files: list };
} catch (e) {
return { ok: false, error: e.message, code: e.code };
}
}
/**
* 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。
* Folder Browser2026-08的核心入口。
*
* 行为约定:
* - 不递归:只列 absDir 的一层直接子项;想看深层 → 点文件夹进入
* - 跳过符号链接文件夹(与 assertNotSymlink 同源策略;
* 不跟随,避免 symlink 指向 dataDir 外部造成越权)
* - 跳过符号链接文件(同样防御)
* - 文件 entryType 由 classifyEntry(name) 决定:
* 'editable' = Markdown 或常见纯文本(白名单内)
* 'binary' = 其它扩展名(侧栏仍显示但灰掉、点击弹 toast
* - 文件夹 entryType 固定为 'folder'
* - 排序:文件夹在前(按名称),文件在后(按名称);
* 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉
*
* @param {string} absDir - 数据目录下某一层的绝对路径
* @returns {Promise<{
* ok: true,
* dir: string, // 回传绝对路径,便于调用方对照
* entries: Array<{
* name: string,
* path: string, // 绝对路径
* entryType: 'folder'|'editable'|'binary',
* isFolder: boolean,
* size?: number,
* mtimeMs?: number,
* }>
* } | { ok:false, error, code?, message? }>}
*/
async function scanDir(absDir) {
if (!absDir || typeof absDir !== 'string') {
return { ok: false, error: 'invalid dir', code: 'EINVAL' };
}
let dirents;
try {
dirents = await fs.readdir(absDir, { withFileTypes: true });
} catch (e) {
if (e.code === 'ENOENT') {
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `目录不存在:${absDir}` };
}
// audit fix (Round 12 P2):非 ENOENT 路径不再把原始 e.message英文 errno
// + 绝对路径透出去。renderer fs-watcher 推过来时直接当成 toast 文案
// 显示,会泄漏路径 + 看着割裂。走 friendly-fs-error 与 Round 8 EROFS /
// ENAMETOOLONG 等统一:未知 errno 时拿 e.message 作为 fallback兜底
// 业务码),不能让用户看空白 toast。
return {
ok: false,
error: sharedFriendlyFsError(e && e.code, e && e.message) || '扫描目录失败',
code: e.code,
};
}
// 用 lstat 一次性拿每一项的类型,过滤掉符号链接(防止越权)。
// 注意 fs.readdir(..., {withFileTypes:true}) 给的 dirent.isSymbolicLink()
// 在 Windows 上对 junction 也判定为 true —— 这里统一跳,避免跟随。
const safeDirents = [];
for (const d of dirents) {
if (d.isSymbolicLink()) continue;
safeDirents.push(d);
}
const entries = await Promise.all(safeDirents.map(async (d) => {
const fullPath = path.join(absDir, d.name);
if (d.isDirectory()) {
return {
name: d.name,
path: fullPath,
entryType: 'folder',
isFolder: true,
};
}
if (d.isFile()) {
try {
const st = await fs.stat(fullPath);
return {
name: d.name,
path: fullPath,
entryType: classifyEntry(d.name),
isFolder: false,
size: st.size,
mtimeMs: st.mtimeMs,
};
} catch {
// 文件在 readdir 与 stat 之间被删了 —— 静默跳过
return null;
}
}
// 其它socket / fifo / block device 等)—— 不展示
return null;
}));
const list = entries.filter(Boolean);
// 排序folder 在前、file 在后,同组内按 zh-CN locale
list.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN');
});
return { ok: true, dir: absDir, entries: list };
}
/**
* 把数据目录根下的相对路径解析成绝对路径,并做防御性校验。
*
* 渲染端传来的 relDir 必须满足:
* - 空字符串 → dataRoot根目录
* - 正斜杠分隔的相对路径(如 'notes/2026'),不含 ..
* - 不含绝对路径前缀Windows 盘符 / POSIX /
*
* 解析后用 isWithinDataDir 二次校验(防止路径穿越 / symlink 跟随)。
*
* @param {string} relDir
* @param {string} dataRoot - 数据目录绝对路径
* @returns {{ok:true, absDir:string, relDir:string} | {ok:false, error:string, message?:string}}
*/
function resolveDirRelative(relDir, dataRoot) {
if (typeof relDir !== 'string') {
return { ok: false, error: 'INVALID_REL_DIR', message: 'relDir 必须是字符串' };
}
// 拒绝盘符 / 绝对路径前缀(必须在 strip 前做,否则 '/etc/passwd' 被剥成
// 'etc/passwd' 反而通过校验)
if (/^[a-z]:[\\/]/i.test(relDir) || relDir.startsWith('/') || relDir.startsWith('\\')) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '不允许使用绝对路径' };
}
const normalized = relDir.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
// 拒绝 .. 段(即使藏在中间)
if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '相对路径不能包含 . 或 ..' };
}
const absDir = normalized === '' ? dataRoot : path.join(dataRoot, normalized);
if (!isWithinDataDir(absDir, dataRoot)) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' };
}
// 重新标准化回 POSIX与传入保持一致+ 末尾无 /
const relOut = normalized;
return { ok: true, absDir, relDir: relOut };
}
/**
* 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。
* 渲染端用于比对 fs-watcher 推送的 relDir 字段。
*
* @param {string} absDir
* @param {string} dataRoot
* @returns {string} POSIX 相对路径,根目录时为 ''
*/
function toRelativeDir(absDir, dataRoot) {
if (!absDir || !dataRoot) return '';
const a = absDir.replace(/\\/g, '/').replace(/\/+$/, '');
const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, '');
if (a === r) return '';
const prefix = r + '/';
if (a.startsWith(prefix)) return a.slice(prefix.length);
// 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支
return '';
}
/**
* 原子写文件 —— audit fixC1/C2 file-IO
* 1. 写到 dst.tmp.<pid>.<now>
* 2. fsync tmp让内容确实落盘再 rename 才不会丢)
* 3. renameWithRetry 覆盖 dstWindows Defender / 杀毒 / 同步盘
* 偶尔瞬态持锁retry-on-busy 比直接放弃稳得多)
*
* 失败路径:
* - 写入 tmp 失败 → unlink tmp + 抛原始 errno
* - rename 失败retry 后)→ unlink tmp + 抛原始 errno
*
* 副作用:成功后磁盘上要么是旧文件(旧文件全程未动),要么是新文件;
* 永远不会有半截内容被读到。
*
* @param {string} dst 目标绝对路径
* @param {string} content UTF-8 文本
*/
async function atomicWriteFile(dst, content) {
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}`;
let fh;
try {
fh = await fs.open(tmpPath, 'w');
await fh.writeFile(content, 'utf-8');
// fsync 关键:没有 fsyncrename 之后断电可能留下「磁盘上 inode 改了
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
// FlushFileBuffersrename 之前必须强制落盘。
await fh.sync();
await fh.close();
fh = null;
} catch (e) {
if (fh) { try { await fh.close(); } catch { /* ignore */ } }
try { await fs.unlink(tmpPath); } catch { /* tmp 不存在也忽略 */ }
throw e;
}
try {
await renameWithRetry(tmpPath, dst);
// audit fix (Round 4 P0-1)rename 后 fsync 父目录(仅 Linux / POSIX
// POSIX rename(2) 在同分区下原子,但「目录项本身」写入磁盘的时机由内核
// 控制;不 fsync 目录就断电,磁盘上可能仍是旧名字 → 文件彻底丢失。
// Windows 上 NTFS 文件系统层 journal 元数据,这条 fsync 不需要(也无害,
// 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。
// 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。
if (process.platform !== 'win32') {
try {
const dirFh = await fs.open(path.dirname(dst), 'r');
await dirFh.sync();
await dirFh.close();
} catch (dirFsyncErr) {
console.warn('[file-ops] 父目录 fsync 失败(不影响本次保存内容,但跨崩溃可能丢目录项):', dirFsyncErr.message);
}
}
} catch (e) {
// rename 失败:清理残余 tmp不影响下次保存
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
throw e;
}
}
/**
* audit fix (C3 file-IO)rename 重试 EBUSY/EPERM/EACCES。
* Windows Defender / 杀毒 / 同步盘会在极短时间内持锁目标文件,
* 单次 rename 失败率约 1-3%。3 次 50/100/200ms backoff 后仍失败才抛。
* 与 config-store 的同名实现保持一致的 backoff 时序,避免两条路径表现差异。
* @param {string} src
* @param {string} dst
*/
async function renameWithRetry(src, dst) {
const delays = [50, 100, 200];
for (let i = 0; i <= delays.length; i += 1) {
try {
await fs.rename(src, dst);
return;
} catch (e) {
const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES';
// 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。
if (!transient || i === delays.length) throw e;
await new Promise((r) => setTimeout(r, delays[i]));
}
}
}
/**
* audit fix (Round 4 收尾)file:write 的 errno 翻译 —— 与 main.js 各 IPC handler
* _friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError
* 共用一份文案。Round 4 之前本函数与 src/app.js#friendlyWriteError、
* src/file-ops.js#friendlyFsError 三处独立EROFS / ENAMETOOLONG / ENOTDIR 文案
* 三处不同ENOTEMPTY 在 src/file-ops.js 独有 —— 用户看到的提示不一致。
*
* 改用 shared/friendly-fs-error.js 单一事实源preload 同时把它过桥到
* window.api.friendlyFsErrorrenderer 两处旧实现也走同一份)。本函数保留壳子
* 是为了不重写所有调用点的语义main 内部仍叫 friendlyWriteError 表达
* 「写盘错误翻译」renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。
*
* @param {NodeJS.ErrnoException|null|undefined} e
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
*/
function friendlyWriteError(e) {
// 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。
// - 已知 errnoshared 模块返回固定中文文案,与 e.message 无关
// - 未知 errno / null e / e.code 缺失shared 模块走 fallback || '未知错误'
// → e?.message 有就透传英文 errno + 路径renderer 拿到的是已经走
// friendlyFsError 二次翻译过的中文,不会再让英文 errno 漏到这里);
// 没有就回退到「未知错误」(注意:旧版这里固定传「写入文件失败」,
// 与 renderer 三处的「未知错误」兜底文案不一致,统一为后者)
return sharedFriendlyFsError(e && e.code, e && e.message);
}
module.exports = {
MAX_FILE_SIZE,
EDITABLE_EXTS,
assertNotSymlink,
assertNoSymlinkAncestor,
classifyEntry,
isWithinDataDir,
resolveFileName,
resolveRenameName,
scanFiles,
scanDir,
resolveDirRelative,
toRelativeDir,
atomicWriteFile,
renameWithRetry,
friendlyWriteError,
};

500
main/fs-watcher.js Normal file
View File

@@ -0,0 +1,500 @@
// 目录监听层Stage 4b.2 抽离 + Stage 8 扩展)
//
// 职责:
// - fs.watch + readdir 轮询双通道捕获数据目录变化
// - 把变化推送给 rendererIPC 'files:changed'payload 含 relDir / dir
// - 窗口最小化 / 隐藏时暂停轮询fs.watch 保持运行),恢复时补扫一次
// - Stage 8支持 rewatch() 把监听目标切换到子目录Folder Browser
//
// 设计:
// - factory 模式:依赖通过参数注入,不引用 mainWindow / configStore 等具名符号
// - 状态dirWatcher / dirPollTimer / lastFilesSnapshot / watchedDir / emitDebounceTimer
// 全部闭包在 factory 返回的实例里,不污染模块全局
//
// 边界:
// - 不读 mainWindow 具名变量 —— 由 deps.getMainWindow() 提供(避免循环引用)
// - 不调 configStore —— 由 deps.resolveDataDir() 注入dir 变化也走这里)
// - 不重复实现 scanDir —— 由 deps.scanDir() 注入(同一份扫描逻辑)
const fsSync = require('fs');
const DIR_POLL_INTERVAL_MS = 2000;
const EMIT_DEBOUNCE_MS = 300;
// audit fix (Round 7 FS-F8)debounce maxWait —— 持续事件流git checkout /
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush
// fs.watch 通道一次都不 emit。maxWait=1000ms 强制 1s 内必须 emit 一次。
const EMIT_MAX_WAIT_MS = 1000;
const REATTACH_BASE_MS = 5_000;
const REATTACH_MAX_MS = 60_000;
// audit fix (Round 7 FS-F1):错误闩锁重复抑制窗口。数据目录被删除时
// updateSnapshot 每 2s 失败一次 → renderer 每 2s 弹一次 error toast →
// 屏幕常驻 5 条警告。30s 重发间隔让用户有充裕时间响应,又不至于漏掉
// 真实新错误(同 error 不同 dir 也算新错误,立即重发)。
const ERROR_REPEAT_THROTTLE_MS = 30_000;
/**
* @typedef {Object} FsWatcherDeps
* @property {() => (object|null)} getMainWindow - 返回 BrowserWindow 或 null
* @property {() => (string|null)} resolveDataDir - 当前生效的数据目录
* @property {(dir: string) => Promise<{ok:boolean, entries?:Array, error?:string}>} scanDir
* 扫描目录,返回 {ok:true,entries} 或 {ok:false,error}。
* Stage 8必须返回 entries含文件夹 + 文件),否则非 md 文件变化会漏报。
* @property {(absDir: string) => string} [toRelDir]
* 可选:把绝对目录路径转成 dataRoot 下的 POSIX 风格相对路径。
* 没传则 payload 不带 relDir 字段(旧调用方完全兼容)。
*/
function createFsWatcher(deps) {
// 解构 + 一次性校验:避免后面每个调用都判 null
const { getMainWindow, resolveDataDir, scanDir, toRelDir } = deps;
if (typeof getMainWindow !== 'function') throw new Error('getMainWindow 必须是函数');
if (typeof resolveDataDir !== 'function') throw new Error('resolveDataDir 必须是函数');
if (typeof scanDir !== 'function') throw new Error('scanDir 必须是函数');
const resolveRelDir = typeof toRelDir === 'function' ? toRelDir : null;
let dirWatcher = null;
let dirPollTimer = null;
let lastFilesSnapshot = '';
/** 当前被监听的目录(供轮询暂停/恢复复用,不必重新传参) */
let watchedDir = null;
let emitDebounceTimer = null;
// audit fix (Round 7 FS-F8)burst 首事件时间戳。持续事件流在
// burstStartedAt + EMIT_MAX_WAIT_MS 时强制 flush避免 trailing-only
// debounce 在永不静默的流上不发任何事件。
let burstStartedAt = 0;
// audit fix (shared-M9)generation token。startWatchingDir / rewatch 每次
// 切换目录都自增updateSnapshot 内 await 结束后比对 token
// - 不匹配 → 这是「上一代目录」的扫描结果,丢弃(既不要写 baseline
// 也不要 emitFilesChanged —— 否则基线会被旧目录列表覆盖,新目录的
// 真实首次扫描结果反而被当成「无变化」忽略,造成侧栏与磁盘状态
// 不一致的幽灵 bug
// - 匹配 → 当前活跃扫描,正常写 baseline + 推送
let dirGeneration = 0;
// audit fix (Round 7 FS-F1)错误闩锁。lastErrorKey = error+code+relDir 拼接;
// 同 key 在 ERROR_REPEAT_THROTTLE_MS 内不再重发,新 key 立即重发,扫描恢复
// ok=true时清空。避免「数据目录被删除」类持续错误每 2s 弹一次 toast。
let lastErrorKey = '';
let lastErrorAt = 0;
// audit fix (Round 7 FS-F5)重连退避计数。OneDrive / 网盘短时不可用时
// 重连失败指数退避 5s → 10s → 30s → 60s封顶避免「目录长时间不存在」
// 时每 5s 打一行 warn 的忙循环。挂载成功后 nextBackoffMs 重置回基线。
let nextBackoffMs = REATTACH_BASE_MS;
// audit fix (Round 7 FS-F7)轮询重入保护。慢盘OneDrive / SMB
// scanDir 可能 > 2ssetInterval 触发新调用与上一轮 await 并发争写
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
let pollInFlight = false;
function isWindowVisible() {
const win = getMainWindow();
return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
}
/**
* 条目列表的稳定指纹folder 用 namefile 用 name + size + mtime。
* 只在这一个地方定义两个调用点updateSnapshot / emitFilesChanged共用。
* @param {Array<{name:string,isFolder?:boolean,size?:number,mtimeMs?:number}>} entries
* @returns {string}
*/
function entriesSnapshot(entries) {
return entries.map((e) => {
if (e.isFolder) return `d:${e.name}`;
return `f:${e.name}|${e.size}|${e.mtimeMs}`;
}).join('\n');
}
/** 把绝对目录转成 relDirpayload 字段);内部统一走 resolveRelDir。 */
function relOf(absDir) {
if (!resolveRelDir || !absDir) return null;
try {
const r = resolveRelDir(absDir);
return typeof r === 'string' ? r : null;
} catch {
return null;
}
}
/**
* 判断当前是否应该 push 这次错误(基于 lastErrorKey + lastErrorAt 闩锁)。
* 同 key 在节流窗口内 → 抑制;新 key → 立即放行;恢复 ok → 调用方自动清闩。
* @param {string} errorKey - "error:code:relDir" 拼接
* @returns {boolean}
*/
function shouldReportError(errorKey) {
const now = Date.now();
if (errorKey !== lastErrorKey) return true;
return (now - lastErrorAt) >= ERROR_REPEAT_THROTTLE_MS;
}
/** 记录错误已上报(写闩锁)。 */
function recordError(errorKey) {
lastErrorKey = errorKey;
lastErrorAt = Date.now();
}
/** 清错误闩锁(扫描恢复 ok 时调用)。 */
function clearErrorLatch() {
lastErrorKey = '';
lastErrorAt = 0;
}
/**
* 安全发送main-M7teardown 窗口过程抛 throw → unhandledException
* @param {object} payload
*/
function safeSend(payload) {
const win = getMainWindow();
if (!win || win.isDestroyed()) return;
try {
win.webContents.send('files:changed', payload);
} catch (e) {
console.warn('[fs-watcher] send failed:', e && e.message);
}
}
async function updateSnapshot(dir, skipInitialEmit = false) {
// audit fix (shared-M9):捕获「这一代」的 generation tokenawait 结束后
// 与当前 dirGeneration 比对 —— 不一致说明用户在扫描期间又切换了目录,
// 本次结果属于上一代,必须丢弃(不能写 baseline也不能 push
const myGen = dirGeneration;
let result;
try {
result = await scanDir(dir);
} catch (e) {
// audit fix (Round 7 FS-F16)scanDir 是注入依赖,契约未约定「绝不
// reject」。catch 兜住,避免 unhandledRejection 杀进程(--unhandled-
// rejections=strict 下直接 fatal
console.warn('[fs-watcher] scanDir 抛出:', e && e.message);
return;
}
if (myGen !== dirGeneration) {
// 切到新目录了:旧扫描结果作废。新目录的扫描正在另一份 updateSnapshot
// 跑着,让它去写基线 / 推送即可。
return;
}
if (!result.ok) {
// 扫描失败audit #3 续):
// 以前只是 console.warn + 不更新基线,结果用户看到的是「目录被删了但列表还显示着旧文件」——
// 误以为数据还在,点了文件得到 FILE_NOT_FOUND 报错。
// 现在:把错误推给 renderer让它在保留旧列表的同时显式提示用户
// "数据目录不可访问"),并提供一个「重新选择」的入口。
// 基线故意不更新 —— 恢复访问后下次轮询自然会有新基线。
console.warn('[fs-watcher] updateSnapshot 失败:', result.error);
if (!skipInitialEmit) {
// audit fix (Round 7 FS-F1)错误闩锁。DATA_DIR_NOT_FOUND / EACCES
// 这类每 2s 复发的错误不再每次都 toast。error+code+relDir 拼接作为
// 闩锁 key同 key 30s 内只发一次,新 key 立即发,恢复 ok 时清空。
// audit fix (Round 7 FS-F13)relDir 用本次扫描的 dir不是
// currentRelDir()/watchedDir—— generation 语义上「本次扫描属于
// 哪个 dir」应该跟着参数走。
const relDir = relOf(dir);
const errorKey = `${result.error || ''}:${result.code || ''}:${relDir || ''}`;
if (shouldReportError(errorKey)) {
recordError(errorKey);
safeSend({
error: result.error,
code: result.code,
dir,
relDir,
});
}
}
return;
}
// 恢复 ok → 清错误闩锁
clearErrorLatch();
const list = result.entries || [];
const snapshot = entriesSnapshot(list);
if (snapshot !== lastFilesSnapshot) {
lastFilesSnapshot = snapshot;
if (!skipInitialEmit) {
emitFilesChanged(list, dir);
}
}
}
/**
* 向 renderer 推送文件列表。
*
* @param {Array=} precomputed - 调用方已经扫描好的列表updateSnapshot 会传),
* 传了就不再重复 scanDir。
* @param {string=} dir - 列表对应的目录绝对路径;不传则用 watchedDir。
*
* 注意:走「自己扫描」分支时必须同步 lastFilesSnapshot 基线。否则 fs.watch 触发的
* 这次推送不会更新基线,紧随其后的 2 秒轮询会认为列表「又变了」,导致每次外部改动
* 都发生一次重复扫描 + 重复 IPC 推送。
*
* Stage 8payload 新增 relDir 字段,告诉渲染端「这是哪个目录的变更」,
* 渲染端比对当前显示目录决定是否重扫。
*
* audit fix (Round 7 FS-F11)payload 同时带 `dir` 字段(绝对路径)与
* `relDir`POSIX 相对路径),与 file:scan-dir handler 的返回形状对齐,
* 方便 renderer 兜底逻辑无需依赖 IPC 调用上下文。
*/
async function emitFilesChanged(precomputed, dir) {
const targetDir = dir || watchedDir || resolveDataDir();
// audit fix (shared-M9):同样的 generation 防护。emitFilesChanged 自带
// scanDir 分支时同样有「scan 期间用户切目录 → 旧结果覆盖新基线」的竞态。
const myGen = dirGeneration;
let list;
if (precomputed) {
list = precomputed;
} else {
let result;
try {
result = await scanDir(targetDir);
} catch (e) {
// audit fix (Round 7 FS-F16)floating promise 兜底。
console.warn('[fs-watcher] emitFilesChanged scanDir 抛出:', e && e.message);
return;
}
if (myGen !== dirGeneration) return;
if (!result.ok) {
// 推送错误而不是清空列表 —— 临时错误不应让用户失去对已有文件的视图。
// 渲染端会保留上次的列表,仅显示错误提示。
// audit fix (Round 7 FS-F12):与 updateSnapshot 错误分支对齐,
// payload 同时带 code 字段。
const relDir = relOf(targetDir);
const errorKey = `${result.error || ''}:${result.code || ''}:${relDir || ''}`;
if (shouldReportError(errorKey)) {
recordError(errorKey);
safeSend({
error: result.error,
code: result.code,
dir: targetDir,
relDir,
});
}
return;
}
list = result.entries || [];
// 同步基线,避免轮询把这次改动再报一遍
lastFilesSnapshot = entriesSnapshot(list);
}
if (myGen !== dirGeneration) return;
// 成功路径同样补 dir 字段FS-F11
safeSend({ entries: list, dir: targetDir, relDir: relOf(targetDir) });
}
function scheduleEmit() {
const now = Date.now();
// audit fix (Round 7 FS-F8)maxWait —— 持续事件流git checkout /
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。
// burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。
if (!burstStartedAt) burstStartedAt = now;
const elapsed = now - burstStartedAt;
const delay = elapsed >= EMIT_MAX_WAIT_MS ? 0 : EMIT_DEBOUNCE_MS;
if (emitDebounceTimer) clearTimeout(emitDebounceTimer);
// audit fix (main-M6):闭包里捕获触发本次 scheduleEmit 时的 watchedDir
// 300ms 后即便用户改了 dataDir或 Folder Browser 切了子目录),
// emitFilesChanged 仍按触发时的 dir 推送 + payload.relDir 与之对齐。
// 否则事件会被「错路由」到新目录的 renderer 视图,造成侧栏幽灵更新。
const dirAtSchedule = watchedDir;
const t = setTimeout(() => {
emitDebounceTimer = null;
burstStartedAt = 0;
emitFilesChanged(undefined, dirAtSchedule);
}, delay);
// audit fix (main-M8)unref —— 否则 fs-watcher 还在挂定时器时 Electron
// 主进程不会自然退出before-quit 取消 close path 后这 300ms 会再卡一下)。
if (typeof t.unref === 'function') t.unref();
emitDebounceTimer = t;
}
// audit fixfs.watch emit error 后退避重连。
// 用户场景OneDrive 暂时离线 → 网盘驱动报 EPERM → fs.watch 死掉;
// 5s 后重连,若目录又可访问就恢复事件推送。退避期间 2s 轮询仍兜底。
// audit fix (Round 7 FS-F5):指数退避 5s → 10s → 30s → 60s封顶
// 长期不存在的目录不再每 5s 打一行 warn。成功挂载后 nextBackoffMs 重置。
let reattachTimer = null;
function scheduleReattach() {
if (reattachTimer || !watchedDir) return;
const delay = nextBackoffMs;
const t = setTimeout(() => {
reattachTimer = null;
if (!watchedDir) return;
// startWatchingDir 内部 stopWatchingDir 会先关旧句柄,再开新句柄;
// 即使同名目录也会重新挂 error handler。失败时 startWatchingDir 自己
// console.warn仍依赖轮询兜底。
try {
startWatchingDir(watchedDir);
// 挂载成功:重置退避计数,给未来新错误回到基线 5s。
nextBackoffMs = REATTACH_BASE_MS;
} catch (e) {
console.warn(`[fs-watcher] 重连失败(${delay}ms 后再试):`, e.message);
// 失败:指数退避,封顶 60s。
nextBackoffMs = Math.min(nextBackoffMs * 2, REATTACH_MAX_MS);
scheduleReattach();
}
}, delay);
// audit fix (main-M8)unref —— 5s 重连定时器若还挂着会拖住进程退出。
if (typeof t.unref === 'function') t.unref();
reattachTimer = t;
}
function startDirPoll() {
if (dirPollTimer || !watchedDir) return;
// audit fix (Round 7 FS-F14)unref —— 这个 timer 是长期存活的,叠加
// will-quit 不清 fsWatcher 会在退出路径上拖住主进程。
dirPollTimer = setInterval(() => {
// audit fix (Round 7 FS-F7)重入保护。慢盘OneDrive / SMB
// scanDir 可能 > 2ssetInterval 触发新一轮与上一轮 await 并发,
// 争写 lastFilesSnapshot → 后完成的可能是先发起的(旧覆盖新)。
if (pollInFlight) return;
pollInFlight = true;
updateSnapshot(watchedDir)
.catch((e) => {
// audit fix (Round 7 FS-F16)floating promise 兜底。
console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message);
})
.finally(() => {
pollInFlight = false;
});
}, DIR_POLL_INTERVAL_MS);
if (typeof dirPollTimer.unref === 'function') dirPollTimer.unref();
}
function stopDirPoll() {
if (dirPollTimer) {
clearInterval(dirPollTimer);
dirPollTimer = null;
}
}
/**
* 启动目录监听:
* - fs.watch: 立即触发,但 Windows / 云同步盘 / 网络盘可能不发事件
* - readdir 轮询: 兜底(每 2 秒)
* - 任一通道发现列表变化 → 通过 IPC 'files:changed' 推送新列表给 renderer
*
* Stage 8监听的目录可以是 dataRoot 本身也可以是其下的子目录Folder Browser 导航时
* 通过 rewatch() 切换。监听目标改变时fs.watch 句柄会先 close 再在新目录上重新打开。
*/
function startWatchingDir(dir) {
stopWatchingDir();
// audit fix (shared-M9):新一世代,旧扫描全部作废(见 updateSnapshot
// / emitFilesChanged 的 myGen 校验)。
dirGeneration += 1;
watchedDir = dir;
// 初始化基线(启动时不把历史文件当作「变化」)
// 第三个参数 skipInitialEmit = true仅同步基线不向 renderer 推送启动时的文件列表
// renderer 会通过 ipc 'file:list' / 'file:scan-dir' 自己拉取,避免重复更新)
updateSnapshot(dir, true);
try {
dirWatcher = fsSync.watch(dir, (eventType, filename) => {
// 不再按扩展名过滤 —— Stage 8 后列表里包含文件夹 + 各种扩展名,
// 任何变化都可能影响侧栏展示,让轮询做最终判定
// audit fix (Round 7 FS-F6)Windows ReadDirectoryChangesW 缓冲区
// 溢出时 Node 用 filename=null 上报(旧版当成噪声直接 return
// 等于在最需要重扫的时刻跳过重扫。批量操作git checkout /
// 解压 / AI 批量写)正好触发这个路径 —— 必须 scheduleEmit 触发重扫。
if (!filename) {
scheduleEmit();
return;
}
// Windows 上可能触发多次事件,加 300ms 防抖合并
scheduleEmit();
});
// audit fix监听底层错误。
// 之前 fs.watch 句柄没有 .on('error') —— 一旦 watchedDir 在外部被
// 删除 / 重命名 / 所在盘符消失fs.watch 会 emit 'error',没人接,
// 默认变 unhandledExceptionpoll 循环还在继续扫这个不存在的目录,
// 每次 updateSnapshot 都返回 ENOENT但 fs.watch 已经死了,
// 用户对数据目录的任何后续修改都收不到事件(只能靠 2s 轮询)。
// 现在挂上 error handler打日志 + 触发一次重新监听5s 退避),
// 让用户重命名 / 重新挂载盘后 fs.watch 自动恢复。
dirWatcher.on('error', (err) => {
console.warn('[fs-watcher] fs.watch 报错,重连退避中:', err.message);
scheduleReattach();
});
} catch (e) {
// audit fix (Round 7 FS-F5)startWatchingDir 内 fs.watch 同步抛
// ENOENT/EACCES/EPERM 时dirWatcher=null没有 error handler 可挂,
// scheduleReattach 永远不会再次触发。手动 schedule 一次,下一次轮询
// 失败 + fs.watch 仍没恢复也会被 scheduleReattach 自循环退避接管。
console.warn('[fs-watcher] 目录监听启动失败(仅依赖轮询):', e.message);
scheduleReattach();
}
// 窗口不可见时不必开轮询(会在 show/restore 时恢复)
if (isWindowVisible()) startDirPoll();
}
/**
* 切换监听目标到另一个子目录Folder Browser 用)。
*
* 与 startWatchingDir 的区别startWatchingDir 内部先 stopWatchingDir清基线所以
* 即使新旧目录完全相同也会重置基线rewatch 同名目录直接 no-op省一次基线重置。
*
* @param {string} dir - 新的绝对目录
*/
function rewatch(dir) {
if (!dir || typeof dir !== 'string') return;
if (dir === watchedDir) return;
startWatchingDir(dir);
}
/**
* 窗口最小化 / 收进托盘时暂停轮询 —— 没人在看文件列表,每 2 秒一次
* readdir + N 次 stat 是纯浪费(在 OneDrive / 坚果云这类同步盘上尤其贵)。
*
* fs.watch 故意保持运行:它是事件驱动的,开着几乎不花钱,隐藏期间的改动仍能捕获。
*/
function pauseDirPolling() {
stopDirPoll();
}
/** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */
function resumeDirPolling() {
if (!watchedDir) return;
startDirPoll();
updateSnapshot(watchedDir);
}
function stopWatchingDir() {
// audit fix (Round 7 FS-F9):自增 dirGeneration让在飞的
// updateSnapshot / emitFilesChanged await 返回后 myGen 校验失败、
// 不会回写刚清空的 baseline。
dirGeneration += 1;
if (emitDebounceTimer) {
clearTimeout(emitDebounceTimer);
emitDebounceTimer = null;
}
burstStartedAt = 0;
if (reattachTimer) {
clearTimeout(reattachTimer);
reattachTimer = null;
}
if (dirWatcher) {
try { dirWatcher.close(); } catch {}
dirWatcher = null;
}
stopDirPoll();
// audit fix (Round 7 FS-F5):停止监听也重置退避计数,避免下次启动
// 接着用上一会话的指数退避值。
nextBackoffMs = REATTACH_BASE_MS;
clearErrorLatch();
pollInFlight = false;
watchedDir = null;
lastFilesSnapshot = '';
}
return {
startWatchingDir,
rewatch,
pauseDirPolling,
resumeDirPolling,
stopWatchingDir,
/** 测试 / 调试用:当前是否在监听某个目录 */
get watchedDir() { return watchedDir; },
};
}
module.exports = {
createFsWatcher,
// DIR_POLL_INTERVAL_MS 故意不出 module.exports只是模块内 setTimeout
// 的常量(兜底 fs.watch 不稳的场景),无任何外部调用方 —— 出 exports
// 会让上层误以为「可以调整轮询频率」,徒增 API surface 风险。
};

6998
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

106
package.json Normal file
View File

@@ -0,0 +1,106 @@
{
"name": "notes",
"version": "1.0.0",
"description": "简洁的 Markdown 阅读器 - Markdown 文件与界面始终一致",
"main": "main.js",
"scripts": {
"start": "node scripts/launch.js",
"start:raw": "electron .",
"lint": "eslint .",
"check": "node scripts/check-syntax.js && node scripts/check-ipc.js",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "patch-package",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux"
},
"keywords": [
"notes",
"markdown",
"reader",
"electron",
"desktop"
],
"author": "",
"license": "MIT",
"dependencies": {
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/state": "^6.4.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.28.0",
"codemirror": "^6.0.2",
"dompurify": "^3.1.0",
"marked": "^12.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"electron": "^44.0.0",
"electron-builder": "^26.15.3",
"eslint": "^9.39.5",
"jsdom": "^25.0.1",
"patch-package": "^8.0.1",
"vitest": "^2.1.9"
},
"build": {
"appId": "com.notes.app",
"productName": "Notes",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"main/**/*",
"preload.js",
"index.html",
"src/**/*",
"shared/**/*",
"icon.ico",
"data/**/*",
"package.json"
],
"win": {
"icon": "icon.ico",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"installerIcon": "icon.ico",
"uninstallerIcon": "icon.ico"
},
"mac": {
"target": "dir",
"category": "public.app-category.productivity"
},
"linux": {
"target": "AppImage",
"category": "Office",
"icon": "icon.ico"
}
},
"engines": {
"node": ">=20.18.0"
},
"overrides": {
"@noble/hashes": "1.4.0"
},
"config": {
"electron_mirror": "https://registry.npmmirror.com/-/binary/electron/"
},
"allowScripts": {
"electron@44.0.0": true,
"electron-winstaller@5.4.0": true,
"esbuild@0.21.5": true
}
}

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js b/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js
index 2492bc7..6587e60 100644
--- a/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js
+++ b/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js
@@ -5,7 +5,7 @@ const crypto_1 = require("crypto");
const fs_1 = require("fs");
const promises_1 = require("fs/promises");
const zlib = require("zlib");
-const blake2_js_1 = require("@noble/hashes/blake2.js");
+const blake2_js_1 = require("@noble/hashes/blake2b");
// Rabin fingerprinting constants from github.com/aclements/go-rabin:
// Poly64 = 0xbfe6b8a5bf378d83 (degree-63 irreducible polynomial over GF(2))
// Window = 64 bytes, Avg = 16 KB, Min = 8 KB, Max = 32 KB

415
preload.js Normal file
View File

@@ -0,0 +1,415 @@
// 安全的 IPC 桥
// 通过 contextBridge 暴露最小化的 API 给渲染进程
//
// marked 和 DOMPurify 是从主进程 require 进来再暴露给渲染端使用。
// 这是因为 Electron 渲染进程在 contextIsolation 下无法直接用 `import 'marked'`
// 这种 bare specifier需要打包工具 + import map而我们坚持零打包。
//
// 关于 DOMPurify 初始化:
// DOMPurify 需要一个 window/document 才能工作。Preload 脚本运行在隔离的
// JS context 中contextIsolation但同一进程里的 window 对象已经存在。
// 我们在 preload 中用 `createDOMPurify(window)` 初始化,它返回的实例可以
// 把 sanitize 后的 HTML 字符串通过 contextBridge 暴露给渲染端 ——
// 返回的是字符串而非 DOM 节点,跨 contextBridge 没问题。
//
// IPC 通道配对由 scripts/check-ipc.js 自动审计(`npm run check`)。
// 新增 API 前先在 main.js 写好 ipcMain.handle / ipcMain.on / webContents.send
// 再回到 preload 加 wrapper脚本会校验通道双向配对。
const { contextBridge, ipcRenderer, nativeImage } = require('electron');
const { marked } = require('marked');
const nodePath = require('path');
const nodeFs = require('fs');
// 把 icon.ico 在 preload 启动时一次性转成 PNG data URL渲染端作为 <img src> 用。
// nativeImage 直接读 .ico 会拿到 Windows 编码,<img> 不一定能渲染toDataURL()
// 输出标准 PNG data URL浏览器 100% 兼容。文件路径在 build.files 里已经包含,
// 打包后路径仍指向资源目录。读不到 → 返回 null渲染端继续走 .app-title::before 占位。
let APP_ICON_DATA_URL = null;
try {
const iconPath = nodePath.join(__dirname, 'icon.ico');
if (nodeFs.existsSync(iconPath)) {
const img = nativeImage.createFromPath(iconPath);
if (img && !img.isEmpty()) {
APP_ICON_DATA_URL = img.toDataURL();
}
}
} catch {
// nativeImage 在某些 headless 测试 / 非 Electron 上下文里会失败;吞掉走 null 路径
}
const createDOMPurify = require('dompurify');
// 设置 schema单一事实源。preload 是 CJS可以直接 require。
//
// renderer 不能自己 import 这个文件 —— 它是 CommonJSmodule.exports
// renderer 跑在 Chromium 里nodeIntegration:false浏览器原生 ESM 没有任何
// CommonJS 互操作:改扩展名(.cjs → .js只能让 ESM loader 不因 MIME 拒绝它,
// 模块本身依然一个具名导出都没有import 会在链接阶段直接抛
// "does not provide an export named 'DEFAULT_SETTINGS'" 并让整个应用起不来。
// 所以 schema 必须和其他主进程能力一样,经 contextBridge 过桥。
const settingsSchema = require('./shared/settings-schema.js');
// Markdown → 安全 HTML 的核心规则ALLOWED_URI_REGEXP + 钩子)。与 tests 共用同一份,
// 防止规则在「实现」与「测试」之间漂移。
const { ALLOWED_URI_REGEXP, installHooks } = require('./shared/render-sanitize.js');
// 文件扩展名白名单EDITABLE_EXTS / MARKDOWN_EXTS—— main + renderer 共用一份,
// 防止「侧栏显示可编辑但 md 链接打不开」或反之的体验割裂。renderer 经 contextBridge
// 拿数组contextBridge 结构化克隆对 Set/Map 的支持视 Electron 版本而定,数组
// 是 100% 可靠的形态,渲染端直接 Array.includes 即可)。
const { EDITABLE_EXTS, MARKDOWN_EXTS } = require('./shared/extension-lists.js');
const EDITABLE_EXTS_LIST = Array.from(EDITABLE_EXTS);
const MARKDOWN_EXTS_LIST = Array.from(MARKDOWN_EXTS);
// Heading slug 算法preload + 锚点滚动共用同一份,否则点击文内锚点会定位失败)。
// 实现见 shared/slug.jsrenderer 端通过 window.api.slugifyHeadingBase 调用同一份,
// 不再源码级镜像避免两边正则漂移audit fix 3.1)。
const { slugifyHeading: sharedSlugify, slugifyHeadingBase: sharedSlugifyBase } = require('./shared/slug.js');
// AI 修改用的行级 + 词级 diff 算法。renderer 是 ESM + Chromium 原生,不能
// 直接 require CJS所以经 contextBridge 暴露成纯函数(返回纯对象,结构化克隆 OK
const markdownDiff = require('./shared/markdown-diff.js');
// AI 错误码字面量main / renderer 共享)—— renderer 是 ESM 不能直接 require CJS
// 所以经 contextBridge 把整个 AI_ERROR 对象暴露到 window.api.aiErrors
// renderer 在 src/ai/ai-status.js 顶层直接 window.api?.aiErrors?.AI_ERROR 取值。
const { AI_ERROR: sharedAiErrors } = require('./shared/ai-errors.js');
// errno → 中文提示main + renderer 共享)—— 同一理由经 contextBridge 过桥,
// 详见 shared/friendly-fs-error.js 注释。renderer 在 src/app.js / src/file-ops.js
// 顶层取 window.api.friendlyFsError 调用,不再各自维护 mapping避免 EROFS /
// ENAMETOOLONG / ENOTDIR / ENOTEMPTY 三处文案漂移)。
const { friendlyFsError: sharedFriendlyFsError } = require('./shared/friendly-fs-error.js');
// 兜底 beforeunload 清理:单条 IPC 通道一个 helperpagehide 未跑时再拆 listener。
//
// 【必须在模块顶层定义,不能放成 contextBridge 对象的方法】
// contextBridge 暴露的 on* wrapper 都是箭头函数,箭头函数没有自己的 this
// 它们的 this 来自词法作用域 = 模块顶层Node CJS 里是 module.exports
// 不是 contextBridge 的 API 对象)。如果 cleanupOn 是 API 对象的方法,
// 下面的 onXxx 调 this.cleanupOn(...) 会拿不到函数,渲染端 bootstrap 在
// 第一个 subscribeIpc 调用就会抛「this.cleanupOn is not a function」直接挂掉。
// 抽到模块顶层用普通函数声明,下面 on* 直接 cleanupOn(...) 调用。
//
// renderer 端 subscribeIpc 仍然有 pagehide 主清理路径,这里只是 pagehide
// 顺序漂移时的兜底。
function cleanupOn(channel, handler) {
const beforeUnload = () => {
try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ }
try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ }
};
try { window.addEventListener('beforeunload', beforeUnload, { once: true }); } catch { /* ignore */ }
return () => {
try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ }
try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ }
};
}
// 配置 marked —— 在 preload 一次性完成
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (href, title, text) => {
const html = baseLink(href, title, text);
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
// 标题 idmarked 从 v9 起移除了内置 slugger不再输出 id
// 导致文内锚点([跳转](#标题))和目录全部失效。这里自己补回来。
// 每次 parse 前 headingSlugs 会被清空,保证同名标题的 -1/-2 后缀按文档重新计数。
//
// 算法实现见 shared/slug.js —— src/outline.js 用同一份规则的源码级镜像,
// 否则 heading id 在各调用方之间会对不上,文内锚点点击无法定位。
const headingSlugs = new Set();
mdRenderer.heading = (text, level, raw) =>
`<h${level} id="${sharedSlugify(raw, headingSlugs)}">${text}</h${level}>\n`;
marked.setOptions({
gfm: true,
breaks: false,
renderer: mdRenderer,
});
// 拿到工厂函数(兼容 ESM/CJS 互操作差异)
const DOMPurifyFactory = (typeof createDOMPurify === 'function')
? createDOMPurify
: (createDOMPurify && typeof createDOMPurify.default === 'function')
? createDOMPurify.default
: null;
// DOMPurify 实例(绑定到 preload 的 window
let DOMPurifyInstance = null;
if (DOMPurifyFactory && typeof window !== 'undefined') {
try {
DOMPurifyInstance = DOMPurifyFactory(window);
} catch (e) {
console.error('[preload] DOMPurify 初始化失败:', e);
}
} else if (!DOMPurifyFactory) {
console.error('[preload] createDOMPurify 不可用');
}
if (!DOMPurifyInstance) {
console.warn('[preload] DOMPurify 未初始化renderMarkdown 将跳过 XSS 清洗');
}
// 允许的 URI 协议与 DOMPurify 钩子见 shared/render-sanitize.js。
// 顶部 require 已引入 ALLOWED_URI_REGEXP 与 installHooks钩子在拿到 DOMPurify
// 实例后立即注册。
installHooks(DOMPurifyInstance);
contextBridge.exposeInMainWorld('api', {
/**
* 设置 schema —— 纯数据,供 renderer 同步读取。
*
* preload 在 renderer 模块求值之前就跑完了,所以 settings-store.js /
* settings-dialog.js 可以在模块顶层直接取 window.api.settingsSchema。
* contextBridge 会做结构化克隆renderer 拿到的是只读副本。
*/
settingsSchema: {
DEFAULT_SETTINGS: settingsSchema.DEFAULT_SETTINGS,
SETTINGS_UI_OPTIONS: settingsSchema.SETTINGS_UI_OPTIONS,
},
/**
* AI 错误码表 —— 与 main/ai.js 同一份字面量shared/ai-errors.js
* 经 contextBridge 过桥后 renderer 端不会与主进程漂移。纯数据对象。
*/
aiErrors: {
AI_ERROR: sharedAiErrors,
},
/**
* errno → 中文提示Round 4 收尾:合并三处独立 mapping
* 与 main/file-ops.js#friendlyWriteError 同一份事实源shared/friendly-fs-error.js
* renderer 端友好提示文案不再与主进程漂移。
*
* @param {string|null|undefined} code - errno 或业务码
* @param {string|undefined} fallback - 未知 code 时的回退文案
* @returns {string}
*/
friendlyFsError: (code, fallback) => sharedFriendlyFsError(code, fallback),
/**
* 用 schema 把任意对象归一成完整 settings补默认值 + 丢未知键)。
* @param {*} raw
* @returns {Record<string, *>}
*/
coerceLoadedSettings: (raw) => settingsSchema.coerceLoadedSettings(raw),
/**
* Markdown → 安全 HTML
* @param {string} markdown
* @returns {string}
*/
renderMarkdown: (markdown) => {
if (typeof markdown !== 'string' || !markdown) return '';
try {
// 同名标题的去重后缀按文档计数,每次 parse 前重置
headingSlugs.clear();
const rawHtml = marked.parse(markdown);
// 【fail-closed】DOMPurify 不可用 → 返回转义后的纯文本,绝不返回未清洗 HTML
// 否则 attacker 写入 "<img src=x onerror=window.api.writeFile(...)>" 就能
// 借 viewer.innerHTML 拿到 privileged API 调用权preload 通过 contextBridge
// 暴露的 api 在 renderer 同源策略下被认为是同源可执行)。
if (!DOMPurifyInstance) {
const escaped = String(rawHtml)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<pre class="md-purify-fallback">${escaped}</pre>`;
}
return DOMPurifyInstance.sanitize(rawHtml, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
// uponSanitizeAttribute 钩子在文件顶部通过 addHook 全局注册
// v3 不再支持 sanitize 配置传 hook按 tag 白名单收口。
// audit fix (Round 13 / Sec-M)
// - 拒绝 <svg> / <math> / <template> / <form> / <input> / <button> /
// <select> / <textarea> —— DOMPurify 默认已经剥掉大部分,但 SVG-namespaced
// <style> 在 HTML 默认名单下会漏掉default 的 FORBID_CONTENTS 只覆盖
// HTML <style>)。显式列出作为防御深度。
// - 注意:未加 FORBID_ATTR: ['style'] —— 项目里 markdown 允许合法 inline
// stylecolor / font-size / background:url(https://...) 等),由
// isDangerousStyleValuerender-sanitize.js 顶部)在 uponSanitizeAttribute
// 钩子里按 CSS 属性名 / 危险值做 denylist 拦截。
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
} catch (e) {
console.error('[preload] renderMarkdown 失败:', e);
return '';
}
},
// 文件操作(只读 + 写入 + 文件管理)
listFiles: () => ipcRenderer.invoke('file:list'),
// Folder BrowserStage 8扫一个相对目录的一层条目含文件夹 + 文件 + binary
// relDir 是 dataRoot 下的 POSIX 相对路径(如 'notes/2026'),空串代表根。
// 返回 { ok, dir, relDir, entries } 或 { ok:false, error, message? }。
scanDir: (relDir) => ipcRenderer.invoke('file:scan-dir', relDir || ''),
// 切换 fs-watcher 监听目标到指定子目录。渲染端进入子目录时调用,
// 之后 fs-watcher 推送的 files:changed 事件 payload 会带 relDir
// 渲染端按 relDir 判断是否重扫。
watchDir: (relDir) => ipcRenderer.invoke('file:watch-dir', relDir || ''),
readFile: (filePath) => ipcRenderer.invoke('file:read', filePath),
// 第三个参数 expectedMtimeMs 是可选的 mtime 校验audit #2
// 传入后主进程会比对磁盘当前 mtime不一致则拒绝写入并返回 FILE_CHANGED_EXTERNALLY。
// 不传则跳过校验(强写场景 / 老调用兼容)。
writeFile: (filePath, content, expectedMtimeMs = null) =>
ipcRenderer.invoke('file:write', filePath, content, expectedMtimeMs),
// createFile 新签名Folder Browser / Stage 8
// createFile(name, { dir?: 绝对路径, initialContent?: string })
// dir 不传 → dataRootinitialContent 不传 → 主进程默认生成 `# 标题\n\n`。
// 向后兼容:第二参仍是字符串时当作 initialContentdir 走根。
createFile: (name, opts) => {
if (typeof opts === 'string') {
return ipcRenderer.invoke('file:create', name, opts);
}
return ipcRenderer.invoke('file:create', name, opts || {});
},
renameFile: (oldPath, newName) => ipcRenderer.invoke('file:rename', oldPath, newName),
deleteFile: (filePath) => ipcRenderer.invoke('file:delete', filePath),
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
// 打开任意路径(不修改设置)—— 设置对话框的"打开预览"按钮使用
openPath: (target) => ipcRenderer.invoke('app:open-path', target),
// 打开 config.json 所在目录(设置对话框 AI 段)。不接参数:完整路径不过 IPC
// 隐私renderer 也无法传任意路径(安全)。见 main.js app:open-config-dir。
openConfigDir: () => ipcRenderer.invoke('app:open-config-dir'),
// 链接点击:在系统默认浏览器中打开
openExternal: (url) => ipcRenderer.invoke('shell:open-external', url),
// 在系统文件管理器中显示文件macOS = RevealWin/Linux = 选中文件)
showItemInFolder: (filePath) => ipcRenderer.invoke('shell:show-item-in-folder', filePath),
// 在系统文件管理器中打开数据目录下的任意目录(用于状态栏路径 chip 在
// 「没打开文件」时的点击行为)。安全:主进程强制 isWithinDataDir 边界检查。
openDir: (dirPath) => ipcRenderer.invoke('shell:open-dir', dirPath),
// 应用信息
getDataDir: () => ipcRenderer.invoke('app:get-data-dir'),
getDefaultDataDir: () => ipcRenderer.invoke('app:get-default-data-dir'),
// auto-fallback 2026-08「回到默认」按钮的 IPC —— saveConfig({ dataDir: '' })
// 持久化清空 + 重启 fsWatcher + 刷新托盘菜单,返回 { ok, dir, defaultDir }。
resetDataDir: () => ipcRenderer.invoke('app:reset-data-dir'),
getVersion: () => ipcRenderer.invoke('app:get-version'),
// 设置
getSettings: () => ipcRenderer.invoke('app:get-settings'),
saveSettings: (partial) => ipcRenderer.invoke('app:save-settings', partial),
// P1-1 fix (audit):仅 settings 对话框回显用 —— 返回完整 AI API Key。
// 不入 renderer 常驻缓存;调用方应在用完后立即从 DOM 清掉。
// Q7/Q9 fix (audit)reveal 必须先 arm —— settings-dialog 在用户点
// 「显示 Key」按钮后先调 armRevealAiKey()5 秒内调 revealAiKey() 才生效。
armRevealAiKey: () => ipcRenderer.invoke('app:arm-reveal-ai-key'),
revealAiKey: () => ipcRenderer.invoke('app:reveal-ai-key'),
// 返回 config.json 真实路径 + 所在目录。设置对话框"数据存在哪"提示用。
getConfigPath: () => ipcRenderer.invoke('app:get-config-path'),
chooseDataDir: () => ipcRenderer.invoke('app:choose-data-dir'),
// 事件监听(来自主进程)
//
// audit fixrenderer 在 `subscribeIpc()` 里把所有 onXxx 返回的 unsubscribe
// 收集到 ipcUnsubscribes并在 pagehide 时统一拆。
// 但 pagehide vs beforeunload 顺序在不同 Chromium / Electron 版本下游移,
// 极端情况下(旧 Electron / Ctrl+Shift+R 强制 reloadpagehide 不触发,
// 主进程的 ipcRenderer.on 仍持有旧 callback。下次 reload 时 preload 重新
// 求值又注册一份新 callback → 同一条事件触发多次(菜单被点 N 次开 N 个
// dialog、theme 被翻 N 次、save-request 被处理 N 次)。
// 这里把 cleanup 函数再额外挂到 window 的 beforeunload 上做兜底:
// - 正常路径renderer 自己的 pagehide 先跑unsubscribe 被消费
// - 兜底路径pagehide 没跑 → beforeunload 触发,主进程侧 listener 被清
// 每个 on* 单独挂 listener + beforeunload共享 cleanupOn helper见顶部
onMenuCommand: (callback) => {
// menu 事件是「无 payload 只有 channel 名」语义
const events = ['menu:toggle-theme', 'menu:settings'];
const handlers = events.map(name => {
const handler = () => callback(name);
ipcRenderer.on(name, handler);
return { name, handler, cleanup: cleanupOn(name, handler) };
});
return () => handlers.forEach(({ cleanup }) => cleanup());
},
onFilesChanged: (callback) => {
const handler = (_event, payload) => callback(payload);
ipcRenderer.on('files:changed', handler);
return cleanupOn('files:changed', handler);
},
onAlwaysOnTopChanged: (callback) => {
const handler = (_event, enabled) => callback(enabled);
ipcRenderer.on('always-on-top:changed', handler);
return cleanupOn('always-on-top:changed', handler);
},
onMaximizeStateChanged: (callback) => {
const handler = (_event, isMaximized) => callback(isMaximized);
ipcRenderer.on('window:maximize-state', handler);
return cleanupOn('window:maximize-state', handler);
},
// auto-fallback 2026-08启动时主进程广播一次 data-dir:resolved。
// payload: { dir, defaultDir, fellBack, saved }
// fellBack=true 表示 custom 路径不可用、临时回退到默认;
// renderer 在 fellBack=true 时弹引导弹窗(恢复默认 / 切换 / 取消),
// false 时只拿 defaultDir 做 UI 文案,不弹任何东西。
onDataDirResolved: (callback) => {
const handler = (_event, payload) => callback(payload);
ipcRenderer.on('data-dir:resolved', handler);
return cleanupOn('data-dir:resolved', handler);
},
// 通知主进程当前是否有未保存改动(用于关闭时弹保存确认)
setDirty: (isDirty) => ipcRenderer.invoke('renderer:set-dirty', !!isDirty),
// 主进程请求立即保存(关闭窗口时触发)
onSaveRequest: (callback) => {
const handler = (_event, payload) => callback(payload);
ipcRenderer.on('renderer:save-request', handler);
return cleanupOn('renderer:save-request', handler);
},
reportSaveResult: (reqId, result) => {
ipcRenderer.send(`renderer:save-result:${reqId}`, result);
},
// 窗口控制
setAlwaysOnTop: (enabled) => ipcRenderer.invoke('window:set-always-on-top', enabled),
minimizeWindow: () => ipcRenderer.invoke('window:minimize'),
toggleMaximizeWindow: () => ipcRenderer.invoke('window:toggle-maximize'),
closeWindow: () => ipcRenderer.invoke('window:close'),
// AI 修改OpenAI 兼容 /chat/completions配置由用户在设置中填写
// 返回结构:
// { ok:true, id, content, responseFormat:'json'|'raw' } 成功
// { ok:false, error, message } 失败error 是稳定错误码)
aiEdit: (payload) => ipcRenderer.invoke('ai:edit', payload),
// 取消进行中的 AI 请求requestId 由 aiEdit 的成功路径或发送方自己维护
aiCancel: (requestId) => ipcRenderer.send('ai:cancel', requestId),
// AI diff 算法(行级 + 词级renderer 不能直接 require CJS
// 暴露成纯函数:返回 { rows, regions },对象经 contextBridge 结构化克隆
aiDiffCompute: (base, next, options) => markdownDiff.computeFullMarkdownDiff(base, next, options),
// 安全应用单 region返回 { ok:true, content } 或 { ok:false, reason }
aiDiffApply: (currentContent, region) => markdownDiff.applyDiffRegionSafely(currentContent, region),
// Heading slug 基础算法renderer 端不再源码级镜像)。
// 返回空串意味着原文剥完 HTML + 行内标记后什么都不剩(纯符号 heading
// contextBridge 跨边界走结构化克隆string in / string out不带内部状态。
// 调用方负责把多个 heading 串起来做去重。
slugifyHeadingBase: (raw) => sharedSlugifyBase(raw),
/**
* 文件扩展名白名单(共享自 shared/extension-lists.js
* - EDITABLE_EXTS_LIST: string[] — 可打开编辑的文件扩展名
* - MARKDOWN_EXTS_LIST: string[] — 支持预览的扩展名EDITABLE_EXTS 子集)
* 这里用数组(不是 Set过桥contextBridge 结构化克隆虽支持 Set/Map
* 但 Set/Map 内的字符串比较没有 Array.includes 直观;用数组 includes
* 性能完全够用。 之前 src/app.js 维护着一份 LOCAL_FILE_EXTS 数组,
* 与 main/file-ops.js 的 EDITABLE_EXTS 易漂移 —— 合并到 shared 后
* 两边自动同步。
*/
EDITABLE_EXTS: EDITABLE_EXTS_LIST,
MARKDOWN_EXTS: MARKDOWN_EXTS_LIST,
/**
* 应用图标PNG data URL来自 icon.ico。渲染端用 <img src> 显示在自定义
* 标题栏左侧。文件缺失 / nativeImage 加载失败时返回 null渲染端继续用
* .app-title::before 占位(保持现有视觉)。
* @returns {string|null}
*/
getAppIconDataUrl: () => APP_ICON_DATA_URL,
});

262
scripts/check-ipc.js Normal file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env node
// IPC 通道配对审计(取代 preload.js 旧的那 45 行手工注释)
//
// 规则:
// 1. renderer → maininvoke / send
// preload.js 中 `ipcRenderer.invoke('foo')` 必须有 main.js 中 `ipcMain.handle('foo', ...)` 配对。
// `ipcRenderer.send('foo')` 必须有 `ipcMain.on('foo', ...)` 或 `ipcMain.once('foo', ...)` 配对。
// 2. main → renderer推送
// main.js 中 `webContents.send('foo')` 必须有 preload.js 中 `ipcRenderer.on('foo', ...)` 配对。
// "on" 订阅也可以走 `onXxx` 高阶函数(事件名出现在数组/字符串字面量里)—— 用宽松匹配。
// 3. 动态通道名(如 renderer:save-result:${reqId})按"前缀"匹配:发起方提供完整字面量,
// 接收方按前缀 ipcMain.on(`renderer:save-result:${...}`) 即可。
//
// 输出:失败时返回非零退出码 + 把缺漏通道写到 stderr。
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const PRELOAD = path.join(ROOT, 'preload.js');
const MAIN = path.join(ROOT, 'main.js');
const MAIN_DIR = path.join(ROOT, 'main');
/**
* 递归收集目录下所有 .js 文件(排除 node_modules / dist 等)。
* @param {string} dir
* @returns {string[]}
*/
function walkJs(dir) {
if (!fs.existsSync(dir)) return [];
const out = [];
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (ent.name === 'node_modules' || ent.name === 'dist') continue;
out.push(...walkJs(p));
} else if (ent.isFile() && ent.name.endsWith('.js')) {
out.push(p);
}
}
return out;
}
/**
* 读文件,返回内容。
* @param {string} p
*/
function read(p) {
return fs.readFileSync(p, 'utf8');
}
/**
* 提取引号字符串字面量(单引号 / 双引号 / 反引号)。
* @param {string} src
* @returns {string[]}
*/
function extractStringLiterals(src) {
/** @type {string[]} */
const out = [];
// 单/双引号
const re1 = /(['"])((?:\\.|(?!\1).)*)\1/g;
let m;
while ((m = re1.exec(src))) out.push(m[2]);
// 模板字符串(无 ${} — 不展开
const re2 = /`([^`\\]*(?:\\.[^`\\]*)*)`/g;
while ((m = re2.exec(src))) {
// 如果包含 ${...},整段作为"模板前缀"
const tpl = m[1];
if (tpl.includes('${')) {
// 取 ${ 之前的字面前缀
const idx = tpl.indexOf('${');
out.push(tpl.slice(0, idx) + '${...}');
} else {
out.push(tpl);
}
}
return out;
}
/**
* 从文件中抽取 ipcRenderer.{invoke,send,on} 的通道名。
* @param {string} src
* @param {RegExp} headRe 形如 /ipcRenderer\.(invoke|send|on)\(\s*(['"`])/g
* @returns {string[]} 通道字面量
*/
function extractIpcCalls(src, headRe) {
/** @type {string[]} */
const out = [];
for (const m of src.matchAll(headRe)) {
const quote = m[1];
const start = m.index + m[0].length;
const end = src.indexOf(quote, start);
if (end === -1) continue;
let literal = src.slice(start, end);
if (literal.includes('${')) {
const idx = literal.indexOf('${');
literal = literal.slice(0, idx) + '${...}';
}
out.push(literal);
}
return out;
}
/**
* 给定发送方通道集合 + 接收方"模式"集合(字面量 + 通配前缀),找出漏配的通道。
* 接收方可以是字面量或 prefix${...}prefix 部分前缀匹配即可。
*
* @param {string[]} sent
* @param {string[]} received
* @returns {string[]} 漏配(按 sent 顺序)
*/
/**
* 收集 `const X = '...' | \`...\`` 形式的字符串字面量赋值(包括带 ${...} 的模板)。
* 仅在脚本作用域内查找(不在函数体内更精确,但 main.js 顶层都在 module 作用域,问题不大)。
* @param {string} src
* @returns {Map<string,string>} varName → 字面量
*/
function collectStringVarAssignments(src) {
/** @type {Map<string,string>} */
const map = new Map();
const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([`'"])((?:\\.|(?!\2).)*)\2/g;
let m;
while ((m = re.exec(src))) {
const name = m[1];
let literal = m[3];
if (literal.includes('${')) {
const idx = literal.indexOf('${');
literal = literal.slice(0, idx) + '${...}';
}
map.set(name, literal);
}
return map;
}
/**
* 提取 ipcMain.once(VAR, ...) / webContents.send(VAR, ...) 中的字面量(用变量映射)。
* @param {string} src
* @param {RegExp} varCallRe
* @param {Map<string,string>} varMap
* @returns {string[]}
*/
function extractIpcCallsByVar(src, varCallRe, varMap) {
/** @type {string[]} */
const out = [];
for (const m of src.matchAll(varCallRe)) {
const varName = m[1];
const lit = varMap.get(varName);
if (lit) out.push(lit);
}
return out;
}
function diffMissing(sent, received) {
return sent.filter((s) => {
if (!s) return false;
if (received.includes(s)) return false;
// 动态模板sent 是字面received 是前缀${...}?两边都可能是字面或模板
for (const r of received) {
if (r === s) return false;
// 一边是 "foo:${...}" 另一边是 "foo:bar" → 视为配对(只要前缀对得上)
if (r.endsWith('${...}') && s.startsWith(r.slice(0, -6))) return false;
if (s.endsWith('${...}') && r.startsWith(s.slice(0, -6))) return false;
}
return true;
});
}
function main() {
const preloadSrc = read(PRELOAD);
const mainSrc = read(MAIN);
// audit fix (Round 8 R-2):合并 main.js + main/**/*.js。
// 旧实现只读 main.js主进程拆出来的子文件main/ai.js / main/file-ops.js /
// main/fs-watcher.js / main/config-store.js里的 ipcMain.handle / webContents.send
// 完全不在审计图里 —— `files:changed`main/fs-watcher.js:149这种动态事件名
// 容易因重构漏配对。
const mainSubSrc = walkJs(MAIN_DIR).map(read).join('\n\n');
const combinedMainSrc = mainSrc + '\n\n' + mainSubSrc;
// preload.jsipcRenderer.invoke / send / on
const invokeRe = /ipcRenderer\.invoke\(\s*([`'"])/g;
const sendRe = /ipcRenderer\.send\(\s*([`'"])/g;
const onRe = /ipcRenderer\.on\(\s*([`'"])/g;
const preloadInvoke = extractIpcCalls(preloadSrc, invokeRe);
const preloadSend = extractIpcCalls(preloadSrc, sendRe);
const preloadOn = extractIpcCalls(preloadSrc, onRe);
// 订阅也可能在 onMenuCommand([...]) 的字符串数组里 — 简单提取那些 ['menu:foo', 'menu:bar'] 数组
const preloadArrOn = [];
const arrRe = /\[\s*((?:['"][\w:.-]+['"]\s*,\s*)+['"][\w:.-]+['"])\s*\]/g;
let am;
while ((am = arrRe.exec(preloadSrc))) {
for (const lit of extractStringLiterals(am[1])) {
if (lit.includes(':') && !lit.includes(' ')) preloadArrOn.push(lit);
}
}
// main.js + main/**/*.jsipcMain.handle / on / once + webContents.send
// 同时识别用变量中转的动态通道:先收集 const X = `prefix:${...}` 这种声明,
// 再把 ipcMain.once(channel, ...) / webContents.send(channel, ...) 里 channel 替换为它的字面量。
const stringVarLiterals = collectStringVarAssignments(combinedMainSrc);
const handleRe = /ipcMain\.handle\(\s*([`'"])/g;
const onMainRe = /ipcMain\.on\(\s*([`'"])/g;
const onceMainRe = /ipcMain\.once\(\s*([`'"])/g;
const sendFromMain = extractIpcCalls(combinedMainSrc, handleRe);
const onMain = extractIpcCalls(combinedMainSrc, onMainRe).concat(extractIpcCalls(combinedMainSrc, onceMainRe));
// 变量形式ipcMain.once(channel, ...) —— channel 来自 stringVarLiterals
const onMainByVar = extractIpcCallsByVar(combinedMainSrc, /ipcMain\.(?:on|once)\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals);
const handleByVar = extractIpcCallsByVar(combinedMainSrc, /ipcMain\.handle\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals);
onMain.push(...onMainByVar);
sendFromMain.push(...handleByVar);
// webContents.send(...) 的通道
const wcSendRe = /webContents\.send\(\s*([`'"])/g;
const webContentsSend = extractIpcCalls(combinedMainSrc, wcSendRe);
// 变量形式
const wcSendByVar = extractIpcCallsByVar(combinedMainSrc, /webContents\.send\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals);
webContentsSend.push(...wcSendByVar);
const errors = [];
// 1. renderer→main invoke 必须配 main.handle
const missingForInvoke = diffMissing(preloadInvoke, sendFromMain);
if (missingForInvoke.length) {
errors.push(`renderer→main invoke 缺 main.handle 配对:\n ${missingForInvoke.join('\n ')}`);
}
// 2. renderer→main send 必须配 main.on/once
const missingForSend = diffMissing(preloadSend, onMain);
if (missingForSend.length) {
errors.push(`renderer→main send 缺 main.on/once 配对:\n ${missingForSend.join('\n ')}`);
}
// 3. main→renderer webContents.send 必须配 preload.on (或 onXxx 数组)
const preloadSubscribed = new Set([...preloadOn, ...preloadArrOn]);
const missingForWcSend = diffMissing(webContentsSend, [...preloadSubscribed]);
if (missingForWcSend.length) {
errors.push(`main→renderer send 缺 preload.on 订阅:\n ${missingForWcSend.join('\n ')}`);
}
// 4. 列出所有声明的通道,便于人工核对(不算错误)
const allChannels = new Set([
...preloadInvoke,
...preloadSend,
...preloadOn,
...preloadArrOn,
...sendFromMain,
...onMain,
...webContentsSend,
]);
if (errors.length) {
console.error('[check-ipc] 失败:\n' + errors.map((e) => `${e}`).join('\n\n'));
process.exit(1);
}
console.log(`[check-ipc] 通过 (${allChannels.size} 个通道全部配对)`);
}
main();

86
scripts/check-syntax.js Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
// 语法体检:对 main / preload / scripts / src 全部 JS 文件跑 `node --check`。
// 比 ESLint 严格度低,但能捕获**最基础的**语法错误(漏括号、错引号等)
// 在文件压根还没被 linted 之前的快速反馈。
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
/** @type {string[]} */
const targets = [
'main.js',
'preload.js',
'scripts/launch.js',
// src/ 下用 ESM import/exportnode --check 会按 ESM 解析package.json 无 "type" 字段 → CJS
// 因此 src/**/*.js 必须以 .mjs 单独跑。
];
const esmTargets = [
'src',
];
function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
// audit fix (Q3.7):跳过 symlink 文件 + 目录。
// 之前只用 isFile()/isDirectory() 判定fs.readdirSync withFileTypes 不会跟随 symlink
// 但 isSymbolicLink() 会同步报告出来 —— 显式跳过避免后续 stat 跟着 symlink 跑到
// 项目外文件。实际风险低(项目里没有 symlink但保留防御。
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
walk(full, out);
} else if (entry.isFile() && entry.name.endsWith('.js')) {
out.push(full);
}
}
}
/** @type {string[]} */
const allCjs = targets.map((p) => path.join(ROOT, p)).filter(fs.existsSync);
/** @type {string[]} */
const allEsm = [];
for (const dir of esmTargets) {
const abs = path.join(ROOT, dir);
if (fs.existsSync(abs)) walk(abs, allEsm);
}
let failed = false;
for (const file of allCjs) {
try {
// audit fix (Q3.7):改用 execFileSync 不带 shell。
// 之前用 ``execSync(`node --check "${file}"`, { stdio: 'pipe' })`` 把路径拼进
// shell 字符串 —— 文件名里出现 `; rm -rf ~ #` 就会被 cmd.exe / sh 解释执行。
// 项目内的文件名目前可信,但这是审计工具自身表面的隐患,留着没意义。
execFileSync('node', ['--check', file], { stdio: 'pipe' });
} catch (err) {
failed = true;
console.error(`[check-syntax] CJS 失败: ${path.relative(ROOT, file)}`);
if (err.stderr) console.error(err.stderr.toString());
}
}
for (const file of allEsm) {
try {
// audit fix (Q3.7):同样改用 execFileSync。
// ESM 走 stdin 重定向:用 { input: fs.readFileSync(file) } 把文件内容喂给子进程
// stdin避开 shell 重定向 / 文件名展开。
const source = fs.readFileSync(file);
execFileSync('node', ['--check', '--input-type=module'], { stdio: ['pipe', 'pipe', 'pipe'], input: source });
} catch (err) {
failed = true;
console.error(`[check-syntax] ESM 失败: ${path.relative(ROOT, file)}`);
if (err.stderr) console.error(err.stderr.toString());
}
}
if (failed) {
console.error('[check-syntax] 失败');
process.exit(1);
}
console.log(`[check-syntax] 通过 (${allCjs.length} CJS + ${allEsm.length} ESM)`);

117
scripts/launch.js Normal file
View File

@@ -0,0 +1,117 @@
// Notes 启动包装
//
// 主要职责:
// 1. 处理 Windows 控制台编码cp936 → utf-8让中文日志不乱码
// 2. 在 Windows 上以管理员权限运行时切换工作目录到项目根
// (某些路径如 `C:\Windows\System32` 在普通权限下无法读取 electron 二进制)
// 3. 避免被 ELECTRON_RUN_AS_NODE 影响main.js 也会清除,这里是双保险)
//
// 与 Todo List 的 launch.js 逻辑完全一致 —— 它经过实际使用验证。
const path = require('path');
const { spawn } = require('child_process');
const fs = require('fs');
// 防止 ELECTRON_RUN_AS_NODE=1 让我们作为普通 Node 运行
if (process.env.ELECTRON_RUN_AS_NODE) {
console.log('[launch] 清除 ELECTRON_RUN_AS_NODE 环境变量');
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Windows 控制台 UTF-8
if (process.platform === 'win32') {
try {
process.stdout.setDefaultEncoding('utf8');
process.stderr.setDefaultEncoding('utf8');
} catch {
// 某些嵌入式环境下不可用,忽略
}
// 把当前终端的代码页切换到 UTF-8。
// 否则即使我们输出 UTF-8 字节cmd.exe 默认 cp936 会按 GBK 解析成乱码。
// chcp 65001 = UTF-8 code page
try {
require('child_process').execSync('chcp 65001 > nul', {
stdio: 'ignore',
shell: true,
});
} catch {
// chcp 在某些嵌入式终端不可用,忽略
}
}
// 解析 electron 可执行文件路径
function resolveElectronBinary() {
try {
// 优先使用 require('electron') 暴露的二进制路径dev 模式)
const electronPath = require('electron');
if (typeof electronPath === 'string' && fs.existsSync(electronPath)) {
return electronPath;
}
} catch {
// fallthrough
}
// 兜底:尝试常见路径
const candidates = [
path.join(__dirname, '..', 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'),
path.join(__dirname, '..', 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
throw new Error('未找到 electron 可执行文件,请先运行 npm install');
}
const electronPath = resolveElectronBinary();
const projectRoot = path.resolve(__dirname, '..');
console.log('[launch] Electron:', electronPath);
console.log('[launch] 项目根目录:', projectRoot);
// 在 Windows 上以管理员权限运行时,当前工作目录可能是 System32
// electron 二进制所在目录可能存在空格或权限问题,统一切换到项目根
if (process.cwd() !== projectRoot) {
try {
process.chdir(projectRoot);
} catch (e) {
// 切换失败只发警告,不要静默吞掉 —— spawn 仍会用 cwd: projectRoot
// 所以这里失败通常不影响 electron 启动,但日志里有必要让用户看到。
console.warn(`[launch] 切换工作目录到 ${projectRoot} 失败:${e.message}`);
}
}
// 传递所有参数给 electron
const args = [projectRoot, ...process.argv.slice(2)];
console.log('[launch] 启动参数:', args.join(' '));
const child = spawn(electronPath, args, {
stdio: 'inherit',
cwd: projectRoot,
env: {
...process.env,
// 确保不会被子进程继承为 Node 模式
ELECTRON_RUN_AS_NODE: undefined,
},
windowsHide: false,
});
child.on('error', (err) => {
console.error('[launch] 启动失败:', err.message);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.log(`[launch] Electron 被信号终止: ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});
// 透传 Ctrl+C
process.on('SIGINT', () => {
child.kill('SIGINT');
});
process.on('SIGTERM', () => {
child.kill('SIGTERM');
});

42
shared/ai-errors.js Normal file
View File

@@ -0,0 +1,42 @@
// AI 错误码 —— 主进程与 renderer 端共享的字符串字面值。
//
// audit fix (CQ-MED-7):之前错误码字面量分散在 main/ai.js#ERR_* 与
// src/ai/ai-status.js#AI_ERROR 两处,靠注释提醒同步 —— 实际曾漂移renderer 旧版
// 把 'AI_TIMEOUT' 写成 'ERR_TIMEOUT',导致 ai-controller 收到 main 返回的错误码
// 后 aiErrorMessage switch 永不命中、永远走 fallback 'AI 修改失败')。
// 现在所有跨进程错误码集中在本文件main / preload / renderer 三处共用同一份字面值,
// 任何一边改了另一边不会失配。
//
// 加载:
// - main/ai.js: const { AI_ERROR } = require('../shared/ai-errors.js');
// - preload.js: contextBridge.exposeInMainWorld('api', { aiErrors: { AI_ERROR } })
// - renderer: const { AI_ERROR } = window.api.aiErrors;
// fallback 见 src/ai/ai-status.jsjsdom 单测环境走本地副本)
//
// 命名规则AI_ERROR.* 的 value 是字符串字面值;调用方比较时用 AI_ERROR.FOO 引用,
// 永远不要直接引用字符串字面量。
'use strict';
const AI_ERROR = Object.freeze({
// ---- 跨进程main ↔ renderer----
// 主进程 ai.js 抛出 + renderer 端 controller 检测本地未配置时同样抛出。
NOT_CONFIGURED: 'AI_NOT_CONFIGURED',
// fetch 超时AbortError + timeout 信号)
ERR_TIMEOUT: 'AI_TIMEOUT',
// HTTP 4xx/5xx 或非 2xx 响应
ERR_PROVIDER: 'AI_PROVIDER_ERROR',
// 响应体 JSON 解析失败 / 不符合 OpenAI/Anthropic 协议
ERR_FORMAT: 'AI_BAD_RESPONSE',
// 用户主动取消renderer abort signal / controller.cancelled 标志)
ERR_CANCELLED: 'AI_CANCELLED',
// ---- renderer 端辅助判定main 不会产生这些值,但放一起便于集中引用)----
NO_FILE: 'NO_FILE',
EDITOR_UNAVAILABLE: 'EDITOR_UNAVAILABLE',
IPC_FAILED: 'IPC_FAILED',
GENERATION_STALE: 'GENERATION_STALE',
FILE_CHANGED: 'FILE_CHANGED',
});
module.exports = { AI_ERROR };

111
shared/extension-lists.js Normal file
View File

@@ -0,0 +1,111 @@
// 文件扩展名白名单main / preload / renderer 三处共用)
//
// ============================================================================
//
// 之前一批可编辑扩展名同时维护在两处(具体数量已不准):
// - main/file-ops.js 的 EDITABLE_EXTS SetclassifyEntry 入口决定文件
// entryType关系到 sidebar 展示 + 打开提示)
// - src/app.js 的 LOCAL_FILE_EXTS 数组resolveLocalFile 判断 md 内
// 链接是否可以跳转到该文件)
//
// 两边列表一旦漂移,「侧栏显示可编辑 / 点击却提示不支持」或「侧栏灰掉
// 但 md 里能点开」的体验割裂就会出现。本文件是单一事实源:
//
// • EDITABLE_EXTS — 可打开编辑的文件扩展名(小写、不含点)
// • MARKDOWN_EXTS — 支持 Markdown 渲染预览的扩展名EDITABLE_EXTS 子集)
//
// 加载方式:
// • main / preloadCJSconst exts = require('../shared/extension-lists.js')
// • renderer经 preload contextBridge 过桥window.api.EDITABLE_EXTS
// 因为 renderer 是 ESM + Chromium 原生,不能直接 import CJS。
// preload 内对 Set 调 Array.from 后再 exposeInMainWorld所以 renderer 端
// 拿到的是 Array不是 Set用 .includes / Array.isArray 判断即可。
//
// 修改流程只改本文件一处main + renderer 两侧自动同步。
// ============================================================================
'use strict';
/**
* 可打开编辑的文件扩展名白名单(小写、不含点)。
*
* 包含 Markdown + 常见纯文本 + 常见编程语言;其它扩展名一律视为 binary
* (侧栏仍展示但灰掉、点击提示"不支持的文件类型")。
*
* 范围决策2026-08-25 反馈):覆盖 Web 脚本 + 主流语言,避免「写笔记
* 时顺手维护个 .py / .sql 也打不开」的体验割裂。编辑器是 CodeMirror
* Markdown 6没有专门的语法高亮import map 里只配了 markdown /
* javascript / css / html所以 .py / .go / .rs 等虽然可编辑但会
* 是纯文本色 —— 后续要做 syntax highlighting 再补 lang-* 包。
*
* 列表约定:
* - 按「用途族」分组Markdown / 数据 / 配置 / 文档 / Python /
* JS-TS / Web 样式 / 组件框架 / JVM / Go / Rust / Ruby / PHP /
* Shell / Windows / SQL / C-C++ / C# / Swift / Scala / Lua /
* Perl / R / Dart
* - 排序方便 review 时一眼看清覆盖了哪些,没别的语义
*/
const EDITABLE_EXTS = new Set([
// Markdown
'md', 'markdown',
// 常见纯文本
'txt', 'text', 'log',
// 表格 / 结构化数据
'csv', 'tsv', 'json', 'xml', 'yaml', 'yml', 'toml',
// 配置 / 环境
'ini', 'cfg', 'conf', 'env',
'gitconfig', // 隐藏文件 .gitconfig 走 lastIndexOf('.') === 0 路径,需在白名单才能 editable
// 文档 / 排版
'rst', 'tex',
// Python
'py', 'pyi', 'pyw',
// JavaScript / TypeScriptjsx/tsx 让 React 用户也能直接编辑)
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx',
// WebHTML / CSS 衍生
'html', 'htm', 'css', 'scss', 'sass', 'less',
// 组件框架
'vue', 'svelte',
// JVM 系
'java', 'kt', 'kts',
// Go
'go',
// Rust
'rs',
// Ruby
'rb',
// PHP
'php',
// Shell 系POSIX 主流)
'sh', 'bash', 'zsh',
// Windows 脚本
'ps1',
// SQL
'sql',
// C / C++
'c', 'h', 'cpp', 'hpp', 'cc', 'cxx',
// C#
'cs',
// Swift
'swift',
// Scala
'scala',
// Lua
'lua',
// Perl
'pl',
// R
'r',
// Dart
'dart',
]);
/**
* 支持 Markdown 渲染预览的扩展名EDITABLE_EXTS 的子集)。
*
* 仅 .md / .markdown 走 viewer 预览,其它可编辑文件(.txt/.py/.json/...
* 在 viewer 显示「不支持预览」空态,但仍可在编辑器里修改。
*/
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
module.exports = { EDITABLE_EXTS, MARKDOWN_EXTS };

View File

@@ -0,0 +1,77 @@
// 文件系统 errno → 用户能看懂的提示main + renderer 共享)
// ============================================================================
//
// 单一事实源。解决 Round 3 之前的三份独立实现漂移:
// - main/file-ops.js#friendlyWriteError(e) —— 主进程内部,签名 e.code
// - src/app.js#friendlyWriteError(code, fallback) —— renderer save 路径
// - src/file-ops.js#friendlyFsError(code, fallback) —— renderer 文件 CRUD
//
// 三份在 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 上文案不同 —— 用户看到
// 不一致提示。每加一个 errno 都要同步改三处,每处都有人漏改。
//
// 修法Round 4 收尾):本文件 CJS export 函数 friendlyFsError(code, fallback)
// - main/file-ops.js 用 require('./shared/friendly-fs-error.js')
// 调用时友好FsError(e?.code, e?.message || '写入文件失败')
// - preload 用 require + contextBridge 暴露 window.api.friendlyFsError
// renderer 端走 window.api.friendlyFsError(...) 拿到同一份文案
//
// 加载方式:
// - main.js / main/file-ops.js / preload.js (CJS)const { friendlyFsError } = require('...')
// - renderer经 preload contextBridge 过桥window.api.friendlyFsError
// —— renderer 跑在 Chromium 原生 ESM不能直接 import CJS
//
// 【fallback 行为】
// - 已知 errno返回固定中文文案与 fileOps.friendlyFsError 测试矩阵对齐)
// - 未知 errno返回 fallback业务码如 PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED /
// FILE_TOO_LARGE / FILE_CHANGED_EXTERNALLY 等不是 fs errno由 IPC 调用方
// 把 result.message 中文文案塞进 fallback
// - fallback 为空串 / undefined走兜底「未知错误」避免空 toast
// ============================================================================
'use strict';
/**
* 把后端返回的 errno 翻译成中文用户提示。
*
* @param {string|null|undefined} code - 原始 errnoEACCES / EPERM / ENOSPC ...
* 或业务码字符串PATH_NOT_ALLOWED 等),业务码一律走 fallback
* @param {string|undefined} fallback - errno 未匹配时使用的回退文案
* (主进程一般传 e?.messagerenderer 一般传 IPC result.message / result.error
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
*/
function friendlyFsError(code, fallback) {
switch (code) {
case 'EACCES':
case 'EPERM':
return '文件被占用或没有写入权限(可能是只读文件 / 另一进程独占 / 权限不足)';
case 'ENOSPC':
return '磁盘空间不足';
case 'EROFS':
return '只读文件系统,无法写入';
case 'EIO':
return '磁盘 I/O 错误';
case 'EBUSY':
return '文件被其他程序占用';
case 'ENAMETOOLONG':
return '路径过长';
case 'ENOTDIR':
return '父目录不是目录';
case 'EISDIR':
return '目标路径是文件夹,无法写入';
case 'ENOTEMPTY':
return '目标文件夹不为空';
// audit fix (Settings P3 / ENOENT mapping):原本 ENOENT 没在 mapping 里,
// 走 default → fallback 兜底成「未知错误」。ENOENT 是最常见的 fs errno
// 之一rename 源文件已删 / delete 已被外部删 / 路径打错 / watch 到一半
// 文件被替换),用户看到一个空泛"未知错误"会以为 Notes 出 bug。补上。
// 措辞区分「文件不存在」与「路径里某一级目录不存在」也覆盖 ENOTDIR
// 已有的 case —— ENOENT 统一按"目标路径不存在"处理(精确到「文件还是
// 目录」要 main 端自己抛业务码,不该让 errno mapping 揣测)。
case 'ENOENT':
return '文件或目录不存在(可能已被移动、重命名或删除)';
default:
return fallback || '未知错误';
}
}
module.exports = { friendlyFsError };

604
shared/markdown-diff.js Normal file
View File

@@ -0,0 +1,604 @@
// 行级 + 词级 Markdown diff —— 用于 AI 修改预览
// ============================================================================
//
// 从参考项目 markdown.guanjihuan.com 的 src/lib/markdownDiff.ts 移植为 CJS
// 以便在 main / preload / renderer / 单元测试之间共用同一份算法。
//
// 核心思路:
// 1. 行级 LCS diff动态规划O(n*m) 但有上限保护)找到 add/remove/context 序列
// 2. 把连续的 add/remove 序列打包成"region"(一个修改块)
// 3. region 内部做词级 diff同上tokenize 后 LCS
// 4. applyDiffRegionSafely 用 region 上下文(前后各 2 行)安全定位并替换
//
// 关键保护:
// - MAX_LINE_DIFF_CELLS / MAX_TOKEN_DIFF_CELLS / MAX_TOKEN_DIFF_CHARS
// 防止大文档(>1MB触发指数级内存 / CPU 爆炸
// - endsWithNewline 跟踪:拼接回去时保留尾换行,避免与原文件 diff
'use strict';
/**
* @typedef {Object} DiffSegment
* @property {'equal'|'removed'|'added'} type
* @property {string} text
*/
/**
* @typedef {Object} FullDiffRow
* @property {string} id
* @property {string} [regionId]
* @property {'context'|'removed'|'added'} type
* @property {number} [oldLineNumber]
* @property {number} [newLineNumber]
* @property {DiffSegment[]} segments
*/
/**
* @typedef {Object} AiDiffRegion
* @property {string} id
* @property {number} oldStart
* @property {string[]} oldLines
* @property {number} newStart
* @property {string[]} newLines
* @property {string[]} beforeContext
* @property {string[]} afterContext
* @property {string} [conflict]
*/
/**
* @typedef {Object} PendingAiDiffProposal
* @property {string} id
* @property {string} baseContent
* @property {string} nextContent
* @property {boolean} [isReadOnly]
* @property {FullDiffRow[]} rows
* @property {AiDiffRegion[]} regions
*/
/**
* @typedef {Object} ComputeFullMarkdownDiffOptions
* @property {boolean} [tokenDiff]
*/
/**
* @typedef {Object} DiffOp
* @property {'context'|'removed'|'added'} type
* @property {string} text
* @property {number} [oldLineNumber]
* @property {number} [newLineNumber]
*/
const CONTEXT_SIZE = 2;
const MAX_LINE_DIFF_CELLS = 200_000;
const MAX_TOKEN_DIFF_CHARS = 4_000;
const MAX_TOKEN_DIFF_CELLS = 40_000;
function normalizeContent(content) {
// audit fixWindows Notepad / 某些 PowerShell pipeline 会写 UTF-8 BOM
// (U+FEFF) 在文件头。不剥 BOM 会让第一行变成 "\uFEFF# Title"
// computeDiffOps 里 oldLines[0] === newLines[0] 永远 false
// 每个 BOM-prefixed 文件都会在 AI diff 面板里把首行当成「被改」渲染,
// 即使内容一字未动。仅在文件起始位置剥一次intra-content 的 BOM 保留)。
// \u5BA1\u8BA1\u4FEE\u590D (Round 11 deep-fix P2-1)\uFF1A\u628A BOM \u5265\u9664\u4ECE\u300C\u4EC5\u6587\u4EF6\u5934\u300D\u6269\u5230\u300C\u5168\u6587 BOM \u5B57\u7B26\u300D\u3002
// \u65E7\u7248 intra-file BOM\uFF08\u4F8B\u5982\u591A\u6B21 cat \u62FC\u63A5\u65F6\u5076\u5C14\u51FA\u73B0\uFF09\u4F1A\u88AB\u5F53\u6210\u6B63\u6587\u4E00\u90E8\u5206\uFF0C
// \u8BA9\u5BF9\u5E94\u884C\u6C38\u4E0D\u7B49\u4E8E oldLines \u91CC\u540C\u6837\u4F4D\u7F6E\u7684\u884C \u2192 \u6574\u884C\u88AB\u8BEF\u5224\u4E3A\u300C\u4FEE\u6539\u300D\u3002
// \uFEFF \u5728 Markdown \u91CC\u6CA1\u6709\u4EFB\u4F55\u6709\u610F\u4E49\u7684\u8BED\u4E49\uFF08\u96F6\u5BBD BOM \u6807\u8BB0\uFF09\uFF0C\u5168\u6587\u5265\u662F\u5B89\u5168\u7684\u3002
//
// \u884C\u5C3E\u89C4\u6574\uFF1A\r\n \u2192 \n\uFF1B**\u4FDD\u7559\u88F8 \r**\uFF08\u65E7 Mac \u98CE\u683C\u5408\u6CD5\u53EF\u542B\uFF09\u3002
// \u539F `replace(/\r\n?/g, '\n')` \u4F1A\u541E\u6389\u88F8 \r \u628A\u4E00\u884C\u62C6\u6210\u4E24\u884C\uFF08Round 8 BOM \u4FEE\u590D\u7684\u526F\u4F5C\u7528\uFF09\uFF0C
// \u73B0\u5728\u6539\u7528 /\r\n/g \u53EA\u5339\u914D CRLF\u3002
return String(content).replace(/\r\n/g, '\n').replace(/\uFEFF/g, '');
}
function splitLines(content) {
const normalized = normalizeContent(content);
if (normalized.length === 0) return [];
// audit fix「空文件」与「只有一个换行的文件」语义上等价 —— 都是「无内容行」,
// 但前者 splitLines 返回 [],后者返回 [""]diff LCS 在这两种输入下会产生
// 不同的 opsadded 与 nothing让 AI diff 偶现「空文件被加了一行」的幽灵。
// 统一成 []。
const lines = normalized.endsWith('\n')
? normalized.slice(0, -1).split('\n')
: normalized.split('\n');
if (lines.length === 1 && lines[0] === '') return [];
return lines;
}
function joinLines(lines, endsWithNewline, eol = '\n') {
if (lines.length === 0) return endsWithNewline ? eol : '';
return `${lines.join(eol)}${endsWithNewline ? eol : ''}`;
}
function computeDiffOps(oldLines, newLines, onFallback) {
let prefixLength = 0;
const maxPrefixLength = Math.min(oldLines.length, newLines.length);
while (prefixLength < maxPrefixLength && oldLines[prefixLength] === newLines[prefixLength]) {
prefixLength += 1;
}
let oldSuffixStart = oldLines.length;
let newSuffixStart = newLines.length;
while (
oldSuffixStart > prefixLength
&& newSuffixStart > prefixLength
&& oldLines[oldSuffixStart - 1] === newLines[newSuffixStart - 1]
) {
oldSuffixStart -= 1;
newSuffixStart -= 1;
}
const oldMiddle = oldLines.slice(prefixLength, oldSuffixStart);
const newMiddle = newLines.slice(prefixLength, newSuffixStart);
/** @type {DiffOp[]} */
const ops = [];
for (let index = 0; index < prefixLength; index += 1) {
ops.push({ type: 'context', text: oldLines[index], oldLineNumber: index + 1, newLineNumber: index + 1 });
}
if (oldMiddle.length * newMiddle.length > MAX_LINE_DIFF_CELLS) {
// H3 fix (audit)cells 超过 MAX_LINE_DIFF_CELLS 时退化到「整段删 + 整段加」
// fallback。原来的实现不告诉调用方 —— UI 看到「整篇被改」会误以为 AI 整篇重写。
// onFallback 让 controller toast「diff 过大,已退化为整段替换」。
if (typeof onFallback === 'function') {
onFallback(`行级 diff 超过 ${MAX_LINE_DIFF_CELLS} cells 上限,已退化为整段替换(${oldMiddle.length} 行 removed + ${newMiddle.length} 行 added`);
}
oldMiddle.forEach((text, index) => {
ops.push({ type: 'removed', text, oldLineNumber: prefixLength + index + 1 });
});
newMiddle.forEach((text, index) => {
ops.push({ type: 'added', text, newLineNumber: prefixLength + index + 1 });
});
} else {
// LCS 动态规划
const dp = Array.from({ length: oldMiddle.length + 1 }, () => new Array(newMiddle.length + 1).fill(0));
for (let i = oldMiddle.length - 1; i >= 0; i -= 1) {
for (let j = newMiddle.length - 1; j >= 0; j -= 1) {
dp[i][j] = oldMiddle[i] === newMiddle[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
let i = 0;
let j = 0;
while (i < oldMiddle.length && j < newMiddle.length) {
if (oldMiddle[i] === newMiddle[j]) {
ops.push({
type: 'context',
text: oldMiddle[i],
oldLineNumber: prefixLength + i + 1,
newLineNumber: prefixLength + j + 1,
});
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 });
i += 1;
} else {
ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 });
j += 1;
}
}
while (i < oldMiddle.length) {
ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 });
i += 1;
}
while (j < newMiddle.length) {
ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 });
j += 1;
}
}
for (let index = oldSuffixStart; index < oldLines.length; index += 1) {
ops.push({
type: 'context',
text: oldLines[index],
oldLineNumber: index + 1,
newLineNumber: newSuffixStart + index - oldSuffixStart + 1,
});
}
return ops;
}
function findSequence(lines, sequence) {
if (sequence.length === 0) return [];
const matchesAt = (index) => sequence.every((line, offset) => lines[index + offset] === line);
// fix(audit 2026-08):原版在 preferredIndex 命中时直接 [preferredIndex] 返回,
// 跳过扫描全文 → 其它位置的重复匹配被静默忽略。背景AI 从 base 算 diff 时只
// 有 1 个匹配;但用户编辑后可能在其它位置粘了相同行 → 当前文档有 N 个匹配,
// preferredIndex hint 仍指向原位置。直接套用 hint 会改错位置(旧位置可能是
// 用户新增的副本,而非 AI 原意要改的那一行)。
// 修复:总是扫描全文,仅当只有 1 个匹配时才信任 hint 返回单元素数组。
// hint 现在在调用方applyDiffRegionSafely通过 beforeContext/afterContext 实现,
// 见 :547+ region clamp 切片。
const matches = [];
for (let i = 0; i <= lines.length - sequence.length; i += 1) {
if (matchesAt(i)) matches.push(i);
}
return matches;
}
function tokenize(text) {
const tokens = [];
let index = 0;
while (index < text.length) {
const char = text[index];
// 用贪婪匹配,匹配 $...$ 行内数学Katex 风格的简单 token
const mathMatch = char === '$' ? text.slice(index).match(/^\$[^$]+\$/) : null;
const wordMatch = text.slice(index).match(/^[A-Za-z0-9_]+/);
const spaceMatch = text.slice(index).match(/^\s+/);
if (mathMatch) {
tokens.push(mathMatch[0]);
index += mathMatch[0].length;
} else if (spaceMatch) {
tokens.push(spaceMatch[0]);
index += spaceMatch[0].length;
} else if (wordMatch) {
tokens.push(wordMatch[0]);
index += wordMatch[0].length;
} else {
tokens.push(char);
index += 1;
}
}
return tokens;
}
function diffTokens(removedText, addedText, onFallback) {
if (removedText.length + addedText.length > MAX_TOKEN_DIFF_CHARS) {
// H3 fix (audit)token 字符数超过上限 → 退化到行级 fallback。
// 通过 onFallback 把警告挂到 computeFullMarkdownDiff 的 warnings 字段。
if (typeof onFallback === 'function') {
onFallback(`词级 diff 超过 ${MAX_TOKEN_DIFF_CHARS} 字符上限,已退化为行级(${removedText.length}+${addedText.length} chars`);
}
return {
removedSegments: [{ type: 'removed', text: removedText }],
addedSegments: [{ type: 'added', text: addedText }],
};
}
const oldTokens = tokenize(removedText);
const newTokens = tokenize(addedText);
if (oldTokens.length * newTokens.length > MAX_TOKEN_DIFF_CELLS) {
// H3 fix (audit)token cells 超上限同样退化。
if (typeof onFallback === 'function') {
onFallback(`词级 diff cells 超过 ${MAX_TOKEN_DIFF_CELLS} 上限,已退化为行级(${oldTokens.length}×${newTokens.length} tokens`);
}
return {
removedSegments: [{ type: 'removed', text: removedText }],
addedSegments: [{ type: 'added', text: addedText }],
};
}
const dp = Array.from({ length: oldTokens.length + 1 }, () => new Array(newTokens.length + 1).fill(0));
for (let i = oldTokens.length - 1; i >= 0; i -= 1) {
for (let j = newTokens.length - 1; j >= 0; j -= 1) {
dp[i][j] = oldTokens[i] === newTokens[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
/** @type {DiffSegment[]} */
const removedSegments = [];
/** @type {DiffSegment[]} */
const addedSegments = [];
let i = 0;
let j = 0;
const pushSegment = (segments, type, text) => {
const last = segments[segments.length - 1];
if (last && last.type === type) {
last.text += text;
} else {
segments.push({ type, text });
}
};
while (i < oldTokens.length && j < newTokens.length) {
if (oldTokens[i] === newTokens[j]) {
pushSegment(removedSegments, 'equal', oldTokens[i]);
pushSegment(addedSegments, 'equal', newTokens[j]);
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
pushSegment(removedSegments, 'removed', oldTokens[i]);
i += 1;
} else {
pushSegment(addedSegments, 'added', newTokens[j]);
j += 1;
}
}
while (i < oldTokens.length) {
pushSegment(removedSegments, 'removed', oldTokens[i]);
i += 1;
}
while (j < newTokens.length) {
pushSegment(addedSegments, 'added', newTokens[j]);
j += 1;
}
return { removedSegments, addedSegments };
}
function buildContextRows(lines) {
return lines.map((text, index) => ({
id: `context-${index}`,
type: 'context',
oldLineNumber: index + 1,
newLineNumber: index + 1,
segments: [{ type: 'equal', text }],
}));
}
function buildChangedRows(regionId, changedOps, options, onFallback) {
/** @type {FullDiffRow[]} */
const rows = [];
const removedOps = changedOps.filter((op) => op.type === 'removed');
const addedOps = changedOps.filter((op) => op.type === 'added');
const pairCount = Math.min(removedOps.length, addedOps.length);
const useTokenDiff = options.tokenDiff !== false;
for (let i = 0; i < pairCount; i += 1) {
const removedOp = removedOps[i];
const addedOp = addedOps[i];
const { removedSegments, addedSegments } = useTokenDiff
? diffTokens(removedOp.text, addedOp.text, onFallback)
: {
removedSegments: [{ type: 'removed', text: removedOp.text }],
addedSegments: [{ type: 'added', text: addedOp.text }],
};
rows.push({
id: `${regionId}-removed-${i}`,
regionId,
type: 'removed',
oldLineNumber: removedOp.oldLineNumber,
segments: removedSegments.length > 0 ? removedSegments : [{ type: 'removed', text: removedOp.text }],
});
rows.push({
id: `${regionId}-added-${i}`,
regionId,
type: 'added',
newLineNumber: addedOp.newLineNumber,
segments: addedSegments.length > 0 ? addedSegments : [{ type: 'added', text: addedOp.text }],
});
}
for (let i = pairCount; i < removedOps.length; i += 1) {
rows.push({
id: `${regionId}-removed-${i}`,
regionId,
type: 'removed',
oldLineNumber: removedOps[i].oldLineNumber,
segments: [{ type: 'removed', text: removedOps[i].text }],
});
}
for (let i = pairCount; i < addedOps.length; i += 1) {
rows.push({
id: `${regionId}-added-${i}`,
regionId,
type: 'added',
newLineNumber: addedOps[i].newLineNumber,
segments: [{ type: 'added', text: addedOps[i].text }],
});
}
return rows;
}
/**
* 计算两段 Markdown 文本的完整 diff。
* @param {string} baseContent
* @param {string} nextContent
* @param {ComputeFullMarkdownDiffOptions} [options]
* @returns {{ rows: FullDiffRow[], regions: AiDiffRegion[] }}
*/
function computeFullMarkdownDiff(baseContent, nextContent, options = {}) {
const normalizedBaseContent = normalizeContent(baseContent);
const normalizedNextContent = normalizeContent(nextContent);
// H3 fix (audit):收集 fallback 警告(行级 cells 超 MAX_LINE_DIFF_CELLS /
// 词级超 MAX_TOKEN_DIFF_CHARS 触发整段替换 / 退化为行级时挂警告),
// 让 controller toast「diff 过大,已退化为整段替换」/「词级已退化为行级」,
// 避免 UI 上看到「整篇被改」以为是 AI 整篇重写。
const warnings = [];
const emitWarning = (msg) => { warnings.push(msg); };
if (normalizedBaseContent === normalizedNextContent) {
return { rows: buildContextRows(splitLines(normalizedNextContent)), regions: [] };
}
const oldLines = splitLines(normalizedBaseContent);
const newLines = splitLines(normalizedNextContent);
const ops = computeDiffOps(oldLines, newLines, emitWarning);
/** @type {FullDiffRow[]} */
const rows = [];
/** @type {AiDiffRegion[]} */
const regions = [];
let index = 0;
while (index < ops.length) {
const op = ops[index];
if (op.type === 'context') {
rows.push({
id: `context-${rows.length}`,
type: 'context',
oldLineNumber: op.oldLineNumber,
newLineNumber: op.newLineNumber,
segments: [{ type: 'equal', text: op.text }],
});
index += 1;
continue;
}
const changeStart = index;
while (index < ops.length && ops[index].type !== 'context') index += 1;
const changedOps = ops.slice(changeStart, index);
const oldLinesInRegion = changedOps.filter((item) => item.type === 'removed').map((item) => item.text);
const newLinesInRegion = changedOps.filter((item) => item.type === 'added').map((item) => item.text);
const beforeContextStart = Math.max(0, changeStart - CONTEXT_SIZE);
const afterContextEnd = Math.min(ops.length, index + CONTEXT_SIZE);
const beforeContext = ops.slice(beforeContextStart, changeStart).filter((item) => item.type === 'context').map((item) => item.text);
const afterContext = ops.slice(index, afterContextEnd).filter((item) => item.type === 'context').map((item) => item.text);
const firstOldLine = changedOps.find((item) => item.oldLineNumber !== undefined);
const firstNewLine = changedOps.find((item) => item.newLineNumber !== undefined);
const firstOldLineNumber = firstOldLine ? firstOldLine.oldLineNumber : undefined;
const firstNewLineNumber = firstNewLine ? firstNewLine.newLineNumber : undefined;
const regionId = `region-${regions.length + 1}`;
// C1 fix (audit):若一个 region 完全没有 beforeContext + afterContext
// 「应用此处」无法唯一定位applyDiffRegionSafely 对纯新增/整篇删除 fallback
// 到 currentLines.length===0 才生效,正常文档永远 conflict。在 UI 显式标 conflict
// 引导用户走「应用全部」。这是 AI 返回「整篇替换」或「在空文档插入内容」时的合理退化。
const needsWholeDocHint = beforeContext.length === 0 && afterContext.length === 0;
regions.push({
id: regionId,
oldStart: firstOldLineNumber !== undefined ? firstOldLineNumber - 1 : Math.max(0, (firstNewLineNumber || 1) - 1),
oldLines: oldLinesInRegion,
newStart: firstNewLineNumber !== undefined ? firstNewLineNumber - 1 : Math.max(0, (firstOldLineNumber || 1) - 1),
newLines: newLinesInRegion,
beforeContext,
afterContext,
...(needsWholeDocHint ? { conflict: '无上下文定位,请使用「应用全部」' } : {}),
});
rows.push(...buildChangedRows(regionId, changedOps, options, emitWarning));
}
// H3 fix (audit):只有真有 fallback 才带 warnings 字段,避免污染 return shape。
return warnings.length > 0 ? { rows, regions, warnings } : { rows, regions };
}
/**
* 用 region 安全替换 currentContent 中对应位置的内容。
* 找不到唯一匹配0 个或多个)时返回 { ok:false, reason },供 UI 标红提示。
* @param {string} currentContent
* @param {AiDiffRegion} region
* @returns {{ ok: true, content: string } | { ok: false, reason: string }}
*/
function applyDiffRegionSafely(currentContent, region) {
const original = String(currentContent);
const normalized = normalizeContent(original);
const currentLines = splitLines(normalized);
const endsWithNewline = normalized.endsWith('\n');
// 保留原稿行尾:检测是否含 CRLF含至少一处即视为 CRLF 文件)。
// 之前 normalizeContent 把 \r\n → \n 后 joinLines 又硬写 \n
// 会把 Windows 用户的 .md 文件静默改成 LF —— 污染文件格式。
const eol = /\r\n/.test(original) ? '\r\n' : '\n';
if (region.oldLines.length > 0) {
// findSequence 现在总是扫描全文(不再走 preferredIndex early-return
// 拿到所有 oldLines 匹配的下标。再用 beforeContext/afterContext 二次过滤,
// 既保留 C3 上下文漂移检测,又能在「多个匹配但只有一个上下文一致」时安全应用。
const allMatches = findSequence(currentLines, region.oldLines, region.oldStart);
if (allMatches.length === 0) {
return { ok: false, reason: '无法安全定位该修改位置' };
}
// C3 fix (audit):多匹配时用 context 过滤,定位唯一正确的位置。
// 场景:用户复制了相同行到多处(如原 c 后面又粘一个 cpreferredIndex 仍
// 命中旧位置,但旧位置的 context 已和 region 算出时不同;其它新位置的
// context 同样不对。三个候选都漂移 → "上下文漂移"。
// 单匹配且有 context 时也照常校验(防止单匹配也漂移的极端情况)。
if (region.beforeContext.length > 0 || region.afterContext.length > 0) {
const contextMatches = allMatches.filter((idx) => {
const beforeActual = currentLines.slice(
Math.max(0, idx - region.beforeContext.length),
idx,
);
const afterActual = currentLines.slice(
idx + region.oldLines.length,
idx + region.oldLines.length + region.afterContext.length,
);
return beforeActual.join('\n') === region.beforeContext.join('\n')
&& afterActual.join('\n') === region.afterContext.join('\n');
});
if (contextMatches.length === 0) {
// 所有匹配位置的 context 都不对 → drift。
return { ok: false, reason: '上下文漂移,无法确定该修改的位置' };
}
if (contextMatches.length > 1) {
// 多处 context 都和 region 一致,但 oldLines 重复了 → 真歧义。
return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' };
}
// 唯一匹配context 一致的位置就是 base 时的位置,安全应用。
const nextLines = currentLines.slice();
nextLines.splice(contextMatches[0], region.oldLines.length, ...region.newLines);
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
}
// 无 context要求 oldLines 全局唯一。
if (allMatches.length !== 1) {
return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' };
}
const nextLines = currentLines.slice();
nextLines.splice(allMatches[0], region.oldLines.length, ...region.newLines);
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
}
// 纯新增:尝试用 beforeContext + afterContext 定位插入点
const preferredIndex = Math.min(Math.max(region.oldStart, 0), currentLines.length);
const beforeMatches = findSequence(currentLines, region.beforeContext);
const afterMatches = findSequence(currentLines, region.afterContext);
/** @type {number | null} */
let insertionIndex = null;
if (
region.beforeContext.length > 0
&& region.afterContext.length > 0
// clamp 切片端点preferredIndex - region.beforeContext.length 在文档开头
// 可能是负数 → JS 的 slice(-N, 0) 会拿"末尾 N 项"误匹配;显式 clamp 到 0。
&& preferredIndex - region.beforeContext.length >= 0
&& currentLines.slice(preferredIndex - region.beforeContext.length, preferredIndex).join('\n') === region.beforeContext.join('\n')
&& currentLines.slice(preferredIndex, preferredIndex + region.afterContext.length).join('\n') === region.afterContext.join('\n')
) {
insertionIndex = preferredIndex;
} else if (beforeMatches.length === 1 && afterMatches.length === 1) {
// P3 fixbefore + after 同时各只有一个匹配,但 preferredIndex 错位了。
// 此时必须校验 before 末尾 === after 起点(即「这两段上下文在文档里相邻」),
// 否则 before 和 after 是两个独立匹配,盲选 before 会插到错位置。
const beforeEnd = beforeMatches[0] + region.beforeContext.length;
if (beforeEnd === afterMatches[0]) {
insertionIndex = beforeEnd;
} else {
// 上下文冲突:让 UI 走 conflict 分支(标灰、应用按钮 disable
return { ok: false, reason: '前后上下文位置冲突,无法确定插入点' };
}
} else if (beforeMatches.length === 1) {
insertionIndex = beforeMatches[0] + region.beforeContext.length;
} else if (afterMatches.length === 1) {
insertionIndex = afterMatches[0];
} else if (currentLines.length === 0) {
insertionIndex = 0;
}
if (insertionIndex === null) {
return { ok: false, reason: '无法安全定位插入位置' };
}
const nextLines = currentLines.slice();
nextLines.splice(insertionIndex, 0, ...region.newLines);
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
}
module.exports = {
computeFullMarkdownDiff,
applyDiffRegionSafely,
};

239
shared/render-sanitize.js Normal file
View File

@@ -0,0 +1,239 @@
// Markdown → 安全 HTML 的核心规则Stage 8与 preload.js 共用)
//
// 职责:
// - 定义 ALLOWED_URI_REGEXPURI scheme 白名单 + 相对路径分支)
// - 注册 uponSanitizeAttribute 钩子(统一走 ALLOWED_URI_REGEXP
// 危险协议 javascript:/vbscript:/data:text\/html/file: 在所有标签上都剥)
// - 导出 renderMarkdown(markdown, dompurifyInstance) → safeHtml
//
// 边界:
// - 不引用 electron / contextBridge / marked —— 由 preload 注入
// - 不导出 DOMPurify 实例(每个进程各自创建,避免泄漏 window
//
// preload.js 与 tests/unit/render-sanitize.test.js 都 require 本文件,
// 保证规则不会在「实现」与「测试」之间漂移。
/**
* 允许的 URI 协议:
* - 前半段:显式 scheme 白名单http/https/mailto/tel/callto/file/data:image/raster
* —— file: 必须先放行DOMPurify 才会回调 uponSanitizeAttribute 钩子
* —— data:image 限定为栅格格式png/jpeg/gif/webp/bmp/ico/tiff—— 显式
* 拒绝 svg+xml/svg 等可携带 JS / XML 外部实体的格式。SVG 数据 URL 在
* `<a href>` 上点击会导航到 top-level SVG 上下文,现代 Chromium 多半
* 拦截脚本执行,但跨浏览器 / 跨版本一致性差。Markdown 用例几乎不需
* SVG 内嵌图raster 已覆盖 99% 场景。
* - 后半段:相对路径分支('img/a.png'、'./other.md'、'/abs/x.png'、'../up.md'
* —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头;
* `(?![/\\]{2})` 显式拒绝「协议相对 URL」`//evil.com/x.png` /
* `\\evil.com\x.png`),避免静默导航到外站
* —— `[a-z+.-]+(?:[\\/][^a-z]*|$)` 覆盖字母开头的相对路径
*
* 危险协议 (javascript:/vbscript:/data:text\/html) 走「字母+冒号」分支——因
* scheme 不在前列、不以 `#` 开头、不含 `/`,全部不匹配。
*
* Windows 路径分隔符 `\`:用户在 Windows 上常写 `![](img\foo.png)`
* 浏览器对 URL 会自动把 `\` 规范化成 `/`Chrome/Firefox 都如此),
* 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。
*/
const ALLOWED_URI_REGEXP =
/^(?:(?:https?|mailto|tel|callto|file):|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,|#|(?:(?![/\\]{2})[/\\]|[^a-z/\\])|[a-z+.-]+(?:[\\/][^a-z]*|$))/i;
// data:image 单 URL 字节上限(防 OOM。10 MB 对正常 AI 输出 + 用户内嵌图
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
const DATA_IMAGE_MAX_URL_LENGTH = 10 * 1024 ** 2;
/**
* 危险 URI scheme —— 在非 IMG 标签(&lt;a href&gt; / &lt;form action&gt; / iframe 等)
* 上剥,包括 file:(导航/数据外流通道。IMG/src 例外file:/// 在 IMG 上是
* Notes 数据目录本地图片路径,需要保留。
*
* audit fix (C1):钩子里先 trim 再匹配。原正则 `^...javascript:...` 锚定首字符,
* 若 DOMPurify 未规范化前导空格 / 控制字符(`&#x20;` 等实体),
* 像 `<a href=" javascript:alert(1)">` 就能绕过钩子。trim 后再 `^` 匹配,
* 同时把 NUL/控制字符一并处理C0 控制字符 0x00-0x1F + 0x7F
*/
const DANGEROUS_URI_NON_IMG_REGEXP = /^(?:javascript|vbscript|data(?!:image\/)|file):/i;
function isDangerousUriNonImg(value) {
if (typeof value !== 'string') return false;
// 剥前导空白 + 控制字符HTML 实体解码后可能留下的 \x00-\x1F / \x7F / 空格 / 换行。
// 用循环 + charCodeAt 比较而非 regex —— ESLint no-control-regex 会拒绝 regex 字面里的控制字符。
let i = 0;
while (i < value.length) {
const code = value.charCodeAt(i);
// 空白:\t(9) \n(10) \v(11) \f(12) \r(13) 空格(32) NBSP(160) 等
// 控制字符0-31 与 127
if (code <= 32 || code === 127 || code === 160) {
i += 1;
continue;
}
break;
}
return DANGEROUS_URI_NON_IMG_REGEXP.test(value.slice(i));
}
/**
* audit fix (Round 9)style 属性里的危险 CSS 模式防御。
*
* 场景:`<a style="background:url(javascript:alert(1))">` —— 上面
* isDangerousUriNonImg 检的是 attrValue **开头**,对「值里嵌套 javascript: URL
* 的 style」无能为力。同理 `style="width:expression(alert(1))"`legacy IE
* `style="-moz-binding:url(...)"`legacy Mozilla XBL、`behavior:url(...)`
* legacy IE HTC
*
* DOMPurify v3 默认对这些模式有部分保护CSS sanitizer 拒 url(javascript:)
* 但行为跨版本 / 跨浏览器一致性差。本函数做防御性深度扫描:发现任一危险模式
* → 整条 style 属性剥掉(保守:宁可错杀不可漏过)。
*
* 同时扫描 @import可绕过 background-image 限定的外链资源加载)和
* url(javascript:|vbscript:|data:text/html|file:) 等危险 URL scheme。
*
* 注意url() 内的空白 / 引号 / 大小写都要容忍 —— 用不区分大小写的 regex
* 容忍 url 关键字后的可选空白。
*/
const DANGEROUS_STYLE_PATTERNS = [
// url(javascript:...) / url("javascript:...") / url('javascript:...')
// 容忍可选空白 + 单/双引号包裹 + 大小写
/url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i,
// legacy IE CSS expression()
/expression\s*\(/i,
// legacy IE HTC behavior
/\bbehavior\s*:\s*url\s*\(/i,
// legacy Mozilla XBL binding
/-moz-binding\s*:/i,
// CSS @import外链资源加载 / CSP 绕过)
/@import/i,
];
// audit fix (Round 13 / Sec-M)CSS 属性 denylist —— 阻止攻击者把整窗当画布。
// 思路Markdown 的合法 inline style 几乎只用 color / font-size / text-align /
// background:url(https://...) 这类纯视觉属性;任何「能改变布局 / 跳出文档流 /
// 遮挡 UI / 隐藏元素 / 让用户看不清真实界面」的属性都属于攻击面。
//
// 受保护列表(每个测试过:
// - `<span style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647">`
// —— 全屏透明覆盖层,配合外链跳转 = 整窗 UI 欺骗 / 点击劫持
// - `<svg><style>.app-shell{display:none}</style></svg>` —— 隐藏 chrome
// 靠 FORBID_TAGS:['svg'] 拦死,这条是给「未来如果放宽 svg」做兜底
// - `.toolbar,.statusbar{visibility:hidden}` —— 视觉欺骗
// - `body::after{content:"会话过期请重新输入 API Key"}` —— 钓鱼覆盖层
// - `pointer-events:none` 让 UI 看着可点但透传到下层
// - `transform: ...` 在 markdown 里几乎不合法使用,且可绕开父级 contain
// - `opacity:0` 让链接看着没东西实际可点
//
// 注意:\b 词边界要求属性名是独立 token`font-size` 不会被误命中 `size`。
const DANGEROUS_STYLE_PROPS = [
'position', 'inset', 'top', 'left', 'right', 'bottom',
'z-index', 'zindex',
'float', 'clear',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'padding',
'transform', 'translate',
'visibility', 'opacity',
'pointer-events',
'content',
// display:none 是合法用法(折叠冗余段落)但 XSS 高敏,宁可错杀。
// 想要折叠段落用引用 styled 容器而不是内联 style。
'display',
];
function isDangerousStyleValue(value) {
if (typeof value !== 'string') return false;
// style 值的注释 / 字符串里也照样扫 —— 攻击者可借 `/* xxx */` 试图绕过;
// 保守策略:任一危险模式出现 → 剥整条 style。
for (const pat of DANGEROUS_STYLE_PATTERNS) {
if (pat.test(value)) return true;
}
// 属性名扫描 —— 把 value 按 `;` 拆成 declaration逐个看属性名是否在
// 黑名单。\b 词边界防 `font-size` 误命中 `size`、防 `text-align` 误命中
// 不存在的 `align`。兼容 `:property` 与 `: property` 两种写法。
for (const decl of value.split(';')) {
const m = decl.match(/^\s*([a-zA-Z-]+)\s*:/);
if (m && DANGEROUS_STYLE_PROPS.includes(m[1].toLowerCase())) return true;
}
return false;
}
/**
* 注册 uponSanitizeAttribute 钩子:
* - &lt;img src&gt; 走 ALLOWED_URI_REGEXP 显式校验(统一策略);
* file:/// 放行ALLOWED 已含javascript:/vbscript:/data:text\/html 等
* 未在白名单的 scheme 一律剥
* - 其他标签的 href/src/action 见到 javascript:/vbscript:/data:text\/html/file:
* → 主动剥
*
* 之前 &lt;img src&gt; 是 keepAttr=true 全放行,靠 DOMPurify v3 内置兜底处理 javascript:
* vbscript: / data:text\/html 在 img 上不会被 DOMPurify 兜底剥掉靠「img 不渲染 html」
* 的策略假设——跨浏览器 / 跨版本一致性差。现在所有 src/href/action 都按 ALLOWED 过滤,
* 单一事实源;同时非 IMG 标签的 file: 仍主动剥(导航 / 数据外流风险)。
*
* 必须在调用 DOMPurifyInstance.sanitize 之前注册一次v3 的钩子是实例级,
* 重启进程后丢失;不需要 removeHook
*
* @param {object} DOMPurifyInstance - 由 createDOMPurify(window) 创建的实例
*/
function installHooks(DOMPurifyInstance) {
if (!DOMPurifyInstance || typeof DOMPurifyInstance.addHook !== 'function') return;
DOMPurifyInstance.addHook('uponSanitizeAttribute', (node, data) => {
const isImgSrc = data.attrName === 'src' && node.tagName === 'IMG';
// <img src> 走显式白名单 —— ALLOWED_URI_REGEXP 已含 file:/data:image/,未匹配即剥
if (isImgSrc) {
if (!ALLOWED_URI_REGEXP.test(data.attrValue)) {
data.keepAttr = false;
return;
}
// audit fix (Round 13 / Sec-H2)file:// 必须严格 file:///... 三斜杠形式
// —— 拒绝 file://host/... 与 file:////host/...。
//
// 威胁Windows 上 Chromium 把 `file://ATTACKER-HOST/share/x.png` 解析为
// UNC 路径 `\\ATTACKER-HOST\share\x.png` 并交给 SMB 客户端去取 —— 系统
// 静默做 NTLMv2 认证,攻击者能直接抓受害者的 domain\user + NTLM response
// CVE-2023-23397 Outlook 那个原语,这里通过 markdown 图片复现)。
// 三斜杠 = "无 host + 绝对路径",是浏览器渲染本地图片的唯一合法形式。
// 写法:要求 file: 后恰好 3 个 /,且第 4 个字符不是 /(即 `////` UNC 也拒)。
if (/^\s*file:/i.test(data.attrValue) && !/^file:\/{3}[^/]/i.test(data.attrValue)) {
data.keepAttr = false;
return;
}
if (
// audit fix (Sec-M3)data:image 单 URL 上限。恶意 markdown 可嵌 5MB+
// base64 图AI 修改场景下尤其:用户 prompt + content + 嵌入图可叠到
// MB 级DOMPurify v3 内置不会卡 base64 长度 → OOM 风险。
// 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。
// 文件绝对路径/file:/https: 不进此分支,行为不变。
data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH
&& /^data:image\//i.test(data.attrValue)
) {
data.keepAttr = false;
}
return;
}
// audit fix (Round 9)style 属性走 isDangerousStyleValue 深度扫描。
// 嵌在 url() 里的 javascript:/expression()/@import 等光靠 attrValue
// 开头扫描抓不到。这里保守:任一危险模式命中 → 整条 style 剥。
if (data.attrName === 'style' && isDangerousStyleValue(data.attrValue)) {
data.keepAttr = false;
return;
}
// audit fix (Round 4 P1-5):非 IMG 的 URI 属性也走 ALLOWED 校验剥 data:image/svg+xml。
// 限制为 URI 类属性href / src / action / formaction / xlink:href / cite / longdesc /
// poster / usemap避免误剥 DOMPurify 自动注入的安全属性(如 target=_blank 时
// 自动加的 rel="noopener noreferrer")—— 那些值不是 URI不该过 ALLOWED 校验。
const URI_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'cite', 'longdesc', 'poster', 'usemap']);
const isUriAttr = URI_ATTRS.has(data.attrName);
if (isDangerousUriNonImg(data.attrValue)) {
data.keepAttr = false;
return;
}
if (isUriAttr && !ALLOWED_URI_REGEXP.test(data.attrValue)) {
// 非 IMG 标签也调一次 ALLOWED_URI_REGEXP.test与 IMG 路径策略对称。
// 之前非 IMG 只走 DANGEROUS负向预查放过 data:image不二次 ALLOWED
// —— 完全依赖 DOMPurify v3 内置 ALLOWED_URI_REGEXP 全局应用兜底。若未来
// DOMPurify 配置改动或行为变更,<a href="data:image/svg+xml;base64,...">
// 会落地;点击进入 top-level SVG 上下文,跨浏览器脚本执行行为不一致。
data.keepAttr = false;
}
});
}
module.exports = { ALLOWED_URI_REGEXP, installHooks };

566
shared/settings-schema.js Normal file
View File

@@ -0,0 +1,566 @@
// 设置 schema —— 单一事实源
// ============================================================================
//
// 这是 main / preload / renderer 三处设置定义的唯一权威来源。
// 之前四处漂移main.js 的 DEFAULT_CONFIG + save-settings 80 行校验链 + renderer
// DEFAULT_SETTINGS + settings-dialog 的选项表)统一收敛到本文件:
//
// • DEFAULT_SETTINGS — 各键的默认值
// • SETTINGS_SCHEMA — 每键的 type / enum / min / max / custom validator
// • validateAndSanitize() — 用 schema 校验+clamp+enumeration
// • SETTINGS_UI_OPTIONS — 对话框用的枚举列表readerFontSize / palette / sort
//
// 加载方式:
// • main.js / preload.jsCJSconst schema = require('../shared/settings-schema.js')
// • renderer经 preload contextBridge 过桥window.api.settingsSchema / coerceLoadedSettings
// —— renderer 跑在 Chromium 原生 ESM不能直接 import CJS无 CJS 互操作),
// .cjs / .js 扩展名都不能解决这个问题,文件 *格式* 决定有没有 named export。
// 所以 renderer 拿到的始终是经 preload 包装过的纯数据 / 函数schema 内部
// 结构不暴露。
//
// 新增/修改设置项流程:
// 1. 在 DEFAULT_SETTINGS 加默认值
// 2. 在 SETTINGS_SCHEMA 加类型/范围/枚举/自定义校验
// 3. 若需要在 UI 显示,在 SETTINGS_UI_OPTIONS 加选项
// 4. 跑 npm run check 验证 — validateAndSanitize 会用 schema 拒绝非法值
// ============================================================================
'use strict';
/**
* 默认值(单一事实源)。所有键都在这里定义,缺一不可。
* @type {Readonly<Record<string, *>>}
*/
const DEFAULT_SETTINGS = Object.freeze({
dataDir: null, // null = 用主进程默认目录home/Notes
theme: 'dark', // 'dark' | 'light'
themePalette: 'default', // 调色板 ID见 SETTINGS_UI_OPTIONS.palettes
alwaysOnTop: false,
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
readerFontSize: 17, // 阅读字号 (px),范围 12..24
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
fileListSort: 'name', // 'name' | 'mtime-desc'
autoSaveDebounceMs: 500, // 自动保存防抖延迟ms0 = 关闭;默认 500ms = 「停打后立刻存」
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
sidebarWidth: null, // 侧栏拖拽后的宽度pxnull = 使用 CSS 默认 --w-sidebar
aiWidth: null, // AI 中间面板拖拽后的宽度pxnull = 使用 CSS 默认 --w-ai
focusMode: false, // 聚焦模式:隐藏工具栏/侧栏/状态栏Ctrl+Shift+F 切换
// AI 修改功能(用户自填 API Key / BaseURL / Model / System Prompt
// 详见 main/ai.js。空值时 AI 入口点击会提示去设置。
aiProvider: 'openai', // 'openai' | 'anthropic' —— 决定 main/ai.js 走哪条协议
aiBaseUrl: '', // baseURLOpenAI 含 /v1如 https://api.openai.com/v1Anthropic 不含(如 https://api.anthropic.com
aiApiKey: '', // API Key敏感数据只在主进程内存里使用不写日志
aiModel: '', // 模型名OpenAI 如 gpt-4o-miniAnthropic 如 claude-opus-5
aiSystemPrompt: '', // 自定义系统提示词;空 = 用 main/ai.js 内置中文 prompt
});
/**
* 每个键的校验规则。
* type: 'string' | 'number' | 'boolean' | 'enum' | 'nullable-string' | 'nullable-path'
* enum?: 仅 enum 类型:允许的字面量数组
* min?: number / integer / string(长度)
* max?: number / integer / string(长度)
* clamp?: number: 是否 clamp 到 [min,max]
* integer?:boolean number 是否取整
* round?: number: 保留几位小数
* choices?:Array<{value, label, hint?}> UI 选项(仅供 dialog 渲染,不参与校验)
*
* 自定义校验(如 dataDir 必须存在)放在 validateAndSanitize() 里集中处理,
* 因为它需要 fs 调用,不能纯声明式表达。
*
* @type {Readonly<Record<string, object>>}
*/
const SETTINGS_SCHEMA = Object.freeze({
dataDir: {
type: 'nullable-path',
description: '数据目录null = 用默认目录',
},
theme: {
type: 'enum',
enum: ['dark', 'light'],
description: 'UI 主题',
},
themePalette: {
type: 'enum',
enum: ['default', 'ocean', 'forest', 'lavender', 'sunset'],
description: '调色板',
},
alwaysOnTop: {
type: 'boolean',
description: '窗口置顶',
},
editorMode: {
type: 'enum',
enum: ['preview', 'edit', 'split'],
description: '视图模式',
},
readerFontSize: {
type: 'number',
min: 12,
max: 24,
integer: true,
clamp: true,
description: '阅读字号 (px)',
},
readerLineHeight: {
type: 'number',
min: 1.4,
max: 2.2,
// fix(audit 2026-08)round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数round:2 才能保留用户的选择。
round: 2,
description: '阅读行距',
},
fileListSort: {
type: 'enum',
enum: ['name', 'mtime-desc'],
description: '侧栏文件列表排序',
},
autoSaveDebounceMs: {
type: 'number',
min: 0,
max: 60000,
clamp: true,
integer: true,
description: '编辑停止后多少毫秒触发自动保存0 = 关闭',
},
splitRatio: {
type: 'number',
min: 0.2,
max: 0.8,
round: 3,
clamp: true,
description: '双栏模式左侧占比',
},
sidebarWidth: {
type: 'nullable-number',
min: 120,
max: 480,
integer: true,
description: '侧栏宽度 (px)null = 用 CSS 默认',
},
aiWidth: {
type: 'nullable-number',
min: 220,
max: 720,
integer: true,
description: 'AI 中间面板宽度 (px)null = 用 CSS 默认 --w-ai (360)',
},
focusMode: {
type: 'boolean',
description: '聚焦模式(隐藏工具栏/侧栏/状态栏)',
},
aiProvider: {
type: 'enum',
enum: ['openai', 'anthropic'],
description: 'AI 服务提供方openai = /chat/completionsanthropic = /v1/messages',
},
aiBaseUrl: {
type: 'string',
// URL 不会超过几 KB留 4 KB 足够;防止有人塞几 MB 把请求体打爆
max: 4_096,
// audit fix (#9 shared):格式校验。空串放行(用户主动清空 = 关闭 AI
// 非空必须是可解析的 http(s) URL避免「abc / www.foo.com / file:///xxx」
// 这类带空格 / 漏 scheme / 协议错误的值被静默接受,最后在主进程 fetch 时
// 才抛 TypeError错误信息很难定位到 settings。trim 后用 URL 解析,
// 协议限定 http: 或 https:(不接 ftp / file / data 等)。
format: 'url-https',
description: 'baseURLOpenAI 含 /v1如 https://api.openai.com/v1Anthropic 不含(如 https://api.anthropic.com',
},
aiApiKey: {
// 密码字段renderer 通过 IPC 传给主进程,不在 schema 校验链里打印
type: 'string',
// API key 通常 50~200 字符;留 4 KB 上限足够
max: 4_096,
description: 'API Key',
},
aiModel: {
type: 'string',
max: 256,
description: '模型名',
},
aiSystemPrompt: {
type: 'string',
// 系统提示词较长是合理的,但单个几十 MB 的 prompt 会拖慢 JSON.stringify
// 且让 AI 计费爆炸 —— 200 KB 对应约 5 万中文字,足够绝大多数场景
max: 200_000,
description: '自定义系统提示词;空 = 用内置默认',
},
});
/**
* UI 选项表 —— 仅供对话框渲染使用。
* key 与 SETTINGS_SCHEMA 的 enum 对齐,但额外带 label / hint。
* @type {Readonly<Record<string, ReadonlyArray<{value: *, label: string, hint?: string}>>>}
*/
const SETTINGS_UI_OPTIONS = Object.freeze({
themePalette: Object.freeze([
{ value: 'default', label: '默认' },
{ value: 'ocean', label: '海洋' },
{ value: 'forest', label: '森林' },
{ value: 'lavender', label: '薰衣草' },
{ value: 'sunset', label: '夕阳' },
]),
fileListSort: Object.freeze([
{ value: 'name', label: '按名称', hint: 'A → ZlocaleCompare(zh-CN)' },
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
]),
// 自动保存不再需要枚举选项debounce 延迟是连续数值0..60000 ms
// 由 toolbar 按钮直接 toggle 0 ↔ 500UI 也不再有「选几秒」的下拉。
readerFontSize: Object.freeze([14, 15, 17, 19, 22]),
readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]),
// 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商
// 用户可填任意走该协议的 baseURL中转、自部署、官方 API 都行)。
aiProvider: Object.freeze([
{ value: 'openai', label: 'OpenAI 兼容', hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等' },
{ value: 'anthropic', label: 'Anthropic 兼容', hint: '/v1/messages · Claude 系列 · 含第三方中转' },
]),
});
/**
* 校验 + sanitize 单个键。
* 返回 { ok:true, value } 或 { ok:false, error }。
*
* @param {string} key
* @param {*} raw
* @param {object} [opts] - { resolveDir?: async (path) => { ok, error? } }
* 注入目录存在性校验(默认走 fs调用方可传 mock
* @returns {Promise<{ok: true, value: *} | {ok: false, error: string}>}
*/
async function validateKey(key, raw, opts = {}) {
const rule = SETTINGS_SCHEMA[key];
if (!rule) {
// 未知键直接拒绝(防止 renderer 误传)
return { ok: false, error: `未知设置项: ${key}` };
}
// nullable 类型null 一律放行
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
return { ok: true, value: null };
}
// 类型分发
switch (rule.type) {
case 'nullable-path': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
const trimmed = raw.trim();
if (!trimmed) return { ok: true, value: null };
if (opts.resolveDir) {
const r = await opts.resolveDir(trimmed);
if (!r.ok) return { ok: false, error: r.error };
}
return { ok: true, value: trimmed };
}
case 'nullable-string': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
if (typeof rule.max === 'number' && raw.length > rule.max) {
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
}
return { ok: true, value: raw };
}
case 'string': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串` };
// P2 fixtrim 首尾空白 —— 用户复制粘贴 AI Key / Model 经常带回车 / 空格,
// 不 trim 会让 main/ai.js 把它当字面量放进 Authorization header / 请求体,
// 服务端校验「key 不匹配」但错误信息没有「多打了空格」的提示,用户无从下手。
// 用户手动改 settings.json 也经常留尾空格。
const trimmed = raw.trim();
// rule.max 限定字符串长度 —— 防止几 MB 的值把请求体撑爆 / 拖慢序列化。
// 用户看到的错误直接说"过长",避免报"非法 JSON"。
// audit fix之前误写成 opts.max导致 aiApiKey/aiBaseUrl/aiModel/
// aiSystemPrompt 声明的上限从未生效。)
if (typeof rule.max === 'number' && trimmed.length > rule.max) {
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
}
// audit fix (#9 shared):可选 format 校验。空串放行(清空合法);
// 非空按 format 规则走,失败给中文错误。
if (trimmed && rule.format === 'url-https') {
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, error: `${key} 不是合法的 URL需以 http:// 或 https:// 开头)` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `${key} 必须是 http(s) URL当前协议 ${parsed.protocol}` };
}
}
return { ok: true, value: trimmed };
}
case 'boolean': {
if (typeof raw !== 'boolean') return { ok: false, error: `${key} 必须是布尔` };
return { ok: true, value: raw };
}
case 'number': {
// audit C3拒绝 null / undefined / 空串 / 非数字 —— Number('') === 0 会
// 让 `readerFontSize: ''` 静默通过 clamp 到 min这是 bug不是「合法 0」。
// 合法输入:实际数字 或 非空数字字符串。
if (raw === null || raw === undefined) {
return { ok: false, error: `${key} 必须是数字` };
}
if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字` };
}
const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字` };
let v = n;
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
}
return { ok: true, value: v };
}
case 'nullable-number': {
// audit fix (Round 9):与 'number' 分支对齐显式拒绝 null / undefined /
// 空串 / 非数字。Number('') === 0 会让 nullable-number 字段把空串静默
// 转成 0 再 clamp 到 min —— 与上面 'number' 分支同款 bug。null 是
// 合法值(保留为 null让 UI 显示「未设置」),但空串 / 非数字必须拒绝。
//
// 审计修复 (Round 11 deep-fix P2-3)undefined 在 nullable 字段应等价于 null。
// 旧版异步路径拒绝 undefined → 同步 sanitizeSync 路径却把 undefined 当作 null
// 手改 config.json 时如果字段被删JSON.stringify 会序列化成 undefined → 字段缺失,
// 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。
// 现在 undefined 走「视为 null」分支。
if (raw === null || raw === undefined) return { ok: true, value: null };
if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字或 null` };
}
const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` };
let v = n;
if (rule.integer) v = Math.round(v);
if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
}
return { ok: true, value: v };
}
case 'enum': {
// enum值在列表内即放行兼容 string / number 字面量;如 0 也要命中)
const allowed = rule.enum.some((e) => e === raw) ||
(typeof raw === 'string' && rule.enum.includes(raw));
if (!allowed) return { ok: false, error: `${key} 取值非法: ${raw}` };
return { ok: true, value: raw };
}
default:
return { ok: false, error: `${key} 类型未定义: ${rule.type}` };
}
}
/**
* 批量校验 + sanitize。
* 跳过未在 partial 里出现的键(局部更新)。
*
* @param {object} partial
* @param {object} [opts] - 同 validateKey
* @returns {Promise<{ok: true, sanitized: object} | {ok: false, error: string}>}
*/
async function validateAndSanitize(partial, opts = {}) {
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
return { ok: false, error: '请求体必须是对象' };
}
/** @type {Record<string, *>} */
const sanitized = {};
for (const key of Object.keys(partial)) {
const r = await validateKey(key, partial[key], opts);
if (!r.ok) return r;
sanitized[key] = r.value;
}
return { ok: true, sanitized };
}
/**
* 与默认合并 + 校验完整 settings 对象(启动 / 读取配置文件时用)。
*
* 同步校验 —— 故意不 await fs 检查 dataDir 存在性load 阶段不阻塞);
* dataDir 存在性推迟到 settings-store.update 时再走 validateAndSanitize。
* 但同步可校验的部分enum / 范围 / 类型)必须现在就做,否则手改的
* config.json 会把整个 UI 弄坏audit #8
*
* @param {*} raw
* @returns {Record<string, *>}
*/
function coerceLoadedSettings(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ...DEFAULT_SETTINGS };
}
// 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖)
// 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位
// (旧 3 秒、10 秒)一律映射到新的 500ms 默认值,避免老用户升级后自动保存
// 突然变得非常激进1s 内反复触发或太迟10s 才一次)。
if ('autoSaveIntervalSec' in raw) {
const old = raw.autoSaveIntervalSec;
if (!('autoSaveDebounceMs' in raw)) {
const offish = old === 0 || old === '0' || old === false;
raw.autoSaveDebounceMs = offish ? 0 : 500;
}
delete raw.autoSaveIntervalSec;
}
/** @type {Record<string, *>} */
const out = { ...DEFAULT_SETTINGS };
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (!(key in raw)) continue;
const sanitized = sanitizeSync(key, raw[key]);
// sanitizeSync 返回 undefined 表示「未知键 / 不可修复」,跳过即可
if (sanitized !== undefined) out[key] = sanitized;
}
// Phase N 修复:保留白名单内的 `_`-前缀元数据键(当前只有 `_hasAiKey`)。
// 之前只迭代 Object.keys(DEFAULT_SETTINGS) 把未声明的键全 drop —— get-settings
// 返回的 `_hasAiKey: !!cfg.aiApiKey` 在 coerce 阶段被吃掉renderer 永远拿不到
// "已配置 API Key" 信号,"显示已填 key" + reveal 流程全失效。
//
// 防御:必须用白名单而不是"所有下划线前缀键都过"。否则攻击者 / 误用方可用
// `_xxx` 形式把任意字段塞进内存 settings虽然不会写盘但能在内存里残留
// 白名单维护成本低(已知元数据键只有少数几个),但放行成本高。
const METADATA_KEYS = new Set(['_hasAiKey']);
for (const key of Object.keys(raw)) {
if (METADATA_KEYS.has(key) && !(key in out)) {
out[key] = raw[key];
}
}
return out;
}
/**
* 同步版本的单键 sanitize —— 只做不依赖 IO 的检查。
* 与 validateKey 共享规则,但把 fs 检查nullable-path推迟到 update 阶段。
*
* @param {string} key
* @param {*} raw
* @returns {*} 合法值;不可修复时返回 undefined调用方应忽略这个键
*/
function sanitizeSync(key, raw) {
const rule = SETTINGS_SCHEMA[key];
if (!rule) return undefined; // 未知键:丢弃
// nullablenull 合法
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
return null;
}
switch (rule.type) {
case 'nullable-path':
case 'string': {
if (typeof raw !== 'string') return undefined;
// audit fix (Round 4 P1-1)nullable-path 与 validateKey 异步路径对齐——空串归一为 null。
// 之前 sanitizeSync 直接返回 ''coerceLoadedSettings 把磁盘上残留的
// `"dataDir": ""` 保留为 '',但 validateKey 异步路径会归一为 null两条路径
// 语义不同步 → 任何依赖 dataDir === null 判断的代码失配resolveDataDir
// 靠 .trim() 兜底不崩但漏检 null 路径)。
if (rule.type === 'nullable-path' && raw.trim() === '') return null;
// 同步路径coerceLoadedSettings / sanitizeSync也要尊重 max —— 用户
// 手工改坏 settings.json 时同样不能让几 MB 的字符串进入运行时。
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// 审计修复 (Round 11 deep-fix P2-3):同步路径也 trim前后空白不再让 URL 校验
// 失败。async validateKey 早就 trimRound 8 fix同步路径遗漏导致
// load(): 不 trim 直接 new URL(' https://api.example.com ')
// update(): trim 后校验
// 两次读同一字段返回不同值UI 看着值变了(实际上是同一字符串的展示差异)。
// 注意trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
// 但保留 nullable-path 上面已经拦截过的场景)。
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw;
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKeyasync 路径)
// 校验 formatcoerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
// 写成 "not-a-url" / "ftp://xxx" 也会被加载,渲染端拿到的值是无效 URL
// 真正 fetch 时才报 TypeError: fetch failed错误链很难定位到 settings。
if (trimmed.length > 0 && rule.format === 'url-https') {
// 必须前缀严格是 http:// 或 https://,避免 'http:/missing-slash' 这种
// URL 构造器能解析但实际 fetch 行为不一致的 case。
if (!/^https?:\/\//i.test(trimmed)) return undefined;
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return trimmed;
}
case 'nullable-string': {
if (typeof raw !== 'string') return undefined;
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// fix(audit 2026-08)nullable-string 与 string 一样需要 format 校验,
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
if (raw.length > 0 && rule.format === 'url-https') {
if (!/^https?:\/\//i.test(raw)) return undefined;
let parsed;
try {
parsed = new URL(raw);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return raw;
}
case 'boolean':
return typeof raw === 'boolean' ? raw : undefined;
case 'number': {
// fix(audit 2026-08):拒绝 Boolean 输入。Number(true) === 1,
// Number(false) === 0 —— 旧版会让 autoSaveDebounceMs: true 静默变 1ms
// (激进到每个 keystroke 都存splitRatio: false 变 0无预览面板
// 合法输入限定为 number 或非空数字字符串。
if (raw === null || raw === undefined) return undefined;
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
if (typeof raw === 'string' && raw.trim() === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
// clamp 后还在范围外(例如 raw 是 NaN / Infinity→ 拒收
if (v < rule.min || v > rule.max) return undefined;
return v;
}
case 'nullable-number': {
// audit fix (Round 4 P2-3):与 number 分支对称——拒绝 Boolean 输入。
// 原版无 typeof 守卫Number(true) === 1 隐式通过 isFinite目前 schema
// 用 nullable-number 的字段sidebarWidth / aiWidthmin 检查会拦下 1
// 但语义上与 number 不一致,且未来加更宽 min 范围的字段会绕过。复制上方
// number 分支的 typeof 守卫保持两条路径对称。
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
if (typeof raw === 'string' && raw.trim() === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
if (rule.integer) v = Math.round(v);
if (v < rule.min || v > rule.max) return undefined;
return v;
}
case 'enum': {
// 数字 enum 接受 number字符串 enum 接受 string 字面量
const ok = rule.enum.some((e) => e === raw)
|| (typeof raw === 'string' && rule.enum.includes(raw));
return ok ? raw : undefined;
}
default:
return undefined;
}
}
module.exports = {
DEFAULT_SETTINGS,
SETTINGS_SCHEMA,
SETTINGS_UI_OPTIONS,
validateKey,
validateAndSanitize,
coerceLoadedSettings,
};

78
shared/slug.js Normal file
View File

@@ -0,0 +1,78 @@
// Heading slug 的唯一算法preload + renderer 共用)
//
// 唯一调用点:把 heading 文本转成 [A-Za-z0-9-]+ 形式的 slug id。
// 因为 preloadmarked renderer.heading和 renderer 端都会
// 产生「指向同一 DOM 节点」的 id必须用同一份算法 —— 否则
// 文内锚点会因 id 不一致定位失败。
//
// 设计取舍:
// - 保留 \p{L} / \p{N} / \p{M}CJK含扩展平面 A/B/...+ 拉丁扩展字母都能保留
// - 删除 markdown 行内 HTML 标签 / 反引号 / 星号 / 下划线 / 波浪号:
// 这些「标记符号」不应进 id否则直接复制渲染出的 id 会得到带引号的字符串
// (下划线一并剥离,与 marked 旧默认行为一致GitHub 是保留的,
// 但那会让 `hello_world` / `_em_` 这种 heading 算成同一个 base撞名重
// - 空白 → `-`;不裁首尾 `-`:避免 `# --foo--` → `foo` 这种出乎用户意料的别名
// - 纯符号 / 空白输入返回 `''`:由 `slugifyHeading`(带 `seenSlugs` 的版本)
// 兜底成 `'section'`,保证渲染出的 DOM id 非空可点击
//
// 这份文件被两类消费者使用:
// - preload.jsCJSrequiremarked renderer 决定 DOM 上的真实 id
// - src/outline.jsrenderer ESM源码级镜像一份renderer 不能 import CJS
// 改算法 = 同步改两边 + 测试 + 检查 outline.test.js 与 slug.test.js 的期望值。
'use strict';
/**
* 把 heading 文本转成「基础 slug」无重复检测、无空值兜底
*
* 返回空串意味着:原文剥完 HTML + 行内标记后什么都不剩(纯符号 heading
* outline.js 用这一点判断是否要跳过这个 heading导航没意义
* preload.js 的 slugifyHeading 把它当 base再走 `|| 'section'` 兜底 + 撞名加后缀。
*
* @param {string} raw
* @returns {string}
*/
function slugifyHeadingBase(raw) {
// audit fix (shared-M5):先 normalize('NFC') 把 NFD 字符串(如 macOS
// 默认文件系统产出的 café 这种「e + 组合 ́」)合并成预组合字符,
// 再做后续 replace。否则 NFD 与 NFC 的同一逻辑 heading 会生成两个不同
// DOM idoutline 点击就会跳到错误锚点。Windows / WSL / 云盘同步经常会
// 带来混合 normalization这个守卫保证 slug 只看逻辑字符。
return String(raw)
.normalize('NFC')
.replace(/<[^>]*>/g, '') // 行内 HTML
.replace(/[`*_~]/g, '') // 行内标记符号
.trim()
.toLowerCase()
// \p{L} = 任意 Unicode 字母(含中日韩),\p{N} = 数字,\p{M} = 组合记号
.replace(/[^\p{L}\p{N}\p{M}\s-]/gu, '')
.replace(/\s+/g, '-');
}
/**
* 带重复检测的 slug 生成器。
*
* 同名 heading 在文档内会得到 `-1`、`-2`、... 后缀marked 默认行为)。
* 调用方负责在每次「解析整篇文档」前清空 `seenSlugs`,让计数按文档重置。
* 空 base 回退 `'section'`,保证 id 始终非空可点击。
*
* @param {string} raw
* @param {Set<string>} seenSlugs - 已被本次解析用过的 slug 集合
* @returns {string}
*/
function slugifyHeading(raw, seenSlugs) {
const base = slugifyHeadingBase(raw) || 'section';
let slug = base;
let i = 1;
while (seenSlugs.has(slug)) {
slug = `${base}-${i}`;
i += 1;
}
seenSlugs.add(slug);
return slug;
}
module.exports = {
slugifyHeadingBase,
slugifyHeading,
};

453
src/ai/ai-chat-panel.js Normal file
View File

@@ -0,0 +1,453 @@
// AI 对话面板(底部 dock
// ============================================================================
//
// 给一段自然语言修改要求 + 当前文件内容,发给主进程调 AI。
// 状态机idle → submitting → (success | error | cancelled) → idle
// 不持有 diff 数据diff 走 AiDiffPanel / AiController
//
// 职责边界:
// - 状态展示status / error 通过 onNotify 转发到状态栏 #status-ai chip
// - 收集用户输入
// - 防 IMEcompositionstart/end 期间不响应 Enter
// - 失败重试:失败后用户改完提示词直接按 Enter 重新提交,不用关面板
//
// 状态展示迁移([feedback-ai-tips-into-status-bar]):原本写在自己
// .ai-chat-status-row 的过程/错误文案,现在通过 onNotify(text, type) 转发
// 给外部(通常是 statusbar 上的 AI chip。dock 本身只剩输入行,不再占底部一行。
// ============================================================================
import { AI_ERROR } from './ai-status.js';
function createRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `ai-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 长提示词软警告阈值按字符计UTF-8 近似字节CJK 是 3 字节/字符,这里保守
* 按 ASCII 1 字节算,实际 CJK 阈值会更大一些,方向是「宁可漏报也不骚扰」)。
* 32KB 是经验阈值:超过后多数模型服务开始明显变慢,部分服务开始截断。
*/
const LONG_PROMPT_BYTES = 32 * 1024;
/**
* 把 controller 的中性状态文案映射到 chip 的视觉类型。
* panel 这一侧不深推;不识别就回退 'success'(中性 muted
* - 空文本 → 'idle'chip 回到"无动作"中性态,只显示 "AI" 标签)
* - '正在发送...' → running
* - '正在取消...' → running用户操作中
* - '已取消...' → cancelled
* 其它走 success。controller 在三处显式调 onNotify(_, 'diff'),不走这条路径。
*
* 注:空文本返回 'success' 会让 chip 的 success dot 一直亮着,掩盖「已回到
* 无动作态」的信号 —— 用户看到 chip 高亮会以为还有事在跑。所以空文本走 'idle'.
*
* @param {string} text
* @returns {'idle'|'running'|'cancelled'|'success'}
*/
function inferStatusType(text) {
if (!text) return 'idle';
if (text.indexOf('正在发送') === 0 || text.indexOf('正在取消') === 0) return 'running';
if (text.indexOf('已取消') === 0) return 'cancelled';
// 'info' 是 panel 主动调的(如长提示词提示),不参与被动推断
return 'success';
}
export class AiChatPanel {
/**
* @param {object} options
* @param {HTMLElement} options.element - 容器 div已在 index.html 写好骨架)
* @param {(payload: {prompt:string, requestId:string}) => Promise<{ok:boolean, id?:string, content?:string, responseFormat?:string, error?:string, message?:string}>} options.onSubmit
* @param {(requestId:string) => void} options.onCancel
* @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify]
* 把过程/错误文案转发到外部 UIstatusbar 上的 #status-ai chip。不传则静默丢弃。
*/
constructor({ element, onSubmit, onCancel, onNotify }) {
this.element = element;
this.onSubmit = onSubmit;
this.onCancel = onCancel;
this.onNotify = typeof onNotify === 'function' ? onNotify : null;
this._open = false;
this._enabled = true;
this._submitting = false;
this._composing = false;
this._currentRequestId = null;
this._cancelled = null; // Set被用户取消的 requestId 集合;懒创建于 _triggerCancel
this._listeners = [];
this._bind();
}
_bind() {
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
const toggleBtn = this.element.querySelector('.ai-chat-toggle');
if (input) {
this._listeners.push([input, 'input', () => {
this._syncSubmitButton();
this._syncLongPromptHint();
}]);
this._listeners.push([input, 'compositionstart', () => { this._composing = true; }]);
this._listeners.push([input, 'compositionend', () => { this._composing = false; }]);
// 兜底IME 异常不发 compositionend 时_composing 会永远卡 true
// 导致 Enter 永久失效。blur 时强制重置(用户切走时输入已结束)。
this._listeners.push([input, 'blur', () => { this._composing = false; }]);
this._listeners.push([input, 'keydown', (e) => {
// Enter = 提交(不 ShiftIME 输入中不发
if (e.key === 'Enter' && !e.shiftKey && !this._composing) {
e.preventDefault();
this._triggerSubmit();
}
}]);
}
// 没有 .ai-chat-form 包装时index.html 当前结构),按钮点击就是触发的来源
if (submitBtn) {
this._listeners.push([submitBtn, 'click', (e) => {
// submitting 时:点击 = 取消(需要在 _triggerSubmit 之前 preventDefault
if (this._submitting) {
e.preventDefault();
this._triggerCancel();
return;
}
// 否则:提交
e.preventDefault();
this._triggerSubmit();
}]);
}
if (toggleBtn) {
this._listeners.push([toggleBtn, 'click', () => this.close()]);
}
for (const [el, ev, fn] of this._listeners) {
el.addEventListener(ev, fn);
}
}
open() {
this._open = true;
this.element.hidden = false;
// C1 fix (audit)close→open 路径上若 _submitting 状态残留IPCF 还没回包),
// input.disabled 仍为 true输入框会被永久禁用。这里走 _inputShouldBeDisabled()
// 合并所有禁用条件_submitting 自身的回包路径仍会通过 finally 再次同步状态。
const input = this.element.querySelector('.ai-chat-input');
if (input) input.disabled = this._inputShouldBeDisabled();
this._syncSubmitButton();
// 自动聚焦输入框(用户点按钮来开)
requestAnimationFrame(() => {
const el = this.element.querySelector('.ai-chat-input');
if (el && !el.disabled) el.focus();
});
}
close() {
this._open = false;
this.element.hidden = true;
// 关闭时若还有请求:撤销
if (this._submitting) this._triggerCancel();
// P3 fixhung 请求兜底 —— _triggerCancel 只是把 cancel 发给主进程;如果 AI
// 服务永远不回包_triggerSubmit 的 await 永远不会 resolvefinally 永远跑不到,
// _submitting 卡在 true。close 不强制复位,下次 open 时用户按提交会被
// `if (this._submitting) return` 挡住、误以为按钮坏了。
// audit fix (Phase O-L18):原 `if (this._submitting && this._canceling)` 是
// 死代码 —— _canceling 从未置 trueline 376-377 注释明确说不设),整个 if
// 分支永远不进。简化:仅看 _submittingnotify 不动(让残留的 in-flight
// 旧请求自己跑完)。输入框 / 输入按钮的 disabled 由下次 open() + setEnabled() 同步。
if (this._submitting) {
this._submitting = false;
this._currentRequestId = null;
this._syncSubmitButton();
}
// 关掉 dock 时清掉状态栏 chip —— 否则 chip 会留下上次的"已应用"或"AI 请求失败"
// 残留,让用户误以为是新请求的结果
this._notify('', 'idle');
}
isOpen() {
return this._open;
}
/**
* 当主进程给出新内容(成功路径),清空输入框。
* 失败 / 取消保留输入框,方便用户改完直接重试。
*/
resetInput() {
const input = this.element.querySelector('.ai-chat-input');
if (input) input.value = '';
this._syncSubmitButton();
}
/**
* 强制重置整个面板状态status / error / submitting / 输入框 disabled
* controller 在 onFileChanged / closeAll 时调用,避免切文件后残留
* "正在提交…" 状态 / 错误行。
*
* Bug-3 fix (audit):同时清掉 _cancelled Set —— 切文件/重置时可能还有
* "用户取消但 await 还没回" 的 requestId 留在 Set 里。reset 不清的话:
* 1. 用户在新文件重新提交 → 触发 _triggerSubmit 的 cancelled.has(requestId)
* 检查时,可能误命中旧 requestIdUUID 冲突概率虽低,但存在);
* 2. Set 只增不减,长期使用会有轻微内存泄漏。
* _cancelled 是懒创建_triggerCancel 第一次调用时),所以这里要 lazy-init。
*/
reset() {
this._submitting = false;
this._canceling = false;
this._currentRequestId = null;
if (this._cancelled) this._cancelled.clear();
const input = this.element.querySelector('.ai-chat-input');
if (input) {
input.disabled = !this._enabled;
input.value = '';
}
this._notify('', 'idle');
this._syncSubmitButton();
}
/**
* 没有打开文件 / 没有可修改内容时,禁用面板。
* @param {boolean} enabled
*/
setEnabled(enabled) {
this._enabled = !!enabled;
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
if (input) {
input.disabled = this._inputShouldBeDisabled();
input.placeholder = this._enabled ? '描述你想如何修改当前文档...' : '请先打开一个 Markdown 文件';
}
if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled();
}
/**
* 单一来源:输入框 / 提交按钮的 disabled 取值。
* 把"未启用 + 提交中 + 空输入"三个条件集中在一处 —— open() / setEnabled() /
* _syncSubmitButton() / finally 路径都走这里,避免某条路径漏合并 _submitting 状态
* 而让用户在 AI 响应期间又能输入新内容(会让新输入框内容与正在响应的内容竞速)。
* @returns {boolean}
*/
_inputShouldBeDisabled() {
return !this._enabled || !!this._submitting;
}
/**
* 应用启动后调用:根据"是否有打开的文件"同步 disabled。
* @param {boolean} hasFile
*/
syncFileState(hasFile) {
this.setEnabled(hasFile);
}
_syncSubmitButton() {
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
if (!submitBtn) return;
if (this._submitting) {
submitBtn.textContent = '取消';
submitBtn.dataset.mode = 'cancel';
// a11y #1critical按钮文字在「生成修改 / 取消」之间切换时,
// aria-label 必须同步 —— 屏幕阅读器只读 aria-label不读 textContent
// 否则用户听到的还是 "生成修改",与可见状态完全失配。
submitBtn.setAttribute('aria-label', '取消 AI 请求');
} else {
const empty = !input || input.value.trim().length === 0;
submitBtn.textContent = '生成修改';
submitBtn.dataset.mode = 'submit';
submitBtn.setAttribute('aria-label', '生成 AI 修改');
// 非 submitting 时只受「未启用 + 空输入」影响_inputShouldBeDisabled 已隐含 !_submitting
submitBtn.disabled = !this._enabled || empty;
}
}
/**
* audit fix (Phase L3-AI 1):超长提示词的软警告。
*
* 用户在 AI dock 里粘贴一大段(比如复制整个章节当 prompt主进程要把全文
* 拼进 IPC payload 一次性送给模型服务。提示词越长IPC 序列化 / 网络上传 /
* 模型处理时间都线性涨,且超出模型上下文窗口会被服务端截断甚至 400。
*
* UX仅在 input value 跨过 LONG_PROMPT_BYTES≈32KB阈值时向 onNotify
* 推一条短提示到 statusbar 的 #status-ai chip用户删回阈值下后立刻清回
* idle不污染后续 submit/cancel 的 chip 文案)。
*
* 注意:不算 input.value 实际 trim 后的 prompt 字节——用户粘的是「即将提交的
* 文本」input.value 是最接近的真相trim 后的差异在 32KB 量级上无意义。
* 不要在这里 throw —— 监听器抛错会让 input 整体崩(绑定时未用 { signal }
* addEventListener 会把异常往上抛到 dispatch
*/
_syncLongPromptHint() {
const input = this.element.querySelector('.ai-chat-input');
if (!input) return;
// chip 已被 submit / cancel / error 占用时不抢戏;只在 idle 状态下显示提示
if (this._submitting) return;
const len = input.value.length;
if (len >= LONG_PROMPT_BYTES) {
const kb = Math.round(len / 1024);
this._notify(`提示词较长(约 ${kb} KBAI 响应可能变慢`, 'info');
} else if (len === 0) {
// 空输入让 chip 回到 idle用户已清空提示词就别再保留长提示
this._notify('', 'idle');
}
// 中间区间(>0 且 < 阈值)保持现状:不打扰用户
}
async _triggerSubmit() {
if (!this.onSubmit) return;
if (this._submitting) return;
if (!this._enabled) return;
const input = this.element.querySelector('.ai-chat-input');
const prompt = (input && input.value || '').trim();
if (!prompt) {
// M7 fix (audit):守卫失败的分支也会被 Enter 触发(例如上一次 success 后用户
// 直接回车提交空字符串)。早返回前把 chip 清回 idle避免 chip 残留上一次的
// success / running 视觉误导用户以为"还有事在跑"或"已经提交过了"。
this._notify('', 'idle');
return;
}
const requestId = createRequestId();
this._currentRequestId = requestId;
this._submitting = true;
this._canceling = false;
this._notify('正在发送请求…', 'running');
this._syncSubmitButton();
if (input) input.disabled = true;
try {
const result = await this.onSubmit({ prompt, requestId });
// 已被新请求顶掉(用户点了"取消"或再次提交):静默
if (this._currentRequestId !== requestId) return;
// P1-3 fix (audit):用户主动点了「取消」→ 忽略该 requestId 的响应,
// 不 resetInput不展示 AI 结果。
if (this._cancelled && this._cancelled.has(requestId)) {
this._cancelled.delete(requestId);
this._notify('已取消 AI 请求', 'cancelled');
return;
}
if (result && result.ok) {
// 成功状态文案由 controller 算(依赖是否有 diff后调用 setStatus
this.resetInput();
// 输入框保持 disabled 由 setEnabled 决定
} else if (result && result.error === AI_ERROR.ERR_CANCELLED) {
this._notify('已取消 AI 请求', 'cancelled');
} else {
const msg = (result && result.message) ? result.message : 'AI 修改失败';
this._notify(msg, 'error');
}
} catch (e) {
if (this._currentRequestId !== requestId) return;
// L17 fix (audit)catch 分支的 e.message 可能来自 IPC 序列化失败 / 主进程
// 抛错 / controller 内部 bug里面可能含路径 / stack / 内部常量。与其
// echo 给用户(泄露内部细节),不如走中性文案 + console 留痕供排查。
console.error('[ai-chat-panel] onSubmit threw:', e);
this._notify('AI 修改失败,请稍后重试', 'error');
} finally {
if (this._currentRequestId === requestId) {
this._submitting = false;
this._canceling = false;
this._currentRequestId = null;
const i = this.element.querySelector('.ai-chat-input');
if (i) i.disabled = !this._enabled;
this._syncSubmitButton();
}
}
}
_triggerCancel() {
if (!this._submitting) return;
if (!this._currentRequestId) return;
const oldRequestId = this._currentRequestId;
// P1-3 fix (audit):先标记该 requestId 为 cancelled让 _triggerSubmit
// 后续 await 返回时直接丢弃(不 resetInput / 不应用结果)。
if (!this._cancelled) this._cancelled = new Set();
this._cancelled.add(oldRequestId);
// P2-1 fix (audit)onCancel 抛错不要立即写 error 行 —— 让 _triggerSubmit
// 的 result 分支统一根据 error code 决定显示「已取消」或「失败」。
try {
if (this.onCancel) this.onCancel(oldRequestId);
} catch (e) {
console.warn('[ai-chat-panel] onCancel threw:', e);
}
// audit fix (Phase L3-RACE 1)cancel 后立刻允许用户提交新 prompt不必等
// 主进程响应真正 abort 后 _triggerSubmit 的 finally 才复位 _submitting。
// 旧 _triggerSubmit 的 finally 因 currentRequestId !== requestId 会跳过状态
// 重置(看下面那个 `if (this._currentRequestId === requestId)` 守卫),
// 所以提前清是安全的;用户立刻打新 prompt + Enter 不会被 `if (this._submitting) return` 拦住。
// 真正的取消通知由旧 _triggerSubmit 的 _cancelled.has 分支产生 '已取消 AI 请求'。
this._submitting = false;
this._currentRequestId = null;
this._notify('已取消 AI 请求', 'cancelled');
const input = this.element.querySelector('.ai-chat-input');
if (input) input.disabled = !this._enabled;
this._syncSubmitButton();
}
/**
* controller 调:强制取消(用于切文件等场景)—— 即使 chatPanel 自己不知道
* 这次提交是否还"在 submitting 状态",都把 IPC cancel 发出去。
* @param {string} requestId
*/
cancelRequest(requestId) {
if (typeof requestId === 'string' && requestId) {
// L6 fix (audit):先把 requestId 加进 _cancelled —— 即使 IPC aiCancel 失败
// main 端找不到 pending、abort 没赶上_triggerSubmit 的 await 返回时
// 仍会命中 _cancelled.has() 短路,显示「已取消」而不是误导性错误信息。
if (!this._cancelled) this._cancelled = new Set();
this._cancelled.add(requestId);
if (this.onCancel) {
try {
this.onCancel(requestId);
} catch (e) {
// audit fix (1.2)controller 在切文件 / 用户强退等关键路径调用本方法,
// 即便 IPC 失败也不应给用户弹红色错误行(瞬时态冲突、误导)。
// 只在 console 留痕即可。
console.warn('[ai-chat-panel] cancelRequest 抛错:', e);
}
}
}
}
/**
* controller 调:更新 chip 状态文案(兼容层 —— 旧 controller 路径会继续用)。
* 内部按文案前缀推断 chip 视觉类型running / cancelled / success
* 若 controller 想强制类型(如 'diff'),请直接调 onNotify。
* @param {string} text
*/
setStatus(text) {
this._notify(text || '', inferStatusType(text));
}
/**
* controller 调:把错误文案写到 chipdanger 色)。
* @param {string} text
*/
setError(text) {
this._notify(text || '', 'error');
}
/**
* 内部统一出口:把 (text, type) 转发到 onNotify未注入则静默。
* 见 [feedback-ai-tips-into-status-bar]:所有 AI 相关提示都收进底部状态栏 chip
* 不再在 dock 底部单开一行。
* @param {string} text
* @param {'idle'|'running'|'cancelled'|'error'|'success'|'diff'} type
*/
_notify(text, type) {
if (this.onNotify) {
try { this.onNotify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ }
}
}
destroy() {
for (const [el, ev, fn] of this._listeners) {
el.removeEventListener(ev, fn);
}
this._listeners = [];
// P3-5 fix (audit)destroy 也要清内部状态,避免 bindMountPoint 重新
// 调用时旧实例残留 _composing / _submitting / _canceling / _currentRequestId
this._composing = false;
this._submitting = false;
this._canceling = false;
this._currentRequestId = null;
this._cancelled = null;
}
}

864
src/ai/ai-controller.js Normal file
View File

@@ -0,0 +1,864 @@
// AI 控制器
// ============================================================================
//
// 把"AI 对话面板 + AI 差异面板 + 编辑器"串成一个状态机。
//
// 状态:
// - chatPanelOpen: bool AI 输入面板是否展开
// - pendingDiff: object|null AI 返回的待应用修改rows + regions
// - isSubmitting: bool 正在等主进程回包
//
// 数据流:
// 用户输入 prompt
// → chatPanel.onSubmit(prompt, requestId)
// → window.api.aiEdit({...}) (主进程调 OpenAI 兼容 API
// → 拿到 { content, responseFormat }
// → computeFullMarkdownDiff(currentContent, content) 算出 regions
// → setPendingDiff({ rows, regions, baseContent, nextContent })
// → diffPanel.setDiff(...) 在中间面板渲染
// 用户点 "应用此处" / "应用全部"
// → editor.applyUserChange(newContent) // 标脏
// → 重算 diff基于新内容regions 全部应用完则关掉 diffPanel
//
// 内容变化时(用户继续编辑、撤销/重做、autosave 落地)也要重算 diff
// 让"应用一部分后还剩多少"始终正确。
// ============================================================================
import { AiChatPanel } from './ai-chat-panel.js';
import { AiDiffPanel } from './ai-diff-panel.js';
import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js';
/**
* 给一段行级 lines 构造只读 context rows用于 AI 普通回复展示)。
* 行号必须与 shared/markdown-diff.js 的 splitLines 严格一致 —— 否则 viewer
* 行号与 diff 行号错位,撤销 / 光标定位跑偏。
* @param {string} content
* @returns {Array<{id:string, type:'context', newLineNumber:number, segments:Array<{type:'equal', text:string}>}>}
*/
function linesToContextRows(content) {
// H1 fix (audit):用与 splitLines 一致的「normalize + endsWith 处理」逻辑,
// 替代原来的 `/\r\n?|\n/` split。两者对裸 \r 旧 Mac 风格的行号会差 1
// - 原 split("\r") = ["", ""] → 2 行
// - normalize 后 splitLines("\r") = [""] → 1 行
// renderer 不能 import CJSshared/* 是 CommonJS所以内联等价实现。
const normalized = String(content || '').replace(/\r\n?/g, '\n');
const lines = normalized.length === 0
? []
: (normalized.endsWith('\n') ? normalized.slice(0, -1).split('\n') : normalized.split('\n'));
return lines.map((line, index) => ({
id: `readonly-${index}`,
type: 'context',
newLineNumber: index + 1,
segments: [{ type: 'equal', text: line }],
}));
}
/**
* @param {object} options
* @param {object} options.api - window.api
* @param {object} options.editor - MarkdownEditor 实例
* @param {object} options.state - app.js 的 state
* @param {(msg:string, type?:string, duration?:number) => void} options.toast
* @param {() => object} options.getSettings - 读最新设置settingsStore.getAll
* @param {() => void} [options.onAfterApply] - 成功 apply region / all 后通知外部刷新 viewer。
* preview 模式下 scheduleLivePreview 只在 split 模式刷新 viewerAI 改动只能通过
* 这个回调让 viewer 看到(否则用户感觉"点了应用,文件没反应")。
* @param {() => void|Promise<void>} [options.onOpenSettings] - 未配置时调:直接打开设置对话框
* 并把焦点定位到 AI 段。AI 按钮 / 提交时检测到未配置都会触发,比 toast 更直接 —— 让用户
* 能立刻看到「还差什么」而不是看到一条转瞬即逝的警告。
* @param {(text:string, type:import('./ai-status.js').AI_STATUS[keyof import('./ai-status.js').AI_STATUS]) => void} [options.notify]
* 把 AI 过程/结果文案转发到外部 UIstatusbar 上的 #status-ai chip。由 controller
* 主动调setStatus / setError 在 chatPanel 内部已经转发到 onNotify但 controller
* 想明确标注类型(如 AI_STATUS.DIFF时直接调本回调绕过 chatPanel 的 inferStatusType
* @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时
* togglePanel 会拒绝打开 AI dock 并 toast双向互斥避免 AI dock 透过 modal
* 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。
*/
export function createAiController({
api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen,
}) {
if (!api || !api.aiEdit) {
console.error('[ai-controller] api.aiEdit 不可用preload 没暴露 AI 桥?');
}
if (!editor) throw new Error('[ai-controller] editor 必填');
// 状态
let pendingDiff = null;
let lastAppliedContent = null; // 防止 content 变化时无限重算自己
let currentRequestId = null; // 用于切文件时取消 in-flight 请求(见 onFileChanged
// P0-1 fix (audit):单调 generation 计数器。handleSubmit 入参时捕获,
// await 后比对 —— 文件切换 / 取消 / 重新提交都会 ++generation 把旧请求废弃。
// 防止 AI 响应覆盖新文件的 pendingDiff数据丢失风险
let requestGeneration = 0;
// 防抖 timeronContentChanged 不再每个 keystroke 都跑 sync DP
let recomputeTimer = null;
// 安全 notify 闭包notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。
// controller 在 4 处显式调AI_STATUS.DIFF / AI_STATUS.IDLE其余走 chatPanel.setStatus 转发。
const safeNotify = (text, type) => {
if (typeof notify !== 'function') return;
try { notify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ }
};
function isChatOpen() {
return chatPanel && chatPanel.isOpen();
}
function isConfigured() {
const s = typeof getSettings === 'function' ? (getSettings() || {}) : {};
// P1-1 fix (audit)renderer 端 aiApiKey 是空字符串main.js 已过滤),
// 真值标识走 _hasAiKey 布尔位 —— 也兼容旧测试 / 老 settings 直接传 aiApiKey 的情况
const hasKey = !!s._hasAiKey || !!String(s.aiApiKey || '').trim();
return !!(String(s.aiBaseUrl || '').trim() && hasKey && String(s.aiModel || '').trim());
}
function recomputeDiffFromBase() {
// 用户在编辑器里继续改了内容:基于最新内容 + AI 的 nextContent 重算
if (!pendingDiff || pendingDiff.isReadOnly) return;
if (!api.aiDiffCompute) return;
const current = safeGetContent();
if (current === null) return; // editor 抛错已被 safeGetContent 记录,不重算
if (current === lastAppliedContent) return; // 没变化
const opts = pickDiffOptions(current, pendingDiff.nextContent);
let next;
try {
// audit A5未包 try/catch 会让 sync 抛错冒到 editor.onChange → app.js
// 的 IPC 链断裂。降级为 toast不影响编辑器继续工作。
next = api.aiDiffCompute(current, pendingDiff.nextContent, opts);
} catch (e) {
if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error');
return;
}
if (!next || !next.regions || next.regions.length === 0) {
// 全部应用完了(或没了)→ 关闭 diff
setPendingDiff(null);
} else {
pendingDiff = { ...pendingDiff, baseContent: current, ...next };
lastAppliedContent = current;
if (diffPanel) diffPanel.setDiff(pendingDiff);
}
syncBodyFlag();
}
function pickDiffOptions(base, next) {
const totalLen = (base || '').length + (next || '').length;
const baseLines = (base || '').split(/\r\n?|\n/).length;
const nextLines = (next || '').split(/\r\n?|\n/).length;
// 超过 80k 字符或 12 万行乘积 → 关闭 token 粒度(避免 DP 爆炸)
return {
tokenDiff: totalLen <= 80_000 && baseLines * nextLines <= 120_000,
};
}
/**
* 取编辑器当前内容。失败返回 null 而非 ''
* 若失败时 fallback 成 ''applyDiff 链路会把真实编辑器内容当成空文本,
* 后续 editor.applyUserChange(nextContent) 就把用户文档替换为 AI 基于空文本生成的内容 —— 静默销毁。
* 调用方必须 null-check 并中止 AI 流程。
*/
function safeGetContent() {
try { return editor.getContent(); }
catch (e) {
// audit fix (Phase O-L20)catch 不直接把 e 整个写 console.warn。
// 写整个 e 会让 e.message含 CM6 state doc 摘要 / 光标位置)落到主进程
// notes-main.log用户私有 logs/,同机其他用户不可读但仍属日志积压);
// 改成只打错误名 + 行号,不附 message。
console.warn('[ai-controller] safeGetContent 失败:', e && e.name ? e.name : 'Error');
return null;
}
}
function setPendingDiff(diff) {
pendingDiff = diff;
lastAppliedContent = diff ? safeGetContent() : null;
if (diffPanel) diffPanel.setDiff(diff);
syncBodyFlag();
}
/**
* 校验 pendingDiff 仍然属于当前打开的文件。
* audit H162/H163之前 handleApplyRegion / handleApplyAll 直接读 safeGetContent()
* 不检查 pendingDiff 是为哪个文件算的 —— 用户提交 AI → 等响应 → 切到另一个文件 →
* 点「应用」,会把 A 的 diff region 算进 B 的内容里(可能命中 / 可能不命中 →
* 即使命中也是把 A 的修改写到 B数据丢失
* 现在所有 setPendingDiff 都带上 myFileapply 前再核对一次。
* @returns {{ ok: true } | { ok: false, reason: string }}
*/
function ensurePendingDiffMatchesCurrentFile() {
if (!pendingDiff) return { ok: false, reason: '没有待应用的 AI 修改' };
const expected = pendingDiff.forFilePath;
const current = state.currentFile && state.currentFile.path;
if (expected == null || current == null || expected !== current) {
return {
ok: false,
reason: '当前文件已切换,原 AI 修改已失效(请重新提交)',
};
}
return { ok: true };
}
function syncBodyFlag() {
// 中间面板body.dataset.aiDiffOpen = 'true' 时 CSS 插入一列
// 注意isReadOnly 也要让面板打开 —— AI 普通回复regions=[])也算"有 AI 内容要展示"
const has = !!pendingDiff && (
pendingDiff.isReadOnly ||
(pendingDiff.regions || []).length > 0
);
document.body.dataset.aiDiffOpen = has ? 'true' : 'false';
}
// 创建面板实例DOM 必须在 bindMountPoint 之后才能实例化)
let chatPanel = null;
let diffPanel = null;
function bindMountPoint(chatElement, diffElement, splitterElement = null) {
// 3rd-agent fix重复 bind 要先销毁旧实例,否则旧 listener 残留、
// 旧 _submitting 状态无法被新 controller 感知。
if (chatPanel && typeof chatPanel.destroy === 'function') {
try { chatPanel.destroy(); } catch { /* ignore */ }
}
if (diffPanel && typeof diffPanel.destroy === 'function') {
try { diffPanel.destroy(); } catch { /* ignore */ }
}
chatPanel = new AiChatPanel({
element: chatElement,
onSubmit: handleSubmit,
onCancel: handleCancel,
// 让 panel 内部的过程/错误文案(正在发送 / 已取消 / AI 请求失败 等)
// 走 onNotify → app.js 的 updateStatusAi → statusbar 上的 #status-ai chip。
// 详见 [feedback-ai-tips-into-status-bar]。
onNotify: notify,
});
// 一开始禁用(直到用户打开文件)
chatPanel.syncFileState(!!state.currentFile);
diffPanel = new AiDiffPanel({
element: diffElement,
onApplyRegion: handleApplyRegion,
onApplyAll: handleApplyAll,
onDismiss: handleDismiss,
onSkipRegion: handleSkipRegion,
splitterElement,
});
// 默认无 diffhidden
diffPanel.setDiff(null);
}
async function handleSubmit({ prompt, requestId }) {
if (!isConfigured()) {
// 未配置:直达设置对话框(焦点定位到 AI 段),让用户能立刻看到缺什么。
// toast 留给 dialog 自己展示,比一条转瞬即逝的提示更直接。
if (typeof onOpenSettings === 'function') {
try {
await onOpenSettings();
} catch {
// dialog 自身挂了不要让用户干等 —— 给个明确的可执行下一步
if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error');
}
}
// 给状态栏 chip 一个明确文案,避免触发 _triggerSubmit 的 'AI 修改失败' 兜底。
return { ok: false, error: AI_ERROR.NOT_CONFIGURED, message: aiErrorMessage(AI_ERROR.NOT_CONFIGURED) };
}
if (!state.currentFile) {
if (typeof toast === 'function') toast('请先打开一个 Markdown 文件', 'warning', 2500);
return { ok: false, error: AI_ERROR.NO_FILE, message: aiErrorMessage(AI_ERROR.NO_FILE) };
}
// P0-1 fix (audit):捕获提交时的文件对象 + 单调 generation。
// await 之后比对:文件已切换 / generation 已递增 → 视为 stale
// 丢弃本响应(防止 AI 修改错文件 —— 数据丢失风险)。
const myGen = ++requestGeneration;
const myFile = state.currentFile;
const filename = myFile.name || '';
// H4 fix (audit):拿到 prompt 后再读最新内容,避免 stale baseContent。
const contentSnapshot = safeGetContent();
if (contentSnapshot === null) {
return { ok: false, error: AI_ERROR.EDITOR_UNAVAILABLE, message: aiErrorMessage(AI_ERROR.EDITOR_UNAVAILABLE) };
}
// 记录当前请求 id切文件时调 aiCancel 主动终止,避免 AI 响应
// 回到一个已经不相关的文档(详见 onFileChanged
currentRequestId = requestId;
// H2 fix (audit):提交时立刻清掉旧 pendingDiff避免 awaiting 期间中间面板
// 显示上一次 AI 的陈旧 diff用户点了"应用此处"以为对的是新请求)。
if (pendingDiff) {
setPendingDiff(null);
}
let result;
try {
result = await api.aiEdit({ prompt, content: contentSnapshot, filename, requestId });
} catch (e) {
currentRequestId = null;
return { ok: false, error: 'IPC_FAILED', message: e && e.message || 'IPC 异常' };
}
// P0-1 fix (audit)await 之后做 stale 检查 —— 注意 currentRequestId 只在
// 我们仍是「最新一次提交」时才清掉,否则会让并发的下一次提交找不到自己
// 的 requestIdonFileChanged / destroy 看 currentRequestId 决定是否取消
// in-flight 请求。audit M1 fixcurrentRequestId 必须在 stale 检查通过后
// 才允许清,且只在我们仍是 currentRequestId 的持有者时清。
if (myGen !== requestGeneration || state.currentFile?.path !== myFile?.path) {
// L2 fix (audit)路径比较替代对象身份。openFile 会基于 reload/mtime 更新
// state.currentFile = { ...state.currentFile, mtimeMs, size }(新对象),
// 旧身份比较会让「同一文件路径、仅 mtime 更新」误判为已切文件,
// 把刚回的 AI 响应丢进 stale 分支。改用路径字符串比较。
if (currentRequestId === requestId) currentRequestId = null;
return {
ok: false,
error: AI_ERROR.GENERATION_STALE,
message: aiErrorMessage(AI_ERROR.GENERATION_STALE),
};
}
// 通过 stale 检查:清掉我们的 requestId 句柄,让 onFileChanged 不再尝试
// 取消这个请求(它马上就要 resolve 了)。
currentRequestId = null;
if (!result || result.ok !== true) {
return { ok: false, error: (result && result.error) || 'UNKNOWN', message: (result && result.message) || 'AI 修改失败' };
}
const aiContent = result.content || '';
const isRaw = result.responseFormat === 'raw';
if (isRaw) {
// AI 没改文档,只是回复一段文字
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: true,
rows: linesToContextRows(aiContent),
regions: [],
forFilePath: myFile && myFile.path, // audit H162标记这个 diff 属于哪个文件
});
if (chatPanel) chatPanel.setStatus('AI 已返回普通回复,未生成文档修改');
return { ok: true, isRawResponse: true };
}
// 算 diff
const opts = pickDiffOptions(contentSnapshot, aiContent);
let diffResult;
try {
diffResult = api.aiDiffCompute(contentSnapshot, aiContent, opts);
} catch (e) {
return { ok: false, error: 'DIFF_FAILED', message: '计算差异失败:' + (e && e.message || '未知') };
}
if (!diffResult.regions || diffResult.regions.length === 0) {
// AI 返回内容与原文一致(已经过 schema 比较过,但 baseContent 可能在
// 期间被外部改过,所以再次校验)
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: true,
rows: linesToContextRows(aiContent),
regions: [],
forFilePath: myFile && myFile.path,
});
if (chatPanel) chatPanel.setStatus('AI 返回内容与当前文档一致');
return { ok: true, isRawResponse: false };
}
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: false,
rows: diffResult.rows,
regions: diffResult.regions,
forFilePath: myFile && myFile.path,
});
if (chatPanel) {
const text = `AI 已返回,请在中间面板确认(${diffResult.regions.length} 处修改)`;
// AI_STATUS.DIFFchip 用 accent-soft 高亮,让用户一眼看到"中间面板有 N 处待确认"
// 显式 notify 而非 setStatus —— 后者会被 panel 内部推断为 success中性 muted
// 弱化了关键状态的视觉权重。
safeNotify(text, AI_STATUS.DIFF);
}
// H3 fix (audit)diff 引擎 fallback 时(行级 cells 超 MAX_LINE_DIFF_CELLS /
// 词级超 MAX_TOKEN_DIFF_CHARS会附带 warnings 字段。告知用户「diff 退化为
// 整段替换」或「词级已退化为行级」,避免 UI 上看到「整篇被改」以为是 AI 整篇重写。
if (Array.isArray(diffResult.warnings) && diffResult.warnings.length > 0 && typeof toast === 'function') {
diffResult.warnings.forEach((msg) => toast(msg, 'warning', 5000));
}
return { ok: true, isRawResponse: false };
}
function handleCancel(requestId) {
// 取消 in-flight 请求;++generation 让 handleSubmit 在 await 返回后
// 走 stale 分支(即使主进程 IPC 失败没真取消,也阻止后续 setPendingDiff
requestGeneration++;
// L5 fix (audit):清掉 currentRequestId —— 否则 onFileChanged / destroy 看到
// 非空 currentRequestId 会再发一次 cancel重复 IPC+ 误以为还有 in-flight 请求。
currentRequestId = null;
if (api.aiCancel) api.aiCancel(requestId);
}
function handleApplyRegion(regionId /*, diff */) {
if (!pendingDiff) return;
// audit fix (Round 13 / Sec-H1)isReadOnly=true 的 diff 没有可应用 region
// regions:[]),且 nextContent 是 AI 普通文本回复而非文档修改。panel 不会
// 渲染 apply 按钮,但事件委托若被 AI 控制的 [data-action] 元素覆盖Sec-H1
// 已加 closest('.ai-reply-content') 兜底),仍可能在 controller 这里再次走到
// apply 路径 —— editor.applyUserChange(aiChatReply) 会把打开的笔记替换为
// AI 的聊天文本。二次守一道readOnly 时直接拒绝,连 region 查询都不跑。
if (pendingDiff.isReadOnly) return;
// audit H162apply 前确认 pendingDiff 还是当前文件的。
// onFileChanged 在切换文件时会 setPendingDiff(null) —— 但有一种竞态:
// 用户提交 AI → 等响应 → 切到 B 文件pendingDiff 被清)→ 切回 A 文件
// onFileChanged 不会再清,因为已经清过了)→ 此时 pendingDiff 是 null
// 但如果 controller 在切到 B 之前刚生成 pendingDiff 且切回 A 后没刷新,
// 那 pendingDiff 仍存在但属于 A。下面这层 forFilePath 校验兜底。
const check = ensurePendingDiffMatchesCurrentFile();
if (!check.ok) {
if (typeof toast === 'function') toast(check.reason, 'error', 3000);
setPendingDiff(null);
return;
}
const region = pendingDiff.regions.find((r) => r.id === regionId);
if (!region) return;
const current = safeGetContent();
if (current === null) {
if (typeof toast === 'function') toast('无法读取编辑器内容,已停止应用', 'error');
return;
}
let result;
try {
result = api.aiDiffApply(current, region);
} catch (e) {
if (typeof toast === 'function') toast('应用失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9)chatPanel status 与 toast 同步 —— 否则面板里
// 还停留在「已应用 N 处修改」/「待确认」的旧状态,用户以为成功了。
if (chatPanel) chatPanel.setStatus('AI 应用失败,可重试');
return;
}
if (!result.ok) {
// 在该 region 上标 conflictUI 灰掉"应用此处"按钮
pendingDiff = {
...pendingDiff,
regions: pendingDiff.regions.map((r) => r.id === regionId ? { ...r, conflict: result.reason } : r),
};
// audit fix (1.3):冲突时**不修改** lastAppliedContent —— 编辑器内容没变,
// 用户先前若有手动编辑safeGetContent() 返回的是漂移值,把它写回 lastAppliedContent
// 会让 onContentChanged 跳过本应触发的 recompute。
// pendingDiff.baseContent 是当前 diff 的基线,保持原状即可。
if (diffPanel) diffPanel.setDiff(pendingDiff);
// audit fix (Round 9):同步 chatPanel status 让用户知道 apply 失败。
if (chatPanel) chatPanel.setStatus('该处无法应用,请用「跳过」放弃');
return;
}
// M5 fix (audit):对称 handleApplyAll 的空内容保护 —— applyDiffRegionSafely
// 在极端场景region 跨边界 + 上下文匹配失败)可能算出 "" 或纯空白。
// applyUserChange('') 会把区域对应行静默清空(数据丢失),必须挡住。
// C4 fix (audit):但 region.oldLines.length > 0 时算「整段删除」是合法语义
// AI 明确有内容可删),此时 appliedContent 为空是正确的,不能挡。
const appliedContent = result.content;
const isLegitimateDelete = region.oldLines.length > 0;
if (
typeof appliedContent !== 'string'
|| (!isLegitimateDelete && appliedContent.replace(/\s+/g, '').length === 0)
) {
if (typeof toast === 'function') {
toast('AI 返回内容为空,已拒绝应用该处(请用「跳过」放弃该修改)', 'error', 4000);
}
// 把 region 标 conflict 让用户走「跳过」逃生口
pendingDiff = {
...pendingDiff,
regions: pendingDiff.regions.map((r) => r.id === regionId ? { ...r, conflict: 'AI 返回内容为空' } : r),
};
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('AI 返回内容为空,请「跳过」放弃');
return;
}
// 应用:替换编辑器内容(标脏)
try {
editor.applyUserChange(appliedContent);
} catch (e) {
// audit fix (Phase O-M1)IME 合成期间抛 EDITOR_COMPOSING 阻止 apply
// 提示用户稍后再试(不要显示「写入编辑器失败」误导成磁盘错误)。
if (e && e.message === 'EDITOR_COMPOSING') {
if (typeof toast === 'function') toast('编辑器正在输入中文,请稍后再试', 'warning');
// audit fix (Round 9):同步 chatPanel status —— chip 仍显示
// 「待确认 N 处」会让用户以为还在 diff 模式可用。
if (chatPanel) chatPanel.setStatus('正在输入中文,请稍后再试');
return;
}
if (typeof toast === 'function') toast('写入编辑器失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('写入编辑器失败,可重试');
return;
}
// M5 fix (audit)apply 成功后立即把 pendingDiff.baseContent 推进到 result.content
// 不要等到下面的 compute 抛错 / 不更新就早返回。baseContent 必须始终反映
// 「编辑器的当前实际内容」—— 后续任何重算 / onFileChanged 比对都依赖它。
// 旧顺序里如果 aiDiffCompute 抛错编辑器是新内容、pendingDiff.baseContent
// 还是旧内容,用户看到「已应用的修改没体现在剩余 diff 里」的诡异行为。
pendingDiff = { ...pendingDiff, baseContent: result.content };
// preview 模式修复apply 后立即通知外部刷新 viewer。split 模式下
// scheduleLivePreview 会覆盖(防双触发不致命 —— loadContentLive 幂等)。
if (typeof onAfterApply === 'function') {
try { onAfterApply(); } catch { /* 不让 viewer 渲染失败阻断 apply */ }
}
// 用 AI 改后的 base 重算剩余 diff
let remaining;
try {
// audit A5与 recomputeDiffFromBase 同理,包 try/catch 防 sync 抛错。
remaining = api.aiDiffCompute(result.content, pendingDiff.nextContent, pickDiffOptions(result.content, pendingDiff.nextContent));
} catch (e) {
if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('重算差异失败');
return;
}
if (!remaining || !remaining.regions || remaining.regions.length === 0) {
// 全部应用完
if (typeof toast === 'function') toast('已应用所有 AI 修改', 'success', 1500);
setPendingDiff(null);
if (chatPanel) chatPanel.setStatus('已应用所有 AI 修改');
} else {
pendingDiff = { ...pendingDiff, baseContent: result.content, ...remaining };
lastAppliedContent = result.content;
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
// H3 fix (audit)partial apply 后 chatPanel status 也要更新regions 数量变了)
if (chatPanel) {
// AI_STATUS.DIFF剩余待确认数要靠 chip 高亮 —— 见 handleSubmit 的同款注释
safeNotify(`已应用 1 处修改,还剩 ${remaining.regions.length} 处待确认`, AI_STATUS.DIFF);
}
}
}
function handleApplyAll(/* diff */) {
if (!pendingDiff) return;
// audit fix (Round 13 / Sec-H1):与 handleApplyRegion 同款 —— isReadOnly
// 状态不可能有合法 apply 路径nextContent 是 AI 普通聊天文本,覆盖编辑器
// 即等于「把笔记销毁换成聊天回复」。详见 handleApplyRegion 同处注释。
if (pendingDiff.isReadOnly) return;
// audit H163与 handleApplyRegion 同款校验,挡住「跨文件覆盖」数据丢失。
const check = ensurePendingDiffMatchesCurrentFile();
if (!check.ok) {
if (typeof toast === 'function') toast(check.reason, 'error', 3000);
setPendingDiff(null);
return;
}
const next = pendingDiff.nextContent;
// P0 fix拒绝空内容覆盖 —— AI 返回 '' 时(截断 / 服务异常 / 提示词诱导),
// applyDiffRegionSafely 也会算出"全删"region"应用全部"就会把用户文档
// 静默替换成空串(这里就是 clearCurrentFile 之外的另一条销毁路径)。
// 校验放在最后一道关卡前next 必须有非空白字符。
if (typeof next !== 'string' || next.replace(/\s+/g, '').length === 0) {
if (typeof toast === 'function') {
toast('AI 返回内容为空,已拒绝应用(请用「跳过」逐条处理,或重新提交)', 'error', 4000);
}
// 把当前 diff 标成 readOnly、关掉 apply 路径,让用户能继续查看 / skip 单条
pendingDiff = {
...pendingDiff,
isReadOnly: true,
};
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
return;
}
// M4 fix (audit):用户在等待 AI 时可能继续编辑文档。pendingDiff.baseContent
// 是提交时的快照,若当前编辑器内容已不同,「应用全部」会用 nextContent
// 直接覆盖整个文档,**丢掉用户在等待期间做的修改**(数据丢失)。
//
// 审计修复 (Round 11):之前只弹一条 toast 警告但 apply 照常进行 —— 5 秒
// 警告用户根本来不及反应apply 已完成、用户的修改被静默覆盖。
// 现在 drift 检出后把 pendingDiff 标成 isReadOnly=truediffPanel 会隐藏
// "应用全部" / "应用此处" 按钮),强制用户「逐条 skip / apply」让决定更精细
// 或者撤销自己的编辑后重提。最坏情况保留全删路(用户主动重提后 baseContent
// 重新对齐apply 不再丢失任何东西)。
const currentForApply = safeGetContent();
if (currentForApply !== null && currentForApply !== pendingDiff.baseContent) {
if (typeof toast === 'function') {
toast('你在等待 AI 时已修改过文档,「应用全部」会覆盖未在 diff 内的修改,已切换为逐条确认', 'warning', 4000);
}
if (chatPanel) chatPanel.setStatus('已切换逐条确认模式(你等待时编辑过文档)');
pendingDiff = {
...pendingDiff,
isReadOnly: true,
};
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
return;
}
try {
editor.applyUserChange(next);
} catch (e) {
// audit fix (Phase O-M1)同上IME 合成期间拒绝 apply。
if (e && e.message === 'EDITOR_COMPOSING') {
if (typeof toast === 'function') toast('编辑器正在输入中文,请稍后再试', 'warning');
// audit fix (Round 9):同步 chatPanel status与 handleApplyRegion 对齐)。
if (chatPanel) chatPanel.setStatus('正在输入中文,请稍后再试');
return;
}
if (typeof toast === 'function') toast('写入编辑器失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('写入编辑器失败,可重试');
return;
}
// preview 模式修复apply 后立即通知外部刷新 viewer见 handleApplyRegion 同处注释)
if (typeof onAfterApply === 'function') {
try { onAfterApply(); } catch { /* 同上 */ }
}
if (typeof toast === 'function') toast('已应用所有 AI 修改', 'success', 1500);
setPendingDiff(null);
if (chatPanel) chatPanel.setStatus('已应用所有 AI 修改');
}
/**
* audit fix从 diff 列表中丢弃一个 region一般是 conflict 标了之后用户放弃应用)。
* 旧版本唯一的恢复路径是「dismiss 整个 diff」—— 一旦某个 region 因上下文变更无法
* 安全定位conflict其它 region 也跟着没法继续用。这里给出细粒度逃生口:
* - 从 pendingDiff.rows / regions 中过滤掉该 region
* - 重算剩余 diff如果 baseContent 因为后续编辑已经漂移,需要基于当前内容重算);
* - 如果没有 region 剩了,就 dismiss。
*
* audit fix (H1-settings / H162):补文件匹配守卫 —— 之前 handleApply* 已经有
* ensurePendingDiffMatchesCurrentFile()handleSkipRegion 漏了同样的保护:
* 用户提 AI → 等响应 → 切到另一文件 → 旧 diff panel 仍可见pendingDiff 没被
* 清掉?实际 onFileChanged 会清,但 render 早于清 / 残留 region 仍可见),点
* 「跳过」会改写为别的文件的 regions 数组(甚至把整个 diff 关掉)。和 apply
* 同样会造成「A 的修改写到 B」类数据事故。
*/
function handleSkipRegion(regionId) {
if (!pendingDiff) return;
// 与 handleApply* 一致:先确认 pendingDiff 还属于当前文件
const guard = ensurePendingDiffMatchesCurrentFile();
if (!guard.ok) {
if (typeof toast === 'function') toast(guard.reason, 'warning', 2500);
setPendingDiff(null);
return;
}
const filtered = (pendingDiff.regions || []).filter((r) => r.id !== regionId);
const filteredRows = (pendingDiff.rows || []).filter((row) => row.regionId !== regionId);
// 如果 baseContent 没变,直接用过滤后的 rows否则重算保持 diff 与最新编辑器同步)
const current = safeGetContent();
if (current === null) {
if (typeof toast === 'function') toast('无法读取编辑器内容,已停止操作', 'error');
return;
}
let nextRows = filteredRows;
let nextRegions = filtered;
let nextBase = pendingDiff.baseContent;
const skippedRegion = (pendingDiff.regions || []).find((r) => r.id === regionId);
if (current !== pendingDiff.baseContent && pendingDiff.nextContent) {
try {
const recomputed = api.aiDiffCompute(current, pendingDiff.nextContent, pickDiffOptions(current, pendingDiff.nextContent));
if (recomputed && Array.isArray(recomputed.rows) && Array.isArray(recomputed.regions)) {
nextRows = recomputed.rows;
nextRegions = recomputed.regions;
nextBase = current;
// 用户已编辑 → 新 regions 由当前内容 + AI nextContent 重算得出。
// 但「跳过 regionId」是用户明确的意图 —— 简单丢弃 ID 是不够的:
// recomputed.regions 是新生成的 ID旧的 regionId 在新集合里压根不存在。
// best effort把 oldLines 文本与被跳过的 region 相同的 region 一并剔除。
// 边界:用户编辑使 skippedRegion.oldLines 在 current 里已经不存在
// 例如用户直接把那几行删了recompute 也不会再生成对应 regionfilter 是 no-op。
if (skippedRegion && Array.isArray(skippedRegion.oldLines)) {
const skippedKey = skippedRegion.oldLines.join('\n');
// 1) 把「oldLines 与被跳过 region 相同」的新 region 剔除
nextRegions = nextRegions.filter((r) => !(Array.isArray(r.oldLines) && r.oldLines.join('\n') === skippedKey));
// 2) 把指向被跳过 region 的 row 剔除(如果旧 ID 还残留在 row 数组里)
nextRows = nextRows.filter((row) => row.regionId !== skippedRegion.id);
}
} else {
// 审计修复 (Round 11 P1-4)recomputed 无效null / 缺 rows/regions也必须 bail。
// catch 分支已有 return但 try 内部"recompute 走通但返回无效 shape"会落到
// 下面 if (!nextRegions...) 用 stale baseContent + filtered旧 regions继续
// 渲染diff panel 看着正常但实际没重算 → 用户以为重新算了实际还是旧的。
// 失败就放弃本次 skip不让 diff panel 进入误导态。
if (typeof toast === 'function') toast('重算差异失败:返回结果无效,已跳过本次跳过操作', 'error');
return;
}
} catch (e) {
if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error');
return;
}
}
if (!nextRegions || nextRegions.length === 0) {
// 跳过之后没 region 了,关掉 diff 面板
if (typeof toast === 'function') toast('已跳过该修改', 'success', 1500);
setPendingDiff(null);
if (chatPanel) chatPanel.setStatus('已跳过该修改,无更多待确认');
return;
}
pendingDiff = {
...pendingDiff,
baseContent: nextBase,
rows: nextRows,
regions: nextRegions,
};
lastAppliedContent = current;
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
if (chatPanel) {
// AI_STATUS.DIFFskip 一处后剩余待确认数要靠 chip 高亮
safeNotify(`已跳过 1 处修改,还剩 ${nextRegions.length} 处待确认`, AI_STATUS.DIFF);
}
}
function handleDismiss() {
setPendingDiff(null);
// dismiss 把 chip 收回到 idle —— 显式 notify 而非 setStatus('')
// setStatus('') 会被 panel 内部推断为 'success'(保持 muted 文字),
// 我们要的是真正清空chip 文字回到 "AI"),用 AI_STATUS.IDLE 类型最准。
safeNotify('', AI_STATUS.IDLE);
}
/**
* 工具栏按钮 / 快捷键调:切 AI 输入面板
* @returns {boolean} true 表示打开了面板
*/
function togglePanel() {
if (!chatPanel) return false;
if (chatPanel.isOpen()) {
chatPanel.close();
return false;
}
// audit fix (Phase L3-AI 4)AI dock 与设置对话框互斥。
// openSettingsDialog 已经在打开时主动 close dock 并 close 后还原,但用户在
// 设置打开期间手动点 AI 按钮(侧栏 btn-ai / 状态栏 chip / 快捷键)会让
// chatPanel.open() 把 dock 显示出来,视觉上从 modal 半透明背景后透出,与
// 设置对话框重叠 → 误点率高。这里守一道:设置打开时拒绝开 dock 并 toast。
if (typeof isSettingsOpen === 'function' && isSettingsOpen()) {
if (typeof toast === 'function') {
toast('设置对话框打开时无法打开 AI 面板,请先关闭设置', 'info');
}
return false;
}
if (!isConfigured()) {
// 未配置:直达设置对话框(焦点定位到 AI 段),让用户能立刻补 Base URL / Key / Model。
if (typeof onOpenSettings === 'function') {
try {
// onOpenSettings 可能是 asynccatch 同步 throw.catch 接住 Promise 拒绝
const r = onOpenSettings();
if (r && typeof r.catch === 'function') r.catch(() => {
if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error');
});
} catch {
if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error');
}
}
return false;
}
chatPanel.open();
return true;
}
/**
* 全部关闭chat dock + diff 面板)。
* diff 跟随 chat 一起关:让"AI 修改"按钮成为唯一的开关入口。
*/
function closeAll() {
if (chatPanel) chatPanel.close();
// 关 dock 时也清掉防抖 timer避免 pendingDiff=null 之后又触发 recompute
if (recomputeTimer) { clearTimeout(recomputeTimer); recomputeTimer = null; }
requestGeneration++;
// L5 fix (audit):关 dock 也清掉 currentRequestId + 发一次 cancel。
// 否则再 open 时 controller 以为自己还有 in-flight 请求,新提交会被
// onFileChanged/destroy 看到 currentRequestId 还在,重复发 cancel。
if (currentRequestId && chatPanel && typeof chatPanel.cancelRequest === 'function') {
try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ }
}
currentRequestId = null;
setPendingDiff(null);
if (chatPanel && typeof chatPanel.reset === 'function') {
// 3rd-agent fix清掉 _submitting / 输入框残留,避免下次 open 时 input 永久 disabled
chatPanel.reset();
}
}
/**
* 文件切换时同步:清掉 pendingDiff、把新内容当基线
* 同时取消 in-flight 的 AI 请求 —— 否则响应回来时 contentSnapshot 还是
* 上一个文件的内容,会让面板把不相关的 diff 渲染出来。
*/
function onFileChanged(newFile) {
// 取消进行中的 AI 请求chatPanel 知道自己的 _currentRequestId
if (chatPanel && currentRequestId) {
chatPanel.cancelRequest(currentRequestId);
currentRequestId = null;
}
// P0-1 fix (audit):递增 generation 让 in-flight handleSubmit 失败,
// 并清掉任何挂起的 debounced recompute新文件的内容基线不再准确
if (recomputeTimer) { clearTimeout(recomputeTimer); recomputeTimer = null; }
requestGeneration++;
// 切换文件时放弃当前 diff避免误应用
setPendingDiff(null);
if (chatPanel) {
chatPanel.syncFileState(!!newFile);
// 3rd-agent fixreset 比单独清 status/error 更彻底 —— 也会把
// _submitting=false + input.disabled=false避免切文件后输入框永久禁用
chatPanel.reset();
}
}
/**
* 编辑器内容变化时:重算 diff让用户看到"已应用部分还剩多少"
* P1-3 fix (audit)100ms 防抖 —— 连续 keystroke 只跑一次 DP
* pickDiffOptions 把 tokenDiff 限制在 ≤80k / ≤120k 行乘积,但每个
* keystroke 仍触发同步 DP 会卡顿)。
*/
function onContentChanged() {
if (!pendingDiff) return;
if (recomputeTimer) clearTimeout(recomputeTimer);
recomputeTimer = setTimeout(() => {
recomputeTimer = null;
recomputeDiffFromBase();
}, 100);
}
/**
* 销毁 controller撤销所有 panel listener、清掉防抖 timer、取消 in-flight 请求。
* 用于页面卸载 / HMR 重置 —— 避免旧实例的 click 监听器 + debounced setTimeout
* 在新 controller 实例运行时继续触发MutationObserver 在 src/app.js 的 pagehide
* 已用类似清理路径)。
*/
function destroy() {
if (recomputeTimer) {
clearTimeout(recomputeTimer);
recomputeTimer = null;
}
// 递增 generation 让 in-flight handleSubmit 自然废弃(不再写 pendingDiff
requestGeneration++;
if (chatPanel && typeof chatPanel.cancelRequest === 'function' && currentRequestId) {
try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ }
currentRequestId = null;
}
if (chatPanel && typeof chatPanel.destroy === 'function') {
try { chatPanel.destroy(); } catch { /* ignore */ }
}
if (diffPanel && typeof diffPanel.destroy === 'function') {
try { diffPanel.destroy(); } catch { /* ignore */ }
}
chatPanel = null;
diffPanel = null;
pendingDiff = null;
lastAppliedContent = null;
setPendingDiff(null);
}
return {
bindMountPoint,
togglePanel,
closeAll,
destroy,
isChatOpen: () => isChatOpen(),
hasPendingDiff: () => !!pendingDiff,
onFileChanged,
onContentChanged,
};
}

268
src/ai/ai-diff-panel.js Normal file
View File

@@ -0,0 +1,268 @@
// AI 修改差异预览面板
// ============================================================================
//
// 渲染 AI 改稿前后的行级 + 词级 diff。用户可以
// - 点 "应用全部修改":把整篇 nextContent 替换进编辑器
// - 点每个 region 的 "应用此处":把单个 region 的 newLines 替换进编辑器
// - 点 "×":丢弃 AI 修改,关掉中间面板
//
// 与 ai-controller 协作:
// 组件本身只负责渲染 + 转发按钮事件;所有"修改编辑器"的操作都通过
// 传进来的 onApply* 回调,由 controller 决定怎么改编辑器、重算 diff。
//
// 数据流:
// diff = { rows, regions, isReadOnly, baseContent, nextContent }
// - rows 已经是扁平化的行序列(含 context / removed / added按 regionId 分组
// - regions 是 [{ id, oldLines, newLines, conflict? }],每个对应一个"修改块"
// - isReadOnly = true 表示 AI 给了纯文本回复(不是文档修改),整块只读展示
// ============================================================================
import { escapeHtml, safeRenderMarkdown } from '../utils.js';
/**
* 把扁平 rows 拆成 context 行 + region 块,渲染时按这个结构走。
* @param {Array<{id:string, regionId?:string, type:string}>} rows
* @returns {Array<{type:'context', row:any} | {type:'region', regionId:string, rows:any[]}>}
*/
function buildBlocks(rows) {
const blocks = [];
let index = 0;
while (index < rows.length) {
const row = rows[index];
if (!row.regionId) {
blocks.push({ type: 'context', row });
index += 1;
continue;
}
const regionId = row.regionId;
const regionRows = [];
while (index < rows.length && rows[index].regionId === regionId) {
regionRows.push(rows[index]);
index += 1;
}
blocks.push({ type: 'region', regionId, rows: regionRows });
}
return blocks;
}
/**
* 渲染一行 diff。
* - removed 行用红色 - 前缀
* - added 行用绿色 + 前缀
* - context 行用空格前缀(与 git diff 一致)
* - token 粒度的 inline diffequal/removed/added会进一步切分
*/
function renderDiffRow(row, hideLineNumbers) {
const lineNumber = row.type === 'removed' ? row.oldLineNumber : row.newLineNumber;
const prefix = row.type === 'added' ? '+' : row.type === 'removed' ? '-' : ' ';
const segmentsHtml = (row.segments || [])
.map((seg) => {
const text = seg.text || ' ';
return `<span class="ai-diff-segment ai-diff-segment-${seg.type}">${escapeHtml(text)}</span>`;
})
.join('');
const numberHtml = hideLineNumbers
? ''
: `<span class="ai-diff-line-number" aria-hidden="true">${Number.isFinite(lineNumber) ? lineNumber : ''}</span>`
+ `<span class="ai-diff-line-prefix" aria-hidden="true">${prefix}</span>`;
return `
<div class="ai-diff-line ai-diff-line-${row.type}${hideLineNumbers ? ' ai-diff-line-plain' : ''}" role="row">
${numberHtml}
<span class="ai-diff-line-text" role="gridcell">${segmentsHtml}</span>
</div>
`;
}
export class AiDiffPanel {
/**
* @param {object} options
* @param {HTMLElement} options.element - 容器 div
* @param {(diff:any) => void} options.onApplyRegion - 单 region 应用
* @param {(diff:any) => void} options.onApplyAll - 全部应用
* @param {(diff:any) => void} options.onDismiss - 关闭预览
* @param {(regionId:string, diff:any) => void} [options.onSkipRegion] - 跳过单个 region
* (一般用于 conflict 区域 —— 给用户提供细粒度逃生口,避免一次冲突就要放弃整篇 AI 修改)
*/
constructor({ element, onApplyRegion, onApplyAll, onDismiss, onSkipRegion, splitterElement = null }) {
this.element = element;
// AI 拖动 splitter 元素(与 .app-ai 联动显隐)。
// 设了 splitterElement 后setDiff 会自动同步它的 hidden。
this.splitterElement = splitterElement;
this.onApplyRegion = onApplyRegion || (() => {});
this.onApplyAll = onApplyAll || (() => {});
this.onDismiss = onDismiss || (() => {});
this.onSkipRegion = onSkipRegion || null;
this._diff = null;
}
/**
* 渲染/更新 diff。diff = null → 显示空状态。
* @param {object|null} diff
*/
setDiff(diff) {
this._diff = diff;
// 每次 setDiff 之前先清掉上一轮的 click 监听器,否则重复 setDiff 会泄漏。
if (this._cleanup) this._cleanup();
this._cleanup = null;
if (!diff) {
this.element.hidden = true;
// 同步把 AI 拖动 splitter 隐藏:避免面板关后 splitter 仍可见占用 grid 列
if (this.splitterElement) this.splitterElement.hidden = true;
this.element.replaceChildren();
return;
}
this.element.hidden = false;
if (this.splitterElement) this.splitterElement.hidden = false;
// C4+H6 fix (audit)_renderHtml / innerHTML 抛错会让 setDiff 中断,
// element 处于 hidden=false 但内部为空 / 半渲染状态,用户看到一个
// 灰板。这里包 try/catch失败时降级为错误提示 + 保留 dismiss 按钮。
try {
this.element.innerHTML = this._renderHtml(diff);
this._bindEvents(diff);
} catch (e) {
console.error('[ai-diff-panel] setDiff failed:', e);
// M4 fix (audit):兜底再显式 unhide —— 理论上 line 115-116 已设置,
// 但若上一次的 setDiff 走的是 null 分支把 element + splitterElement 都 hidden 了,
// 本轮进入 try 前 line 115-116 一定执行;但若 _renderHtml 内部有同步逻辑
// 改了 dataset / 触发了 reflow极端情况catch 进来时面板可能仍处于 hidden。
// 显式再设一次保证失败降级 UI 一定可见、用户能点 dismiss 退出。
this.element.hidden = false;
if (this.splitterElement) this.splitterElement.hidden = false;
this.element.innerHTML = `
<div class="ai-diff-header">
<div>
<h2 class="ai-diff-title">AI 修改预览</h2>
<p class="ai-diff-summary">渲染失败:${escapeHtml(e && e.message || '未知错误')}</p>
</div>
<div class="ai-diff-actions">
<button class="ai-diff-dismiss" type="button" data-action="dismiss" aria-label="关闭 AI 修改预览">×</button>
</div>
</div>
<div class="ai-diff-empty">Diff 渲染异常,请关闭后重试</div>
`;
// 只绑定 dismiss —— 让用户能关掉这个面板
const onClick = (ev) => {
const t = ev.target.closest('[data-action]');
if (t && t.dataset.action === 'dismiss') this.onDismiss(diff);
};
this.element.addEventListener('click', onClick);
this._cleanup = () => this.element.removeEventListener('click', onClick);
}
}
_renderHtml(diff) {
const isReadOnly = !!diff.isReadOnly;
const regions = Array.isArray(diff.regions) ? diff.regions : [];
const regionsById = new Map(regions.map((r) => [r.id, r]));
const hasApplicableDiff = !isReadOnly && regions.length > 0;
const summary = isReadOnly
? 'AI 普通回复,仅供查看'
: hasApplicableDiff ? `${regions.length} 处待确认修改` : 'AI 返回内容与当前文档一致';
const headerActions = hasApplicableDiff
? `<button class="ai-diff-apply-all" type="button" data-action="apply-all">应用全部修改</button>`
: '';
const header = `
<div class="ai-diff-header">
<div>
<h2 class="ai-diff-title">AI 修改预览</h2>
<p class="ai-diff-summary">${escapeHtml(summary)}</p>
</div>
<div class="ai-diff-actions">
${headerActions}
<button class="ai-diff-dismiss" type="button" data-action="dismiss" aria-label="关闭 AI 修改预览">×</button>
</div>
</div>
`;
const body = this._renderBody(diff, regionsById);
return header + body;
}
_renderBody(diff, regionsById) {
if (diff.isReadOnly) {
// 普通文本回复:整块当 markdown 渲染或纯文本展示。
// 这里走 DOMPurifypreload 已暴露 window.api.renderMarkdown保持与 viewer 一致。
// 没设依赖时退化为 <pre> 纯文本safeRenderMarkdown 集中处理)。
const html = safeRenderMarkdown(diff.nextContent || '');
return `<div class="ai-reply-content markdown-body" role="region" aria-label="AI 普通回复">${html}</div>`;
}
const blocks = buildBlocks(diff.rows || []);
if (blocks.length === 0) {
return `<div class="ai-diff-empty">AI 返回了空内容</div>`;
}
const parts = blocks.map((block) => {
if (block.type === 'context') {
return renderDiffRow(block.row, false);
}
const region = regionsById.get(block.regionId);
const hasSkip = this.onSkipRegion && region && region.conflict;
const conflict = region && region.conflict
? `<div class="ai-diff-conflict">${escapeHtml(region.conflict)}</div>`
: '';
// conflict 时除了 disabled "应用此处",再多给一个 "跳过此处" 按钮:
// 用户放弃这一处但保留其它 region 的应用机会(之前只能 dismiss 整篇 diff
const applyBtn = !region
? ''
: `<button class="ai-diff-region-apply" type="button" data-action="apply-region" data-region-id="${escapeHtml(block.regionId)}"${region.conflict ? ' disabled' : ''}>应用此处</button>`;
const skipBtn = hasSkip
? `<button class="ai-diff-region-skip" type="button" data-action="skip-region" data-region-id="${escapeHtml(block.regionId)}" aria-label="跳过该修改区域,保留其它修改">跳过此处</button>`
: '';
const regionHeader = !region
? `<div class="ai-diff-conflict">无法定位该修改区域</div>`
: `<div class="ai-diff-region-actions">${applyBtn}${skipBtn}</div>`;
const rowsHtml = block.rows.map((r) => renderDiffRow(r, false)).join('');
return `
<section class="ai-diff-region" data-region-id="${escapeHtml(block.regionId)}">
<header class="ai-diff-region-header">${regionHeader}${conflict}</header>
<div class="ai-diff-region-body">${rowsHtml}</div>
</section>
`;
}).join('');
return `<div class="ai-diff-fulltext" role="table" aria-label="AI 修改全文对比">${parts}</div>`;
}
_bindEvents(diff) {
const onClick = (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
// audit fix (Round 13 / Sec-H1):拒绝 AI 控制内容里的 [data-action]。
// 威胁isReadOnly=trueAI 普通文本回复)时,整块内容由 AI 控制的 markdown
// 渲染得到DOMPurify 默认不过滤 data-* / <span>/<button> —— 攻击者(或
// 通过 prompt injection 诱导)可在回复里塞
// `<span data-action="apply-all" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647"></span>`
// 做成全屏透明覆盖层;用户的下一次任意位置点击会沿事件冒泡命中
// closest('[data-action]') → apply-all → editor.applyUserChange(nextContent)
// —— 静默把打开的笔记替换为 AI 的聊天回复(数据销毁)。
// 修复策略:信任域分离 —— read-only 走 markdown-body 这条信任链,事件委托
// 只接受 panel 自身渲染的「应用全部 / 应用此处 / 跳过 / 关闭」按钮。其他按钮
// 路径下AI 内容里的 [data-action] 同样应被忽略(防御未来新按钮路径),用
// 「目标在 .ai-reply-content 子树内即忽略」统一兜底。
if (target.closest && target.closest('.ai-reply-content')) return;
const action = target.dataset.action;
if (action === 'dismiss') {
this.onDismiss(diff);
} else if (action === 'apply-all') {
this.onApplyAll(diff);
} else if (action === 'apply-region') {
const regionId = target.dataset.regionId;
if (regionId) this.onApplyRegion(regionId, diff);
} else if (action === 'skip-region') {
// 仅在 controller 给了 onSkipRegion 时才会渲染这个按钮;防御性再判一次
const regionId = target.dataset.regionId;
if (regionId && this.onSkipRegion) this.onSkipRegion(regionId, diff);
}
};
this.element.addEventListener('click', onClick);
this._cleanup = () => this.element.removeEventListener('click', onClick);
}
destroy() {
if (this._cleanup) this._cleanup();
this._cleanup = null;
}
}

72
src/ai/ai-status.js Normal file
View File

@@ -0,0 +1,72 @@
// src/ai/ai-status.js —— AI 链路状态类型 / 错误码常量
//
// 把散落在 ai-controller / ai-chat-panel / ai-diff-panel 里的字面量集中
// 单一事实源;状态类型与 chat panel 的 notify / data-ai-state / dataset.status
// 三处渲染点保持一致。
//
// 错误码统一走 shared/ai-errors.jsCJSmain / preload / renderer 三处共用)。
// renderer 是 ESM 不能直接 require CJS所以 preload 经 contextBridge 暴露
// window.api.aiErrors.AI_ERROR本文件在模块顶层取这个值。单测jsdom 无 preload
// 走下方本地副本 —— 副本与 shared/ai-errors.js 同字面量,新增错误码时两边一起改,
// 由 tests/unit/ai-errors.test.js 校验两边一致。
/**
* AI 状态类型 —— renderer 端所有状态渲染都引用这里。
* 命名与 [data-ai-state] dataset 属性一一对应(见 ai-diff-panel / ai-chat-panel
*/
export const AI_STATUS = Object.freeze({
IDLE: 'idle',
RUNNING: 'running',
CANCELLED: 'cancelled',
ERROR: 'error',
SUCCESS: 'success',
DIFF: 'diff',
});
/**
* AI 错误码 —— 来自 shared/ai-errors.js#AI_ERROR。renderer 在模块顶层直接
* window.api?.aiErrors?.AI_ERROR 取值取不到时jsdom 单测 / preload 注入失败)
* 退化到下方本地副本。
*
* audit fix (CQ-MED-7):之前 renderer 各自维护一份字面量注释提醒"必须与 main/ai.js
* 同名常量一致"——实际曾漂移renderer 旧版 ERR_TIMEOUT='ERR_TIMEOUT' 与 main 的
* 'AI_TIMEOUT' 对不上aiErrorMessage switch 永不命中、永远走 fallback 'AI 修改失败')。
* 现在共享同一文件,注释提醒的"靠人同步"变成"靠文件同步"。
*/
const SHARED_AI_ERROR = (typeof window !== 'undefined'
&& window.api?.aiErrors?.AI_ERROR) || null;
const LOCAL_AI_ERROR_FALLBACK = Object.freeze({
NOT_CONFIGURED: 'AI_NOT_CONFIGURED',
NO_FILE: 'NO_FILE',
EDITOR_UNAVAILABLE: 'EDITOR_UNAVAILABLE',
IPC_FAILED: 'IPC_FAILED',
GENERATION_STALE: 'GENERATION_STALE',
FILE_CHANGED: 'FILE_CHANGED',
ERR_TIMEOUT: 'AI_TIMEOUT',
ERR_PROVIDER: 'AI_PROVIDER_ERROR',
ERR_FORMAT: 'AI_BAD_RESPONSE',
ERR_CANCELLED: 'AI_CANCELLED',
});
export const AI_ERROR = SHARED_AI_ERROR || LOCAL_AI_ERROR_FALLBACK;
/**
* 把 AI 错误码映射成中文消息(用于 status bar chip / toast
* 单一映射点,避免各组件各自拼字符串。
*/
export function aiErrorMessage(code, fallback) {
switch (code) {
case AI_ERROR.NOT_CONFIGURED: return 'AI 未配置,已打开设置';
case AI_ERROR.NO_FILE: return '请先打开 Markdown 文件';
case AI_ERROR.EDITOR_UNAVAILABLE: return '无法读取编辑器内容,已取消本次 AI 请求';
case AI_ERROR.IPC_FAILED: return 'AI 通信异常';
case AI_ERROR.GENERATION_STALE: return '文件已切换,旧 AI 响应已丢弃';
case AI_ERROR.FILE_CHANGED: return '文件已被外部修改';
case AI_ERROR.ERR_TIMEOUT: return 'AI 请求超时';
case AI_ERROR.ERR_PROVIDER: return 'AI 服务返回异常';
case AI_ERROR.ERR_FORMAT: return 'AI 响应格式无法解析';
case AI_ERROR.ERR_CANCELLED: return '已取消 AI 请求';
default: return fallback || 'AI 修改失败';
}
}

3959
src/app.js Normal file

File diff suppressed because it is too large Load Diff

118
src/component.js Normal file
View File

@@ -0,0 +1,118 @@
// 可销毁组件的样板AbortController mixin
//
// 之前大部分 classFileList / MarkdownViewer / Toolbar / EditorToolbar / ContextMenu
// 在构造函数里直接 `element.addEventListener(...)` 挂 listener却没有统一的
// dispose 路径。结果:
// - 单例组件销毁时无法 unbindlistener 持有闭包 → GC 不掉
// - 测试 / HMR 场景下,组件替换时旧 listener 仍触发回调
// - listener 多起来后还得手动写 `this._abort()` / `removeEventListener`,无样板
//
// 这个文件提供一个一次性 AbortController + 在 signal 上挂 listener 的薄封装:
//
// class FileList {
// constructor(opts) {
// const disposable = createDisposable(this);
// this.disposable = disposable; // 暴露 signal必要时给外部用
// this.addEventListener = disposable.addEventListener;
// this.dispose = disposable.dispose;
// element.addEventListener('click', (e) => ..., { signal: disposable.signal });
// }
// }
//
// 调用 `instance.dispose()` 一次性 abort 所有挂在这个 signal 上的 listener。
/**
* @typedef {object} Disposable
* @property {AbortSignal} signal - 给 addEventListener({ signal }) 用
* @property {typeof HTMLElement.prototype.addEventListener} addEventListener
* 包装版 addEventListener自动挂 signal。等价于手动传 { signal: this.signal }。
* @property {(fn: () => void) => void} addAbort - 注册 abort 时一次性执行的清理逻辑
* @property {() => void} dispose - 触发 abort + 标记 disposed幂等
* @property {boolean} isDisposed - 当前是否已 dispose
*/
/**
* 给目标对象(通常是一个 class 实例)添加可销毁 listener 能力。
* 注意:本函数不会修改 target 的原型链 —— 只挂你需要的属性。调用方决定挂哪些。
*
* @param {object} [target] - 用来挂 mixin 属性的对象(默认返回独立对象)
* @returns {Disposable}
*/
export function createDisposable(target = {}) {
const controller = new AbortController();
let disposed = false;
/**
* 包装 addEventListener默认注入当前 signal省略 opts 时等价于
* `{ signal: controller.signal, capture: false, passive: false }`。
* 注意opts 里有 signal 时以调用方为准(允许覆盖,例如与其它组件共享 signal
*/
function addEventListener(elementOrTarget, type, handler, opts) {
if (!elementOrTarget || typeof elementOrTarget.addEventListener !== 'function') {
throw new TypeError('addEventListener: target 不支持 addEventListener');
}
const merged = mergeOptsWithSignal(opts, controller.signal);
elementOrTarget.addEventListener(type, handler, merged);
}
function addAbort(fn) {
if (typeof fn !== 'function') return;
if (controller.signal.aborted) {
// 已经 abort 了,立即同步执行(与 AbortSignal 的「后注册的 abort listener 同步触发」语义一致)
try { fn(); } catch { /* ignore */ }
return;
}
controller.signal.addEventListener('abort', () => {
try { fn(); } catch { /* ignore */ }
}, { once: true });
}
function dispose() {
if (disposed) return;
disposed = true;
controller.abort();
}
target.signal = controller.signal;
target.addEventListener = addEventListener;
target.addAbort = addAbort;
target.dispose = dispose;
// audit fix (renderer-M12):原本无脑用 Object.defineProperty 覆盖 isDisposed
// 子类如果有自己的 isDisposed getter/setter如 lifecycle tracking会被悄悄
// 顶掉 —— mixin 的不变量没保证。先看 target 自身是否已声明 isDisposed
// 有就让宿主定义胜出(已有 own / 显式 prototype 定义都跳);只在完全没
// 这个属性时才注入 mixin 的 getter。
if (!Object.prototype.hasOwnProperty.call(target, 'isDisposed')
&& !Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'isDisposed')) {
Object.defineProperty(target, 'isDisposed', {
get() { return disposed; },
enumerable: true,
});
}
return /** @type {Disposable} */ (target);
}
/**
* 把 signal 合并到 opts 里。
* - opts === undefined → { signal }
* - opts 已有 signal → 不动(调用方说了算)
* - opts 是 AddEventListenerOptions 对象boolean 是 capture→ 扩展为对象
*
* @param {AddEventListenerOptions|boolean|undefined} opts
* @param {AbortSignal} signal
* @returns {AddEventListenerOptions|boolean}
*/
function mergeOptsWithSignal(opts, signal) {
if (opts === undefined || opts === null) {
return { signal };
}
if (typeof opts === 'boolean') {
// 旧 APIopts = useCapture。signal 在 capture 阶段拦截意义不大,但语义上没问题。
return { capture: opts, signal };
}
if (typeof opts === 'object' && 'signal' in opts) {
return opts;
}
return { ...opts, signal };
}

127
src/confirm-dialog.js Normal file
View File

@@ -0,0 +1,127 @@
// 通用确认对话框
// 复用 .modal-overlay / .modal / .btn-primary / .btn-secondary 样式src/styles.css
//
// 用于:
// - 切换文件前的「未保存改动」提示
// - 外部修改冲突时的「保留我的 / 使用外部」选择
// - 未来扩展(删除 / 重命名等)
//
// 返回 Promise resolve 一个按钮 value默认 'cancel' / 'confirm')。
// 用户按 Esc 或点击背景 → resolve('cancel')。
//
// 骨架来自 src/modal.js这里只关心「标题 / 正文 / 按钮列表」三件事。
import { escapeHtml, escapeAttr } from './utils.js';
import { mountModal } from './modal.js';
// audit fix (Phase L2 #7)modal 被另一个对话框占位时给用户 toast避免
// 重要冲突(未保存改动 / 外部修改)静默 cancel 后用户误以为已生效。
import { showToast } from './feedback.js';
/**
* 预置图标 SVG与设置对话框 .settings-section-icon 同语言24×24 viewBox,
* stroke-based, 1.8px stroke-width。调用方传 icon 名字即可,不必内联 SVG。
*
* - warning警告三角 —— 未保存改动 / 放弃前提醒
* - danger圆形 × —— 删除 / 不可逆操作
* - conflict双向箭头 —— 外部修改冲突
* - info圆形 i —— 一般信息提示
*/
const CONFIRM_ICONS = {
warning: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3L2 21h20L12 3z"/>
<line x1="12" y1="10" x2="12" y2="14"/>
<circle cx="12" cy="17.5" r="0.5" fill="currentColor" stroke="none"/>
</svg>`,
danger: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<line x1="8" y1="8" x2="16" y2="16"/>
<line x1="16" y1="8" x2="8" y2="16"/>
</svg>`,
conflict: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<polyline points="16 10 16 6 12 6"/>
<polyline points="8 14 8 18 12 18"/>
<line x1="16" y1="6" x2="18" y2="8"/>
<line x1="8" y1="18" x2="6" y2="16"/>
</svg>`,
info: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<circle cx="12" cy="7.5" r="0.5" fill="currentColor" stroke="none"/>
</svg>`,
};
/**
* 显示一个确认对话框
* @param {object} options
* @param {string} options.title - 标题
* @param {string} options.message - 正文(支持简单 HTML会被 escape
* @param {Array<{value:string,label:string,variant?:'primary'|'secondary'|'danger'}>} options.buttons - 按钮列表;最后一个按钮 = 默认焦点
* @param {'warning'|'danger'|'conflict'|'info'|string} [options.icon] - 预置图标名或自定义 SVG 字符串;不传则不显示图标
* @returns {Promise<string|null>} 被点击按钮的 value用户取消Esc / 点击背景 / 关闭按钮)时返回 'cancel'
* modal 已被另一个对话框占用(无法打开)时返回 null同时弹 toast 提示用户)
*/
// 按钮 variant 白名单 —— 防 audit A10调用方若误传 'foo' 等非法值,
// 之前会得到 `btn btn-foo`CSS 没这个类,按钮变成无样式的纯文本。
const BUTTON_VARIANTS = new Set(['primary', 'secondary', 'danger']);
export function showConfirmDialog({ title, message, buttons = [], icon }) {
return new Promise((resolve) => {
const footerHtml = buttons.map((b) => {
const variant = BUTTON_VARIANTS.has(b.variant)
? b.variant
: (b.value === 'cancel' ? 'secondary' : 'primary');
return `<button class="btn btn-${variant}" type="button" data-action="${escapeAttr(b.value)}">${escapeHtml(b.label)}</button>`;
}).join('');
// 图标区域:预置名 → 取内置 SVG自定义 SVG 字符串 → 直接用;无 → 不渲染。
// icon 颜色由 CSS 的 .confirm-icon-wrap 变体控制warning 走 --warning, danger 走 --danger
const iconSvg = icon ? (CONFIRM_ICONS[icon] || icon) : '';
// iconVariant 用于 CSS 着色:预置名直接传,自定义 SVG 默认走 'info'。
const iconVariant = icon ? (CONFIRM_ICONS[icon] ? icon : 'info') : '';
const iconHtml = iconSvg
? `<div class="confirm-icon-wrap confirm-icon-${escapeAttr(iconVariant)}">${iconSvg}</div>`
: '';
const bodyHtml = `${iconHtml}<p class="confirm-message">${escapeHtml(message)}</p>`;
const modal = mountModal({
title,
body: bodyHtml,
footer: footerHtml,
onAction: () => {
// 任一按钮按下:返回 'close' 由 mountModal 统一负责关闭
// —— 自己不调 modal.close避免与 modal.js 的 close(action) 重复触发
return 'close';
},
onBackdropClose: 'cancel',
});
// 给 confirm 类型 modal 加专属 class —— CSS 用来收窄宽度confirm 文案短,
// 不需要 settings dialog 的 560px、调整 body padding 与 icon 布局。
if (modal) modal.modal.classList.add('modal--confirm');
if (!modal) {
// P1-3modal 已被另一个对话框占用(罕见但会发生,比如用户在设置对话框
// 开着时按 Ctrl+S。旧实现降级到 window.confirm —— 但 confirm 是浏览器
// 原生模态,会与现有 custom modal 同屏出现用户先关掉原生框custom
// modal 还浮在上面,体验割裂。
// 这里改为「静默 cancel」把决定权交还给调用方由它决定是排队重试还是
// 干脆放弃settings dialog 的 preUpdateHook 就是这么用的)。
//
// audit fix (Phase L2 #7):但「重要冲突」场景(未保存改动、外部修改冲突)
// 静默 cancel 等于丢数据 —— 用户看到「啥都没发生」,可能误以为已保存。
// 给一个 toast 提示用户「先关闭占用方」,与 settings-dialog 的行为对齐。
//
// fix(audit 2026-08):原版与「用户主动取消」都返回 'cancel',导致调用方
// app.js 外部修改冲突)把 modal-occupied 误识别为用户主动选择「暂不处理」
// → 静默推进 lastSavedMtimeMs → 后续外部修改被吞。改为返回 null让调用方
// 能区分「对话框没出来」与「用户主动取消」。
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
resolve(null);
return;
}
modal.setOnClose(resolve);
});
}

285
src/context-menu.js Normal file
View File

@@ -0,0 +1,285 @@
// 自定义右键菜单
//
// 用于 markdown 阅读视图和编辑器,统一支持:复制 / 剪切 / 粘贴 / 全选。
//
// 设计要点:
// - 单一浮动菜单实例,按需定位显示(同一时刻只可能有一个菜单)
// - 选项根据上下文(是否可编辑 / 是否有选区)动态启用禁用
// - 完全基于 navigator.clipboard不依赖浏览器默认菜单
// - 显示位置靠近视口边缘时自动向内收缩
// - 在 mousedown / 第二个 contextmenu / ESC / scroll / resize 时自动关闭
const MENU_ID = 'app-context-menu';
/**
* 右键菜单项定义
* @typedef {object} ContextMenuItem
* @property {string} label - 显示文本
* @property {string} value - 点击时回调收到的值
* @property {boolean} [disabled]
* @property {boolean} [separator] - 若为 true 则渲染为分隔条(忽略其它字段)
*/
/**
* 菜单配置
* @typedef {object} ContextMenuShowOptions
* @property {number} x - 视口 x 坐标
* @property {number} y - 视口 y 坐标
* @property {ContextMenuItem[]} items
* @property {(value: string) => void} onSelect
*/
export class ContextMenu {
/**
* @param {object} [options]
* @param {HTMLElement} [options.container] - 菜单挂载容器(默认 document.body
*/
constructor({ container } = {}) {
this.container = container || document.body;
this.element = null;
this.onSelectCallback = null;
// audit fix (Settings P3 / context-menu focus restore)
// 记录 show 之前 document.activeElement —— hide() 时尝试把焦点还回去。
// 原版没记录,菜单关闭后焦点散落到 <body>,键盘用户失位:
// - 侧栏 file-list 右键 → 选「重命名」后 dialog 打开,关闭后焦点
// 跑回 body 而非原先选中的 file-list item键盘用户必须再 Tab
// 一遍才能继续上下选。
// - 编辑器右键 → 选「全选」或「粘贴」后,焦点丢在 body。
// 守卫:忽略 <body> / <html>,避免把焦点「还」到根(与 focus-trap.js
// dispose 路径保持同样的 prevFocus 过滤)。
this._prevFocus = null;
this._blurFrame = null;
// 提前 bind方便 add/removeEventListener 引用同一函数
this._onDocMouseDown = this._onDocMouseDown.bind(this);
this._onDocContextMenu = this._onDocContextMenu.bind(this);
this._onWindowScroll = this._onWindowScroll.bind(this);
this._onWindowResize = this._onWindowResize.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._onMenuFocusOut = this._onMenuFocusOut.bind(this);
}
/**
* 显示菜单
* @param {ContextMenuShowOptions} options
*/
show({ x, y, items, onSelect }) {
this.hide();
if (!Array.isArray(items) || items.length === 0) return;
this.onSelectCallback = onSelect || null;
// 捕获 prevFocus在菜单创建之前 activeElement 已经是「用户操作过的元素」,
// hide() 还要把焦点还回它。守卫 body/rootElement与 focus-trap.js 同套)。
{
const ae = document.activeElement;
this._prevFocus = (ae && ae !== document.body && ae !== document.documentElement
&& typeof ae.focus === 'function') ? ae : null;
}
const menu = document.createElement('div');
menu.id = MENU_ID;
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
// audit L (a11y)menu 隐含 vertical orientation但显式声明可减少
// 屏幕阅读器误判NVDA / JAWS 习惯按 aria-orientation 决定是 ←/→ 还是 ↑/↓)
menu.setAttribute('aria-orientation', 'vertical');
/** @type {HTMLButtonElement[]} */
const menuItems = [];
for (const item of items) {
if (item && item.separator) {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
sep.setAttribute('role', 'separator');
menu.appendChild(sep);
continue;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'context-menu-item';
btn.setAttribute('role', 'menuitem');
btn.textContent = item.label;
if (item.disabled) {
btn.disabled = true;
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (btn.disabled) return;
const value = item.value;
// audit fix先保存回调再 hide() —— hide() 内部会把 this.onSelectCallback
// 清成 null旧顺序hide() 在前 → 后续 if 检查永远失败 → 右键菜单点击
// 全部静默失效)。这影响所有右键入口(侧栏 重命名/在文件夹中显示/删除、
// 编辑器 复制/剪切/粘贴/全选)。
const cb = this.onSelectCallback;
this.hide();
if (cb) cb(value);
});
menu.appendChild(btn);
menuItems.push(btn);
}
this.container.appendChild(menu);
this.element = menu;
// 保留菜单项引用,供方向键导航复用。
this._items = menuItems;
// 定位:菜单尺寸必须先知道,所以先 append 再算坐标。
// 视口边缘自适应收缩(避免菜单跑出屏幕外)。
const rect = menu.getBoundingClientRect();
const margin = 4;
const maxX = window.innerWidth - rect.width - margin;
const maxY = window.innerHeight - rect.height - margin;
const left = Math.max(margin, Math.min(x, Math.max(margin, maxX)));
const top = Math.max(margin, Math.min(y, Math.max(margin, maxY)));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
// 关闭监听必须在本次 contextmenu 事件结束之后再绑定,
// 否则本次的 mousedown 不会触发(右键按下时也会派发 mousedown
// 但其他位置再点右键时本次菜单应被关闭,所以下一次 contextmenu 仍要监听。
// audit #C3 fix (memory leak critical):保存 rAF handle。
// 旧实现hide() 同步把 this.element = null但 rAF 回调里依然跑
// addEventListener — 如果用户在同一帧内连续 show→hide→show旧菜单点选项
// 触发 onSelect 立即 hide新菜单又 show第一次的 rAF 仍会 fire
// 给 document / window 挂上 5 个 listener且永远不会被 removeEventListener
// 回收hide 看 this.element === null 直接 early-return。每个周期泄漏 5 个
// listener伴随绑定函数对 this 的强引用 → GC 不掉 → 右键速度随时间线性变慢。
// 现在hide() 先 cancelAnimationFrame下次 show 再请求rAF 内若 this.element
// 已不是刚绑定的菜单,则不挂监听(防御性二次校验)。
this._attachFrame = requestAnimationFrame(() => {
this._attachFrame = null;
// 二次校验rAF fire 时如果 hide 已跑this.element 已 null跳过监听绑定。
if (!this.element || this.element !== menu) return;
document.addEventListener('mousedown', this._onDocMouseDown, true);
document.addEventListener('contextmenu', this._onDocContextMenu, true);
window.addEventListener('scroll', this._onWindowScroll, true);
window.addEventListener('resize', this._onWindowResize, true);
document.addEventListener('keydown', this._onKeydown, true);
// audit fix (Settings P3 / context-menu blur close):菜单自身的 focusout。
// 旧版只盯 mousedown / contextmenu / scroll / resize / Esc —— 但用户用
// Tab 把焦点移出菜单再点别处时不会触发 mousedownfocus 已经离开菜单),
// 菜单留在屏幕上"卡死"。挂 focusout 在 capture 阶段,检查 relatedTarget
// 是否仍在菜单内 —— 不在则收起。
menu.addEventListener('focusout', this._onMenuFocusOut, true);
// audit H (a11y):菜单打开后立即把焦点放在第一个非 disabled 项,
// 否则键盘用户没法用菜单Tab 会跳到下个页面元素。WAI-ARIA menu 模式
// 要求"打开后焦点进入菜单"。
const first = menuItems.find((b) => !b.disabled);
if (first) first.focus();
});
}
/**
* 主动关闭菜单
*/
hide() {
// 取消未 fire 的 rAF避免它在我们 detach 之后再去挂监听(见 show 注释)。
if (this._attachFrame != null) {
cancelAnimationFrame(this._attachFrame);
this._attachFrame = null;
}
if (!this.element) return;
const menu = this.element;
document.removeEventListener('mousedown', this._onDocMouseDown, true);
document.removeEventListener('contextmenu', this._onDocContextMenu, true);
window.removeEventListener('scroll', this._onWindowScroll, true);
window.removeEventListener('resize', this._onWindowResize, true);
document.removeEventListener('keydown', this._onKeydown, true);
menu.removeEventListener('focusout', this._onMenuFocusOut, true);
if (menu.parentElement) {
menu.parentElement.removeChild(menu);
}
this.element = null;
this._items = null;
this.onSelectCallback = null;
// audit fix (Settings P3 / context-menu focus restore)hide 完成后
// 把焦点还回 prevFocus。rAF 推一拍 —— 此刻 menu 刚 removeChild焦点
// 已经因 focusout / mousedown 落到 bodyrAF 让出这一帧让用户操作稳定,
// 再把焦点给回 prevFocus避免被后续的 focus() 事件覆盖。
if (this._prevFocus) {
const target = this._prevFocus;
this._prevFocus = null;
// 二次校验:节点可能被外部重渲染撕下 DOMfile-list 在 files:changed
// 重建),与 focus-trap.js dispose 的 isConnected 守卫保持一致。
if (target.isConnected) {
requestAnimationFrame(() => {
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
});
}
}
}
/** 当前是否处于显示状态 */
isVisible() {
return !!this.element;
}
_onDocMouseDown(e) {
if (this.element && this.element.contains(e.target)) return;
this.hide();
}
_onDocContextMenu(e) {
// 同一菜单内的右键:忽略,让菜单保持显示
if (this.element && this.element.contains(e.target)) return;
// 其它位置的右键:先关掉自己,由后续事件处理器决定是否打开新菜单
this.hide();
}
_onWindowScroll() {
this.hide();
}
_onWindowResize() {
this.hide();
}
_onKeydown(e) {
if (e.key === 'Escape') {
this.hide();
return;
}
// audit H (a11y)menu 模式的方向键导航 —— ↑/↓ 移动焦点(跳过 disabled
// 与 separatorHome/End 跳首尾。WAI-ARIA Authoring Practices 推荐。
if (!this._items || this._items.length === 0) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End') {
// 只在焦点仍在菜单内时拦截 —— 防止外部其它快捷键(如 editor 里的
// 方向键移动光标)被这里吞掉。
const active = document.activeElement;
const inMenu = active && this.element && this.element.contains(active);
if (!inMenu) return;
e.preventDefault();
const enabled = this._items.filter((b) => !b.disabled);
if (enabled.length === 0) return;
let idx = enabled.indexOf(active);
if (idx === -1) idx = e.key === 'ArrowUp' ? enabled.length - 1 : 0;
else if (e.key === 'ArrowDown') idx = (idx + 1) % enabled.length;
else if (e.key === 'ArrowUp') idx = (idx - 1 + enabled.length) % enabled.length;
else if (e.key === 'Home') idx = 0;
else if (e.key === 'End') idx = enabled.length - 1;
enabled[idx].focus();
}
}
// audit fix (Settings P3 / context-menu blur close)
// 焦点离开菜单时收起。
// - capture 阶段抓 —— 让外层 focusout handler 先跑完(例如文件列表
// 自己的 focus 高亮),我们再决定是否要 hide。
// - relatedTarget 为 null窗口失焦 / DevTools 抓走焦点)也收起,避免
// 菜单"卡在屏幕上谁都看不见"。
// - rAF 推一拍focusout 与 mousedown 同帧时rAF 让 mousedown 的
// _onDocMouseDown 路径有机会先关菜单、避免双触发。
_onMenuFocusOut(e) {
if (!this.element) return;
const next = e.relatedTarget;
if (next && this.element.contains(next)) return;
if (this._blurFrame != null) return;
this._blurFrame = requestAnimationFrame(() => {
this._blurFrame = null;
this.hide();
});
}
}

29
src/dirty-sync.js Normal file
View File

@@ -0,0 +1,29 @@
// state.isDirty ↔ editor.isDirty() 单向提升同步 helperRound 3 bug 修复)
//
// 抽离动机app.js 的 syncDirtyFromEditor 是模块级闭包函数,无法 import 单测。
// 把「单向上提」的纯逻辑挪到本文件app.js 里再薄包一层负责 UI 同步
//syncSaveButton。这样单测可以纯函数式验证「editor 有未保存字符时 state
// 必须被提升」契约,不需要 mock 一堆 app.js 模块级闭包。
//
// 关键不变量(详见 app.js#syncDirtyFromEditor 注释块):
// - state.isDirty 是 onChange 维护的派生状态markdown-editor.js updateListener
// 在 IME 合成期间跳过 onChangestate.isDirty 可能 stale。
// - 半步保存场景state.isDirty=true + editor.isDirty()=false —— 这是有意保留
// 的「飞行期新字符」标记,本函数**绝不**把它盖成 false。
// - 因此本函数只做 false → true 单向提升,永远不做 true → false。
/**
* 把 state.isDirty 同步到 editor.isDirty() 的实时真相(仅提升)。
*
* @param {{ isDirty: boolean }} state - 应用状态对象(仅读 + 写 isDirty
* @param {{ isDirty: () => boolean }} editor - 编辑器实例(提供 isDirty() 真相)
* @returns {boolean} true 表示发生了 false→true 的提升false 表示无需提升
* (已是 true / editor 也说没改动 / 半步保存场景保持 true
*/
export function syncDirtyFromEditor(state, editor) {
if (!state.isDirty && editor.isDirty()) {
state.isDirty = true;
return true;
}
return false;
}

135
src/editor-theme.js Normal file
View File

@@ -0,0 +1,135 @@
// CodeMirror 6 主题 —— 单一来源
//
// 包含:
// - editorThemeSpec CSS 规则 spec用我们的设计令牌覆盖 oneDark
// - editorTheme 暗色主题dark: true → 让 oneDark 接管配色)
// - editorThemeLight 亮色主题(不挂 dark由 styles.css 的 [data-theme=light] 覆盖)
// - pickEditorTheme 按 body[data-theme] 选择对应 extension 数组
//
// 选择高亮用 `!important`oneDark 的 (0,5,0) 选择器特异性会盖住我们的规则,
// 否则用户在暗色主题下看到的是 oneDark 偏灰的 selection看不清
//
// 亮色 active line / active gutter / panels / cursor 等属性也用 !important
// @codemirror/view 的 base theme 用 `&light` / `&dark` 选择器(特异性 (0,2,0)
// 我们的 `.cm-xxx` 是 (0,1,0),会让 base theme 的硬编码色(亮 #e2f2ff / 暗 #222227
// 覆盖我们的 var(--bg-hover)。亮色下「活动行 gutter 变浅蓝色块」就是这个原因。
import { EditorView } from '@codemirror/view';
import { oneDark } from '@codemirror/theme-one-dark';
const editorThemeSpec = {
'&': {
// 显式用 var(--bg-viewer),跟 .editor-pane 同色 —— 视觉上仍是同一张画布,
// 但必须显式写oneDark 内部会给 .cm-editor 涂 #282c34硬编码
// 不写会被它盖掉,导致 dark default 下编辑器比预览明显偏灰。
// 用 !important 是因为 oneDark 的扩展在我们之后注入CSS 源顺序让它赢。
backgroundColor: 'var(--bg-viewer) !important',
color: 'var(--fg-primary) !important',
height: '100%',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-base)',
},
'.cm-content': {
caretColor: 'var(--accent) !important',
// 水平内边距:
// - 左 0让文字紧贴 gutter 右侧开始,与 .markdown-body 文字起点对齐
// gutter 自身宽度由 CodeMirror 自适应行号位数,约 30-40px
// 与 viewer 的 padding-left 32px 偏差 < 10px可接受
// - 右 --space-7与 viewer 的右侧内边距对齐
// 垂直保留 --space-5编辑器比 viewer 顶部留白小一半,写的时候更紧凑
padding: 'var(--space-5) var(--space-7) var(--space-5) 0',
},
'.cm-cursor, .cm-dropCursor': {
// base theme 给 .cm-cursor 设了黑色,&dark 下设 #528bffoneDark 的 cursor 蓝)。
// 我们的 accent 颜色被覆盖,需要 !important 盖掉。
borderLeftColor: 'var(--accent) !important',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--accent-selection) !important',
},
'::selection': {
backgroundColor: 'var(--accent-selection) !important',
},
'.cm-activeLine': {
// 同样要盖掉 oneDark 的 #6699ff0b蓝色调+ base theme 的 &dark #99eeff33 /
// &light #cceeff44改用我们中性的 bg-hover
backgroundColor: 'var(--bg-hover) !important',
},
'.cm-activeLineGutter': {
// base theme 用 `&light .cm-activeLineGutter { background-color: #e2f2ff }` /
// `&dark { background-color: #222227 }`,特异性 (0,2,0) 盖住我们的 (0,1,0)。
// 亮色下 active gutter 会变成浅蓝色块;暗色下变成深灰色块 —— 都是 base theme 赢。
// 加 !important 后我们的 var(--bg-hover) 才能稳定生效。
backgroundColor: 'var(--bg-hover) !important',
color: 'var(--fg-secondary) !important',
},
'.cm-gutters': {
// 显式 bg-viewer 盖掉 oneDark 给 .cm-gutters 涂的 #282c34
// —— 否则 gutter 会比 content 明显偏灰,看上去像"编辑区有个奇怪灰条"
backgroundColor: 'var(--bg-viewer) !important',
color: 'var(--fg-tertiary) !important',
border: 'none',
// 钉死宽度 = viewer 的 padding-left--space-7 = 32px
// 让 gutter 右侧紧贴的 .cm-content 文字起点 = viewer 文字起点,
// 双栏对比时「编辑」和「预览」同一行的 # 与 一 字真正对齐。
// CodeMirror 默认 gutter 按行号位数自适应(约 30-35px
// 钉死后 2 位数以内行号仍能放下;超长文件(千行+)再扩。
width: 'var(--space-7)',
minWidth: 'var(--space-7)',
maxWidth: 'var(--space-7)',
},
// 行号单元格:去掉默认 padding让行号在 32px gutter 内右对齐贴边
'.cm-gutter': {
padding: '0 6px 0 0',
},
'.cm-scroller': {
fontFamily: 'var(--font-mono)',
lineHeight: '1.7',
},
'.cm-panels': {
// base theme `&light .cm-panels { background-color: #f5f5f5; color: black }` /
// `&dark { background-color: #333338; color: white }` 会盖住我们的 (0,1,0) 选择器。
// 搜索面板 / autocomplete 等等都用 .cm-panels亮色下颜色不对。
backgroundColor: 'var(--bg-elevated) !important',
color: 'var(--fg-primary) !important',
},
};
/** 暗色主题dark 标记让 oneDark 自动覆盖我们的 CSS 变量 */
export const editorTheme = EditorView.theme(editorThemeSpec, { dark: true });
/** 亮色主题:不带 dark由 styles.css [data-theme=light] 覆盖色板 */
export const editorThemeLight = EditorView.theme(editorThemeSpec);
// ---- 测试钩 ---------------------------------------------------------------
// editorThemeSpec 不直接 export避免外部拿到 spec 后乱改)。
// 但 vitest 没法直接断言 EditorView.theme() 的内部 CSSOM 表现 —— 它是私有结构,
// 只能断言我们可控的部分backgroundColor / color 声明里带了 !important
// 才能盖住 oneDark 后注入的同 specificity 规则oneDark 的 .cm-editor { background-color: #282c34 }
// 以及 @codemirror/view base theme 的 `&light` / `&dark` 规则(特异性 (0,2,0))。
//
// 这些 getter 只在测试用prod 路径不会触达。
export const __editorThemeSpecForTests = {
get backgroundColor() { return editorThemeSpec['&'].backgroundColor; },
get color() { return editorThemeSpec['&'].color; },
get gutterBackgroundColor() { return editorThemeSpec['.cm-gutters'].backgroundColor; },
get gutterColor() { return editorThemeSpec['.cm-gutters'].color; },
get activeLineBackgroundColor() { return editorThemeSpec['.cm-activeLine'].backgroundColor; },
get activeLineGutterBackgroundColor() { return editorThemeSpec['.cm-activeLineGutter'].backgroundColor; },
get activeLineGutterColor() { return editorThemeSpec['.cm-activeLineGutter'].color; },
get cursorBorderLeftColor() { return editorThemeSpec['.cm-cursor, .cm-dropCursor'].borderLeftColor; },
get caretColor() { return editorThemeSpec['.cm-content'].caretColor; },
get panelsBackgroundColor() { return editorThemeSpec['.cm-panels'].backgroundColor; },
get panelsColor() { return editorThemeSpec['.cm-panels'].color; },
};
/**
* 按 body[data-theme] 选择当前主题的 extensions 数组。
* 暗色叠加 oneDark语法高亮亮色只用自己的覆盖。
*
* @param {'dark' | 'light' | undefined} theme
* @returns {Array}
*/
export function pickEditorTheme(theme) {
return theme === 'light' ? [editorThemeLight] : [oneDark, editorTheme];
}

82
src/editor-toolbar.js Normal file
View File

@@ -0,0 +1,82 @@
// 视图模式切换控件
// 三态preview默认/ edit / split
// 状态写 document.body.dataset.editorModeCSS 据此切布局。
export const EDITOR_MODES = ['preview', 'edit', 'split'];
export class EditorToolbar {
/**
* @param {object} options
* @param {object} options.elements - 三个按钮元素引用 { btnPreview, btnEdit, btnSplit }
* @param {(mode:'preview'|'edit'|'split') => void} options.onChange - 模式变化回调
*/
constructor({ elements, onChange }) {
this.elements = elements;
this.onChange = onChange;
if (elements.btnPreview) {
elements.btnPreview.addEventListener('click', () => this.selectMode('preview'));
}
if (elements.btnEdit) {
elements.btnEdit.addEventListener('click', () => this.selectMode('edit'));
}
if (elements.btnSplit) {
elements.btnSplit.addEventListener('click', () => this.selectMode('split'));
}
}
/**
* 由用户点击按钮或外部 API 主动选择模式。触发 onChange用于持久化
* @param {'preview'|'edit'|'split'} mode
*/
selectMode(mode) {
if (!EDITOR_MODES.includes(mode)) return;
if (this.getMode() === mode) return; // 同模式 → 不重复触发
this._setDOM(mode);
if (this.onChange) this.onChange(mode);
}
/**
* 同步 DOM 与按钮高亮,但**不**触发 onChange。
* 用于「外部(持久化设置)改变了模式」的场景,避免循环。
* @param {'preview'|'edit'|'split'} mode
*/
setMode(mode) {
if (!EDITOR_MODES.includes(mode)) return;
this._setDOM(mode);
}
/**
* 取当前模式(从 dataset 读,保证跟 DOM 一致)
*/
getMode() {
return document.body.dataset.editorMode || 'preview';
}
/**
* 三态循环preview → edit → split → preview
* 触发 onChange 回调。
*/
cycle() {
const cur = this.getMode();
const idx = EDITOR_MODES.indexOf(cur);
const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length];
this.selectMode(next);
}
_setDOM(mode) {
document.body.dataset.editorMode = mode;
const map = {
preview: this.elements.btnPreview,
edit: this.elements.btnEdit,
split: this.elements.btnSplit,
};
Object.entries(map).forEach(([k, btn]) => {
if (!btn) return;
const active = k === mode;
btn.classList.toggle('is-active', active);
// 让屏幕阅读器/键盘用户也能感知当前模式icon-only 按钮尤其依赖此属性
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
}

50
src/event-emitter.js Normal file
View File

@@ -0,0 +1,50 @@
// 极简 EventEmitter
// SettingsStore 等组件需要的事件订阅基础设施。
// 与 Todo List 的实现保持完全一致 —— 不引入任何外部依赖。
export class EventEmitter {
constructor() {
this._handlers = new Map();
}
on(event, handler) {
if (!this._handlers.has(event)) {
this._handlers.set(event, new Set());
}
this._handlers.get(event).add(handler);
return () => this.off(event, handler);
}
off(event, handler) {
const set = this._handlers.get(event);
if (set) set.delete(handler);
}
emit(event, payload) {
const set = this._handlers.get(event);
if (!set || set.size === 0) return;
// audit fix (Settings P3 / event-emitter snapshot):派发时先对 Set
// 拍一份快照。Set 在迭代期间的修改语义:
// - 新增的 handler会被访问ECMA-262 Set 规范)。
// - 删除的 handler如果已访问过不影响本次迭代。
// - 删除当前正在访问的 handler行为未定义 / 跨引擎不一致。
// 一个 handler 内部再去 on/off 同名事件就会触发上面第三条:
// - 副作用"重订阅"导致本次 emit 又派发给新 handler新 handler 收到
// 上一轮事件,常见 bug
// - 副作用"自 off"导致部分 handler 被跳过。
// - handler 抛错后被 catch 但后续 handler 仍需正常执行 —— 这条原来 OK。
// 拍快照后所有路径确定性,跨 V8 / JSC / SpiderMonkey 行为一致。
// 性能O(n) copy对 settingsStore 等小规模事件总线无可观测开销。
const snapshot = Array.from(set);
for (const h of snapshot) {
// 二次校验handler 在前面的执行里可能 off 自己snapshot 拿到时还在
// set 里,但下面那次循环已 off跳过避免重复派发。
if (!set.has(h)) continue;
try {
h(payload);
} catch (e) {
console.error(`[EventEmitter] handler for "${event}" threw:`, e);
}
}
}
}

68
src/feedback.js Normal file
View File

@@ -0,0 +1,68 @@
// 轻量反馈组件
// 仅保留 toast —— Notes 是只读阅读器,不需要 confirmDialog / inputDialog / contextMenu。
const TOAST_DEFAULT_DURATION = 3000;
// 同时存在的 toast 上限 —— 超出时关闭最早创建的,避免极端情况下无限堆积占满 DOM。
const MAX_VISIBLE_TOASTS = 5;
/**
* 显示一个 toast 消息
* @param {string} message - 消息内容
* @param {'info'|'success'|'warning'|'error'} [type='info']
* @param {number} [duration=3000] - 自动关闭毫秒数0 表示不自动关闭
*/
export function showToast(message, type = 'info', duration = TOAST_DEFAULT_DURATION) {
const container = document.getElementById('toast-container');
if (!container) {
// 无 toast container 是早期启动或测试环境(#toast-container 还没挂上),
// 静默丢弃 —— 这里原本的 console.log 会在生产控制台产生噪声,没人在读。
return;
}
// 超出上限时立刻同步移除最早的 toast不等淡出动画—— 顶部满的时候底部瞬间
// 消失一条是可接受的,比排队等淡出再插队让用户以为 toast 卡住了要直观。
// 同时打上 dismissed 标记,避免它自身的自动关闭 timer / click handler
// 在 toast 已脱离 DOM 后还跑回调(虽然 parentElement 检查能拦住,但
// dataset 检查短路更稳,少一次 console 输出)。
while (container.children.length >= MAX_VISIBLE_TOASTS) {
const oldest = container.firstElementChild;
if (!oldest) break;
if (oldest.parentElement === container) {
oldest.dataset.dismissed = '1';
container.removeChild(oldest);
}
}
const toast = document.createElement('div');
toast.className = `toast is-${type}`;
toast.setAttribute('role', 'status');
toast.textContent = message;
container.appendChild(toast);
let timer = null;
if (duration > 0) {
timer = setTimeout(() => dismiss(), duration);
}
// 点击立即关闭
toast.addEventListener('click', () => dismiss());
function dismiss() {
if (timer) {
clearTimeout(timer);
timer = null;
}
// 防止 transition 重入:若已经在淡出中,直接返回
if (toast.dataset.dismissed === '1') return;
toast.dataset.dismissed = '1';
toast.style.opacity = '0';
toast.style.transform = 'translateY(-4px)';
toast.style.transition = 'opacity 150ms ease, transform 150ms ease';
setTimeout(() => {
if (toast.parentElement === container) {
container.removeChild(toast);
}
}, 150);
}
}

476
src/file-list.js Normal file
View File

@@ -0,0 +1,476 @@
// 侧边栏文件列表组件
// 职责:渲染文件列表 + 处理点击 + 处理搜索过滤 + 排序
//
// 与 Todo List 的 sidebar.js 区别:移除分类、子分类、右键菜单、拖拽、智能列表。
// 只保留 "扁平文件列表 + 点击打开 + 搜索过滤 + 排序" 这四件事。
//
// Stage 8Folder Browser
// 列表里可以同时出现三种条目:
// - entryType === 'folder' : 进入子目录 → onSelect(entry) 中 entryType === 'folder'
// - entryType === 'editable': 打开编辑 → onSelect(entry) 中 entryType === 'editable'
// - entryType === 'binary' : 不支持打开,点击弹 toast → onSelect(entry) 中 entryType === 'binary'
// 渲染端 (app.js) 在 onSelect 里按 entryType 分发;本组件只负责按类型画图标 + 类名。
// data-path 仍用绝对路径(文件夹也有 path = 绝对目录),保持事件委托反查统一。
import { formatSize, formatRelativeTime, pathsEqual } from './utils.js';
import { createDisposable } from './component.js';
/**
* 文件名 fuzzy 匹配(子序列 + 评分)
*
* 算法query 的每个字符按顺序出现在 name 里(不要求相邻)。
* - 大写 / 小写不敏感(两端都 lowercase 后比较)
* - 越靠前的命中越得分高(开头匹配 > 末尾匹配)
* - 单词边界命中再加分(`n` 出现在「_」/「.」/「-」后立刻得分 boost
* - 完全包含子串 → 等同 fuzzy 得分 +50避免「完全匹配反而排后」的反直觉
*
* 返回 0 表示不匹配filter 直接丢弃)。
*
* 约定query 必须是小写。FileList 在 input handler 处统一做了 lowercase
* 这里不再重复 —— N 个文件就少 N 次 String#toLowerCase 调用。
*
* @param {string} name - 文件名(含扩展名)
* @param {string} lowerQuery - 已 lowercase 的搜索词
* @returns {number} 匹配分数0 = 不匹配
*/
function fuzzyScore(name, lowerQuery) {
if (!lowerQuery) return 1; // 空 query = 不过滤
const n = name.toLowerCase();
// 子串包含 → 直接高分fuzzy 子序列可能反而排后面)
let idx = n.indexOf(lowerQuery);
if (idx >= 0) {
// 靠前 + 单词边界 + 命中整词 → 更高分
let bonus = 0;
if (idx === 0) bonus += 30;
else if (idx > 0 && /[_\-.\s]/.test(n[idx - 1])) bonus += 20;
return 100 + bonus - idx;
}
// 子序列匹配fuzzy
let qi = 0;
let score = 0;
let lastMatchIdx = -1;
for (let ni = 0; ni < n.length && qi < lowerQuery.length; ni++) {
if (n[ni] === lowerQuery[qi]) {
// 相邻匹配加分(连续命中),否则只加基础分
if (lastMatchIdx === ni - 1) score += 8;
else score += 5;
// 单词边界 bonus
if (ni === 0 || /[_\-.\s]/.test(n[ni - 1])) score += 4;
// 位置越靠前 bonus 越多
score += Math.max(0, 10 - Math.floor(ni / 3));
lastMatchIdx = ni;
qi++;
}
}
if (qi < lowerQuery.length) return 0; // 没匹配完所有字符
return score;
}
/**
* 文件列表组件
*/
export class FileList {
/**
* @param {object} options
* @param {HTMLUListElement} options.element - <ul> 容器
* @param {HTMLInputElement} options.searchInput - 搜索输入框
* @param {(file: {path:string,name:string}) => void} options.onSelect - 点击文件回调
* @param {string} [options.sortMode='name'] - 排序模式:'name' | 'mtime-desc'
*/
constructor({ element, searchInput, onSelect, onContextMenu, sortMode = 'name' }) {
this.element = element;
this.searchInput = searchInput;
this.onSelect = onSelect;
this.onContextMenu = onContextMenu;
this.files = [];
this.filteredFiles = [];
this.activePath = null;
this.searchTerm = '';
this.sortMode = sortMode === 'mtime-desc' ? 'mtime-desc' : 'name';
/** path → file供事件委托时反查避免每个 <li> 挂一个闭包) */
this._byPath = new Map();
// 统一 lifecycledispose() 一次性 unbind 所有 listener。
createDisposable(this);
// 事件委托:整个列表只挂一对 click / keydown 监听。
// 原来是每次 render 给每个 <li> 各挂两个闭包 —— 列表一重建就要重新分配 2N 个函数,
// 搜索时每敲一个字符都重建一次。委托后 render 只管建 DOM。
if (element) {
element.addEventListener('click', (e) => {
const file = this._fileFromEvent(e);
if (file && this.onSelect) this.onSelect(file);
}, { signal: this.signal });
// 键盘激活与导航Enter/Space 等价于点击;↑/↓/Home/End 走 moveActive
// moveActive 只挪高亮,不会打开文件 —— 打开动作由 Enter/Space 触发,
// 走 _fileFromEvent(e) 从当前 focus 所在 LI 反查文件)。
element.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault(); this.moveActive(1); return;
}
if (e.key === 'ArrowUp') {
e.preventDefault(); this.moveActive(-1); return;
}
if (e.key === 'Home') {
e.preventDefault(); this.moveActive('first'); return;
}
if (e.key === 'End') {
e.preventDefault(); this.moveActive('last'); return;
}
if (e.key === 'Enter' || e.key === ' ') {
const file = this._fileFromEvent(e);
if (!file) return;
e.preventDefault();
if (this.onSelect) this.onSelect(file);
}
}, { signal: this.signal });
// 右键菜单弹给调用方处理context-menu.js
element.addEventListener('contextmenu', (e) => {
const file = this._fileFromEvent(e);
if (!file || !this.onContextMenu) return;
e.preventDefault();
this.onContextMenu(file, e.clientX, e.clientY);
}, { signal: this.signal });
}
if (searchInput) {
// 100ms trailing debounce —— 文件多时1000+)每按一键全量重算 + 重渲染
// 是 O(N) 的工作连续输入会卡debounce 让连续打字期间 CPU 闲下来。
// { signal: this.signal } 走 AbortController 路径dispose 时自动解绑。
let searchTimer = null;
const flushSearch = () => {
searchTimer = null;
this.searchTerm = searchInput.value.trim().toLowerCase();
this.applyFilter();
this.render();
};
// dispose 时把挂起的 debounce 定时器清掉 —— 否则它会在组件已销毁后
// 触发 flushSearch访问已被释放的 this.searchTerm / applyFilter / render。
// 见 createDisposable.addAbort 的「abort 时一次性执行清理」语义。
this.addAbort(() => {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
});
searchInput.addEventListener('input', () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(flushSearch, 100);
}, { signal: this.signal });
// 失焦时立即落盘当前值,不等 debounce —— 用户离开搜索框后还看到旧结果会困惑
searchInput.addEventListener('blur', () => {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
flushSearch();
}
}, { signal: this.signal });
}
}
/**
* 从委托事件里反查对应的 file 对象。
* @param {Event} e
* @returns {object|null}
*/
_fileFromEvent(e) {
const li = e.target.closest && e.target.closest('.file-item');
if (!li || !this.element.contains(li)) return null;
return this._byPath.get(li.dataset.path) || null;
}
/**
* 设置排序模式(供设置对话框等调用方热切换)
* @param {'name' | 'mtime-desc'} mode
*/
setSort(mode) {
const next = mode === 'mtime-desc' ? 'mtime-desc' : 'name';
if (next === this.sortMode) return;
this.sortMode = next;
this.applyFilter();
this.render();
}
/**
* 用新的条目列表替换当前列表
*
* Stage 8接受混合条目文件夹 + editable + binary。每条至少含
* { name, path, entryType: 'folder'|'editable'|'binary', isFolder?: boolean, size?, mtimeMs? }
* 向后兼容:旧调用传 { name, path, size, mtimeMs }(无 entryType→ 视为 'editable'。
*
* @param {Array<object>} entries
*/
setFiles(entries) {
const previousActive = this.activePath;
this.files = Array.isArray(entries)
? entries.map((e) => ({
...e,
entryType: e.entryType || (e.isFolder ? 'folder' : 'editable'),
}))
: [];
// M-series fix (audit Phase M-H2):移除 setFiles 内的 onFileRemoved 触发。
// 旧实现仅凭「previousActive 不在新 entries 里」就通知上层清空编辑器——
// 这把「用户导航到子目录」Folder Browser 语义:浏览期间不自动关文件)
// 与「文件被外部删除」混淆为同一信号,导致切子目录时编辑器被错误清空。
//
// 改成 setFiles 只负责渲染,外部清理判定上移到 app.js#applyEntries
// 用「文件目录 === 当前浏览目录」区分「文件被删」与「导航到别处」。
// signature`activePath` 与新 entries 不再自动清零 —— 由调用方决定要不要
// 通过 setActive(null) 主动清,否则高亮会跨目录污染。
// fix(audit 2026-08)Windows 路径大小写不敏感,=== 会让外部重命名(小
// 小写差异)后侧栏失去高亮。统一走 pathsEqual。
//
// 审计修复 (Round 11 P1-1):用 fs 返回的真实 case 覆盖 previousActive。
// 旧逻辑保留 previousActive用户输入时的大小写state.currentFile.path
// 与侧栏 data-path 出现大小写漂移IPC / 日志 / 错误消息里看着像两个文件。
// 现在回迁时优先取 this.files 里的真值fs 返回的磁盘 caseIPC 跨进程
// 看到的路径字符串统一。
const canonicalForActive = (active) => {
if (!active) return active;
const hit = this.files.find((f) => pathsEqual(f.path, active) && f.entryType !== 'folder');
return hit ? hit.path : active;
};
if (previousActive
&& !this.files.some(f => pathsEqual(f.path, previousActive) && f.entryType !== 'folder')) {
// audit fix (Round 9)保留「activePath 跨目录导航」回迁语义。
// 之前这里直接 activePath=null用户切走再切回原目录时 entries 又含
// previousActive但 activePath 已被清,侧栏不自动恢复高亮。
// 改成:当前目录不含 previousActive 时不主动清,存到 lastSeenActivePath
// 等下次 setFiles 时回迁;如果上层调 setActive(null) 显式清则一切如旧。
this.lastSeenActivePath = previousActive;
this.activePath = null;
} else if (previousActive
&& this.files.some(f => pathsEqual(f.path, previousActive) && f.entryType !== 'folder')) {
// 回迁到原目录entries 又含 previousActive→ 恢复 activePath
// 同时把大小写统一到 fs 真实 case见上 canonicalForActive 注释)。
this.activePath = canonicalForActive(previousActive);
this.lastSeenActivePath = null;
} else if (this.activePath) {
// 兜底:当前 activePath 还在 setFiles 命中的新 entries 里时,统一一次大小写。
// 例如外部 openFile 后接着 setFiles 没经过回迁分支activePath 仍可能是旧 case。
this.activePath = canonicalForActive(this.activePath);
}
this.applyFilter();
this.render();
}
/**
* 设置当前激活的文件(侧边栏高亮 + 调用方同步状态栏)
*
* 只挪 is-active 类,不重建列表 —— 这是最高频的渲染触发点(每次打开文件都走),
* 而列表内容并没有变化,重建一遍纯属浪费。
* @param {string|null} filePath
*/
setActive(filePath) {
const next = filePath || null;
if (next === this.activePath) return;
this.activePath = next;
if (!this.element) return;
// audit fixWindows 路径大小写不敏感state.currentFile.path 可能是用户输入
// 的 casefs 返回的是磁盘真实 case。直接 === 比对会让侧栏永远找不到 active
// 条目、不显示高亮。统一走 pathsEqual含 Windows case-fold
for (const li of this.element.children) {
const active = pathsEqual(li.dataset.path, this.activePath);
li.classList.toggle('is-active', active);
li.setAttribute('aria-selected', active ? 'true' : 'false');
}
}
/**
* 列表获焦时由键盘导航调用:移动激活态到相邻项。只挪高亮 + 焦点,
* 不触发 onSelect —— 打开文件由 Enter / Space 键负责(走 _fileFromEvent
* 旧版本在这里直接 onSelect导致每按一次方向键就打开一个文件。
* @param {number} delta -1 上一项 / +1 下一项 / 'first' / 'last'
*/
moveActive(delta) {
const items = Array.from(this.element.querySelectorAll('.file-item'));
if (items.length === 0) return;
// audit fix与 setActive 保持一致 —— Windows 上 case-fold 比对。
let idx = items.findIndex(li => pathsEqual(li.dataset.path, this.activePath));
if (idx < 0) idx = 0;
if (delta === 'first') idx = 0;
else if (delta === 'last') idx = items.length - 1;
else idx = Math.max(0, Math.min(items.length - 1, idx + delta));
const target = items[idx];
// 直接给目标项加高亮,避免 setActive→render 整树重画把焦点带走
items.forEach(li => {
const active = li === target;
li.classList.toggle('is-active', active);
li.setAttribute('aria-selected', active ? 'true' : 'false');
});
this.activePath = target.dataset.path;
// audit fix (2.4)focus 默认会触发 scrollIntoView → 侧栏布局可能跳动。
// 改用 preventScroll再用 scrollIntoView('nearest') 把项拉到可视区但不强制居中。
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
try { target.scrollIntoView({ block: 'nearest' }); } catch { /* ignore */ }
}
/**
* 应用搜索过滤 + 排序
*
* 搜索:先按 fuzzy 评分过滤 + 排序(高分在前),
* 再按 sortMode 排序name 或 mtime —— 排序优先级:
* 1) searchTerm 存在 → fuzzy 排序覆盖 sortMode用户明确在搜
* 2) searchTerm 为空 → 走 sortMode
*
* 这样 "wel" 仍能在 mtime-desc 模式下把 welcome.md 排前,而不是按修改时间硬排。
*/
applyFilter() {
const term = this.searchTerm;
let list;
if (!term) {
list = this.files.slice();
} else {
// fuzzy打分并过滤
const scored = [];
for (const f of this.files) {
const score = fuzzyScore(f.name, term);
if (score > 0) scored.push({ file: f, score });
}
scored.sort((a, b) => b.score - a.score);
list = scored.map(s => s.file);
}
// 仅在无搜索时使用 sortMode有搜索时 fuzzy 排序已覆盖
if (!term) {
if (this.sortMode === 'mtime-desc') {
list.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
} else {
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
}
}
this.filteredFiles = list;
}
/**
* 渲染 DOM
*/
render() {
if (!this.element) return;
this.element.replaceChildren();
// 重建 path → file 索引,供委托的 click / keydown 反查
this._byPath.clear();
for (const file of this.filteredFiles) {
this._byPath.set(file.path, file);
}
if (this.filteredFiles.length === 0) {
// 空状态分两种:搜索无结果 vs 目录真为空。
// - 搜索有词 → "没找到匹配项"(让用户清空搜索或换关键词)
// - 搜索为空 → "此文件夹为空"index.html 的 data-empty 默认值;
// setFiles 调用时 element 还没渲染数据时 element.dataset.empty 还是这个)
// 这样 UI 不会把"搜不到"误读成"目录空",也避免反过来。
const emptyMsg = this.searchTerm
? `没有匹配 “${this.searchTerm}” 的文件`
: '此文件夹为空';
this.element.dataset.empty = emptyMsg;
return;
}
// 有内容时清掉 data-empty避免渲染期间被 :empty::after 短暂闪一下
this.element.removeAttribute('data-empty');
const term = this.searchTerm;
const frag = document.createDocumentFragment();
for (const file of this.filteredFiles) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.path = file.path;
// entryType / isFolder 是渲染端做图标 / 灰态分流的依据,
// 也让右键菜单 / onSelect 分发有统一锚点。
li.dataset.entryType = file.entryType;
li.classList.add(`is-${file.entryType}`);
// 列表项自身是按钮语义:通过 role/tabindex/aria-selected 把键盘可达性补齐
// (容器 ul 已经声明 role="listbox",单项用 role="option" 是规范对应)
li.setAttribute('role', 'option');
li.tabIndex = 0;
// audit fixWindows 路径大小写不敏感fs 返回的真实 case 不一定等于
// state.currentFile.path 上记录的 case统一走 pathsEqual。
const isActive = pathsEqual(file.path, this.activePath);
if (isActive) {
li.classList.add('is-active');
li.setAttribute('aria-selected', 'true');
} else {
li.setAttribute('aria-selected', 'false');
}
const name = document.createElement('div');
name.className = 'file-name';
// 搜索高亮:纯 textContent + createElement('mark'),避免 innerHTML 引入 XSS
if (term) {
const lowerName = file.name.toLowerCase();
const idx = lowerName.indexOf(term);
if (idx >= 0) {
const before = file.name.slice(0, idx);
const hit = file.name.slice(idx, idx + term.length);
const after = file.name.slice(idx + term.length);
if (before) name.appendChild(document.createTextNode(before));
const mark = document.createElement('mark');
mark.textContent = hit;
name.appendChild(mark);
if (after) name.appendChild(document.createTextNode(after));
} else {
name.textContent = file.name;
}
} else {
name.textContent = file.name;
}
const meta = document.createElement('div');
meta.className = 'file-meta';
// Stage 8folder / binary 没有 size+mtime只有 editable 显示完整 meta。
// folder 显示「文件夹」binary 显示大小 + 「不可编辑」提示。
if (file.entryType === 'folder') {
meta.textContent = '文件夹';
} else if (file.entryType === 'binary') {
meta.textContent = `${formatSize(file.size)} · 不可编辑`;
} else {
meta.textContent = `${formatSize(file.size)} · ${formatRelativeTime(file.mtimeMs)}`;
}
// 文件图标:左侧装饰,让 list 项视觉更有「文件感」。
// aria-hidden因为纯视觉语义信息已经在 file-name 里。
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
icon.setAttribute('class', 'file-icon');
icon.setAttribute('viewBox', '0 0 24 24');
icon.setAttribute('width', '14');
icon.setAttribute('height', '14');
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '1.8');
icon.setAttribute('stroke-linecap', 'round');
icon.setAttribute('stroke-linejoin', 'round');
icon.setAttribute('aria-hidden', 'true');
// 不同类型用不同 SVG pathfolder / file / binary(带斜杠禁止感)
if (file.entryType === 'folder') {
icon.innerHTML = '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
} else if (file.entryType === 'binary') {
// 文件 + 斜杠:表达「不可编辑」
icon.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="5" y1="5" x2="19" y2="19"/>';
} else {
icon.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>';
}
// 文本容器:把 name + meta 包成一列,让整体用 grid 布局对齐 icon。
// 直接 append name/meta 到 li 会让它们和 icon 一起进入 grid 的同一列,
// 排版混乱;所以包一层 .file-text。
const text = document.createElement('div');
text.className = 'file-text';
text.appendChild(name);
text.appendChild(meta);
li.appendChild(icon);
li.appendChild(text);
// 点击 / 键盘由构造函数里的委托监听统一处理,这里不再逐项绑定
frag.appendChild(li);
}
this.element.appendChild(frag);
}
}

481
src/file-ops.js Normal file
View File

@@ -0,0 +1,481 @@
// 文件操作层Stage 4c 抽离)
//
// 渲染端文件 CRUD创建、重命名、删除、显示。
// 全部走 IPC 调用主进程,结果落到 toast / state / 当前视图上。
//
// 设计:
// - 接收上下文对象state / controls / api / callbacks不直接 import app.js
// - 通过 createFileOps(ctx) factory 暴露,避免任何模块级副作用
// - 公开 API 仅 showFileListContextMenu / createNewFile其他函数仅内部互调
//
// 边界:
// - 不读 currentFile / isDirty 等 state 字段之外的全局
// - 不调 updateWindowTitle / syncSaveButton 等 UI 同步函数以外的回调
// (完整名单见 ctx.callbacks
// 文件系统 errno → 中文友好提示Round 4 收尾后抽离到 shared/friendly-fs-error.js
// preload 暴露成 window.api.friendlyFsError。本文件内部 + 测试都走这一份,
// 不再各自维护 mapping —— 避免 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 文案
// 漂移。本文件调用点直接用 window.api.friendlyFsError(...)。
import { showToast } from './feedback.js';
import { showPromptDialog } from './prompt-dialog.js';
import { showConfirmDialog } from './confirm-dialog.js';
import { isMarkdownFile } from './utils.js';
// 审计修复 (Round 11 deep-fix P1-1)create / delete / rename 三个回调的入口
// 在读 state.isDirty 之前必须先 syncDirtyFromEditor —— 否则 CJK 用户在 IME
// 合成期调「新建 / 删除 / 重命名」时state.isDirty 是 stale false跳过
// dirty 确认 → 直接销毁用户当前编辑的内容。app.js save() / openFile() /
// 关窗已经走这个路径(详见 src/dirty-sync.js 注释file-ops 这一层之前漏了。
import { syncDirtyFromEditor } from './dirty-sync.js';
// 审计修复 (Round 11 vol.2):客户端文件名预校验。
//
// 镜像 main/file-ops.js resolveRenameName / resolveFileName 的「输入合法性」检查
// (路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符 / 非空)。
// 不镜像 main 的「存在性 + 自动避让」逻辑 —— 那是 IPC 端的事rename/create 都
// 走 main 协商,不在 renderer 重复实现。
//
// 为什么不共享同一份代码main 是 CJSrequire('fs') + path.join 路径协商),
// renderer 是 ESM 且不能 require Node 模块。共享会带来上下文桥 / bundle 复杂度。
// 退而求其次:把这条规则文档化在 main + 本注释,变更时手工同步 —— 改动频次极低
// Windows 保留设备名清单基本不变),双拷贝维护成本可控。
const WIN_RESERVED_DEVICE_NAMES = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]);
/**
* 校验输入是否可作为文件名(含扩展名或无)。返回 null = 通过;返回字符串 = 错误消息。
* 用于 prompt-dialog 的 validate 选项,在用户点确认时立即拦截,不让非法值走到 IPC。
*
* @param {string} raw
* @returns {string|null}
*/
function validateFileName(raw) {
if (typeof raw !== 'string' || !raw.trim()) return '文件名不能为空';
const name = raw.trim();
if (/[/\\]/.test(name) || name.includes('..')) return '文件名不能包含路径分隔符或 ..';
if (/[<>:"|?*]/.test(name)) return '文件名包含 Windows 保留字符(< > : " | ? *';
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(name)) return '文件名包含非法字符';
// Windows 保留设备名(取最后一个 . 之前的部分判断;与 main resolveRenameName 保持一致)
const lastDot = name.lastIndexOf('.');
const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) {
return `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名`;
}
return null;
}
/**
* @typedef {object} FileOpsCtx
* @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs
* @property {object} controls - { fileList, viewer, editor, contextMenu }
* @property {object} api - preload contextBridge 暴露的 window.api
* @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? }
*/
/**
* @param {FileOpsCtx} ctx
*/
export function createFileOps(ctx) {
const { state, controls, api, callbacks } = ctx;
if (!state || !controls || !api || !callbacks) {
throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks');
}
const { contextMenu, viewer, fileList, editor } = controls;
const { openFile, updateWindowTitle, clearCurrentFile } = callbacks;
/**
* 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名
*/
function formatStampForName(d) {
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
}
/**
* 在系统文件管理器中显示条目(文件 / 文件夹 分支)。
* - 文件editable / binary走 `shell:show-item-in-folder`,通过
* shell.showItemInFolder 实现「打开父目录并选中文件」语义
* macOS = RevealWin/Linux = 资源管理器选中)。
* - 文件夹:走 `shell:open-dir`,通过 shell.openPath 直接打开目录本身
* —— showItemInFolder 在主进程会 stat.isFile() 校验,目录会被拒
* NOT_A_FILE用户点「在文件夹中显示」反而弹错误 toast。
*
* Stage 8folder 条目仍保留该入口,期望是「用系统文件管理器打开这个目录」
* (参见 showFileListContextMenu 注释)。
*/
async function revealFile(file) {
if (!file || !file.path) return;
// 文件夹条目不能用 showItemInFolder主进程会 stat.isFile() 校验,
// 目录被拒 → 弹「不是一个文件」)。改走 openDir让 shell.openPath
// 打开目录本身Win/linux 进 ExplorermacOS 进 Finder
const isFolder = file.entryType === 'folder';
const result = isFolder
? await api.openDir(file.path)
: await api.showItemInFolder(file.path);
if (!result || !result.ok) {
showToast(`显示失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
}
}
/**
* 删除文件:弹 confirm → 走 shell.trashItem → 如果是当前打开的文件则清空视图。
* 即便 IPC 返回 FILE_NOT_FOUND也照样清空视图说明文件已不在数据目录里
*
* Phase 2 audit 修复 (P1 BUG #4):删除飞行期 fs-watcher 可能先推 files:changed
* A 不在 entriesonFilesChanged 的 rescue 分支会把当前 dirty 内容救到
* `<A>-rescued-<stamp>.md`(与被删的 A 内容相同),然后 clearCurrentFile —
* 这是「删除」操作的孤儿副作用。修复:进 deleteFile 前设 state.deletingPath =
* file.pathonFilesChanged rescue 分支遇此标记跳过 rescue。finally 清标记
* —— 不论 IPC 成功/失败/discarded。
*/
async function deleteFile(file) {
if (!file || !file.path) return;
// 审计修复 (Round 11 deep-fix P1-1)syncDirtyFromEditor 把 editor.isDirty()
// 真相同步到 state.isDirty避免 IME 合成期删除当前文件时跳过 dirty 确认
// 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。
syncDirtyFromEditor(state, editor);
const isCurrent = state.currentFile && state.currentFile.path === file.path;
// 删除当前文件 + 有未保存改动 → 二次确认
if (isCurrent && state.isDirty) {
const choice = await showConfirmDialog({
title: '删除未保存的文件',
message: `${file.name}」有未保存改动,确定要删除(移至回收站)吗?`,
icon: 'danger',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '删除', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
}
// Phase 2 audit (P1 BUG #4):设删除守卫,挡住 fs-watcher rescue 误触发
state.deletingPath = file.path;
try {
const result = await api.deleteFile(file.path);
if (!result || !result.ok) {
if (result && result.error === 'FILE_NOT_FOUND') {
// 文件已不在了(用户手动删过、外部工具删过)—— 仿照 renameCurrentOrFile
// 的 FILE_NOT_FOUND 路径dirty 状态下不能直接 clearCurrentFile 丢内容。
// 先取 editor.getContent() 救到 <base>-rescued-<stamp>-<suffix>.md再清视图。
// audit fix (Round 4 F1):之前 deleteFile 的 FILE_NOT_FOUND 直接 clearCurrentFile
// → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged
// rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
const originalName = state.currentFile.name || 'untitled.md';
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : '';
// 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。
api.createFile(rescueName, rescueContent).then((r) => {
if (r && r.ok) {
showToast(`文件已不在;未保存内容已救回到「${rescueName}`, 'warning', 4500);
} else {
showToast(`文件已不在;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)}`, 'error', 4500);
}
}).catch((e) => {
console.error('[file-ops] delete FILE_NOT_FOUND rescue IPC 异常:', e);
showToast('文件已不在;未保存内容丢失', 'error', 4500);
});
} else {
showToast('文件已不存在', 'warning');
}
} else {
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
}
showToast(`已移至回收站:${file.name}`, 'success', 1500);
if (isCurrent) {
// 走集中清理:状态归零 + 清空 viewer/editor + 解除文件列表高亮。
// 注意不再单独写 syncSaveButton / updateWindowTitle / updateStatusStats
// clearCurrentFile 内部已统一处理(并把 lastSavedMtimeMs 重置为 null
// 而不是这里的 0 —— 与文件被删、目录切换等其它路径行为一致)。
clearCurrentFile();
}
} finally {
// 不论成败都清守卫 —— 否则下次 fs-watcher 事件还会被误判为「正在删除」
state.deletingPath = null;
}
}
/**
* 重命名文件:弹 prompt 让用户输入新文件名。
* 若是当前打开的文件,同步 state.currentFile 的 path/name 以让 UI 反映。
*
* 用户反馈:「重命名不要自动补后缀」—— 这里显示完整文件名(含 .md让用户
* 自由编辑:可以改成 bar.md / bar.txt / 干脆去掉扩展名。新建文件场景也
* 走同一思路createNewFile 注释):默认值直接带 .md主进程
* resolveFileName / resolveRenameName 都「用户输入什么就用什么」。
*/
async function renameCurrentOrFile(file) {
if (!file || !file.path) return;
// 审计修复 (Round 11 deep-fix P1-1)sync editor.isDirty() → state.isDirty。
// rename 的 dirty 检查在 FILE_NOT_FOUND 路径下出现IME 期间跳过会丢 IME buffer。
syncDirtyFromEditor(state, editor);
const raw = await showPromptDialog({
title: '重命名',
message: '重命名为',
defaultValue: file.name,
confirmLabel: '重命名',
validate: validateFileName,
});
if (raw == null) return;
const newName = raw.trim();
if (!newName) {
showToast('文件名不能为空', 'warning');
return;
}
if (newName === file.name) return;
// Phase 2 audit 修复 (P1 BUG #3):设重命名守卫挡住 fs-watcher race 误触发 rescue。
// 主进程 renameWithRetry 累计 50+100+200=350ms 可能超过 fs-watcher 300ms 防抖
// 窗口rename IPC 还没 resolve 时 fs-watcher 先把「A 已不在 + B 已出现」推到
// rendereronFilesChanged 的 rescue 分支会把当前 dirty 内容救到
// `<A>-rescued-<stamp>.md`(内容 = editor 内容 = 与重命名后的 B 完全相同)→
// 孤儿文件。守卫:进 renameFile 前设 state.renamingPath = file.pathonFilesChanged
// rescue 分支遇此标记 + state.currentFile.path 一致 → 跳过 rescue。
// finally 中:先更新 state.currentFile.path再清守卫顺序很重要 —— 否则守卫清
// 在 path 更新之前fs-watcher 又来一次看到旧 path 仍会被误判为删除)。
state.renamingPath = file.path;
try {
const result = await api.renameFile(file.path, newName);
if (!result || !result.ok) {
// 与 deleteFile 对齐:原文件已不在(被外部删过 / 同步盘撤回)→ 静默清视图,
// 否则编辑器里仍显示"刚被改名的旧路径文件",但磁盘上已无对应文件。
// 这里不抛"重命名失败"的红色 toast —— 因为失败的根本原因是文件没了,不是用户
// 输入的 newName 不合法(那种情况主进程会回 EEXIST / PATH_NOT_ALLOWED
if (result && result.error === 'FILE_NOT_FOUND') {
// Phase 2 audit 修复 (P1 BUG #5)rename 飞行期原文件被外部删除
// → dirty 内容不能直接 clearCurrentFile 丢。先取 editor.getContent()
// 救到 rescue 文件,再清。复用 onFilesChanged rescue 的 createFile 模式。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
const originalName = state.currentFile.name || 'untitled.md';
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : '';
// audit fix (Round 4 F6):与文件其它 IPC 调用对齐用 ctx.apicreateFile /
// renameFile / deleteFile 都从 ctx 解构),不再直连 window.api。若未来
// IPC 桥被换 mock / 拦截器rescue 路径与主路径走同一来源,行为一致。
api.createFile(rescueName, rescueContent).then((r) => {
if (r && r.ok) {
showToast(`原文件已被删除,未保存内容已救回到「${rescueName}`, 'warning', 4500);
} else {
showToast(`原文件已被删除;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)}`, 'error', 4500);
}
}).catch((e) => {
console.error('[file-ops] rename FILE_NOT_FOUND rescue IPC 异常:', e);
showToast('原文件已被删除;未保存内容丢失', 'error', 4500);
});
} else {
showToast('文件已不存在', 'warning');
}
if (state.currentFile && state.currentFile.path === file.path) {
clearCurrentFile();
}
return;
}
showToast(`重命名失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
if (state.currentFile && state.currentFile.path === file.path) {
const newMeta = {
path: result.path,
name: result.name,
mtimeMs: result.mtimeMs,
size: result.size, // P2-6状态栏 size 也需要更新
};
// Phase N 修复扩展名变化foo.md → foo.txt必须重算 currentFileIsMarkdown。
// 旧实现只更新 path/namestate.currentFileIsMarkdown 保留旧值 → viewer 仍
// 走 loadContent (markdown 渲染)、applyEditorMode 不重跑 → 工具栏 preview/split
// 按钮仍 enabled、用户可能在 .txt 文件里看到假 markdown 渲染。
const wasMd = state.currentFileIsMarkdown;
const isMd = isMarkdownFile(result.path);
state.currentFile = { ...state.currentFile, ...newMeta };
state.currentFileIsMarkdown = isMd;
// P2 fix同步刷新 lastSavedMtimeMs —— fs-watcher 会因 rename 触发
// files-changedsetFiles 之后 onFilesChanged 会比对 state.files 的
// mtimeMs 与 lastSavedMtimeMs如果不写后者会被误判为「文件被外部修改」弹 rescue 提示。
state.lastSavedMtimeMs = result.mtimeMs;
// P2 fixfs-watcher 触发 setFiles 时,新文件 path 还没出现在列表里
// (旧 path 已不在file-list 会把 activePath 清空(见 setFiles 内 onFileRemoved 分支)。
// 主动 setActive 把高亮迁到新 path避免「编辑器里有内容但侧栏看不到高亮」。
if (fileList && typeof fileList.setActive === 'function') {
try { fileList.setActive(result.path); } catch { /* 不让 setActive 异常阻断 rename 流程 */ }
}
// P1-5viewer 也要同步 —— 笔记里的 <img src="./foo.png"> 是相对路径,
// viewer 用 viewer.currentFile.path 当 baseDir路径不变就 404。
// setFileMeta 在 path 变化时自动重跑 resolveRelativeImages
// path 没变则只刷状态栏。
viewer.setFileMeta(newMeta);
// 扩展名在 md ↔ 非 md 之间切换viewer 内部 DOM 仍渲染旧内容。
// setFileMeta 只更新 metadata + 重跑相对图解析,**不**重渲染主体。
// 需要主动调 loadContent / showUnsupportedFile 切换 viewer 主区域;
// 再调 applyEditorMode 让工具栏按钮可用性 跟着对。
if (wasMd !== isMd) {
try {
const currentContent = editor && typeof editor.getContent === 'function'
? editor.getContent()
: '';
if (isMd) {
viewer.loadContent(state.currentFile, currentContent);
} else {
viewer.showUnsupportedFile(state.currentFile);
}
} catch (e) {
console.warn('[file-ops] rename viewer 切换失败:', e);
}
// applyEditorMode 会按 state.currentFileIsMarkdown 重置预览按钮 disabled、
// setReadOnly、updateStatusMode/Stats/Scroll。
if (typeof callbacks.applyEditorMode === 'function') {
callbacks.applyEditorMode(state.editorMode);
}
}
updateWindowTitle();
// audit fix (Round 9)rename 后通知 AI controller。理由同 discard_local
// path 变了content 也可能因扩展名切换而被 viewer 重渲染)—— AI 的
// pendingDiff 持有的 baseContent / forFilePath 与磁盘现状已经脱节,
// 不通知就会触发「AI 算 diff → 用户点应用 → setExternalContent 覆盖
// 刚保存的 rename 后内容」这条 stale apply 路径。AI controller 内部
// 自己负责 cancel + clearPendingDiff + requestGeneration++。
if (typeof callbacks.onFileChanged === 'function') {
try { callbacks.onFileChanged(state.currentFile); } catch (e) {
console.warn('[file-ops] rename 通知 AI 失败:', e);
}
}
}
showToast(`已重命名为:${result.name}`, 'success', 1500);
} finally {
// 顺序:先在上面把 state.currentFile.path 更新到 result.path再清守卫。
// 如果在这里调 state.renamingPath = null 而 state.currentFile.path 还是旧值,
// 此时 fs-watcher 再来一次事件(很常见的二次触发)会看到 state.currentFile.path
// (旧) !== state.renamingPath (旧) → 不命中守卫 → 走 rescue 路径。
// 注意:本 finally 跑到这里state.currentFile.path 已经被赋值成 result.path
// (上面 line 197 的 state.currentFile = { ...state.currentFile, ...newMeta }
// 所以现在清守卫是安全的。
state.renamingPath = null;
}
}
/**
* 顶部「+」新建按钮:弹 prompt 取名 → 走 createFile → 自动打开新文件。
*
* Stage 8把 state.currentDir 透传给 api.createFile让新文件落在当前浏览的
* 子目录里(而不是永远落到数据根)。
*
* 用户反馈2026-08-28「新建笔记时直接把后缀放在文件名后面而不是自动
* 加上」—— 默认值已带 .md用户可见 / 可改(直接改 .txt 等)。主进程
* resolveFileName 也对齐 resolveRenameName用户输入什么就用什么
* 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json
* 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。
*/
async function createNewFile() {
// 审计修复 (Round 11 deep-fix P1-1)entry sync 把 editor.isDirty() 真相同步给
// state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认,
// 用户的 CJK buffer 静默丢失。
syncDirtyFromEditor(state, editor);
if (state.isDirty) {
// 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动
const choice = await showConfirmDialog({
title: '新建文件',
message: '当前文件有未保存改动,新建文件将丢弃这些改动。继续?',
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '继续新建', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
// 用户已经确认丢弃当前改动。openFile 内部还会按 isDirty 再问一次「放弃改动 / 保存并打开」,
// 但用户在第一步已经表态「继续新建(丢弃)」,不应该再问一遍。
// audit fix把 clearCurrentFile() 推迟到名字 prompt 成功 + 创建成功之后。
// 旧顺序:先 clearCurrentFile → 弹 prompt → 用户取消 → 当前文件已经清空了,
// 即便用户只是「改个名字」,也已经把刚才确认要丢弃的改动实际丢了,但还没真新建。
// 新顺序:保持 isDirty / currentFile 不变直到名字 OK + createFile 成功,
// 然后再清。如果用户在 prompt 阶段取消,编辑器内容原样保留。
}
const defaultName = `未命名-${formatStampForName(new Date())}.md`;
// Stage 8在子目录里新建时prompt 文案补一段「当前文件夹xxx」让用户清楚新文件落哪。
// 根目录时维持原样。
// 用户反馈2026-08-28默认名直接带 .md 后缀(与重命名一致),用户想换成
// .txt / .json / 其他扩展名可直接编辑;不再「自动补」。
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
? state.breadcrumb.map((s) => s.name).join('/')
: '';
const message = breadcrumbTail
? `新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)\n当前文件夹:${breadcrumbTail}`
: '新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)';
const raw = await showPromptDialog({
title: '新建笔记',
message,
defaultValue: defaultName,
confirmLabel: '创建',
validate: validateFileName,
});
if (raw == null) return;
const name = raw.trim();
if (!name) {
showToast('文件名不能为空', 'warning');
return;
}
// Stage 8把当前目录绝对路径传给主进程主进程负责 auto-mkdir用户主动在该目录下新建
const result = await api.createFile(name, { dir: state.currentDir || '' });
if (!result || !result.ok) {
showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
// 名字 prompt + IPC 都成功了才清空当前文件(覆盖上面推迟的 clearCurrentFile
if (state.isDirty) clearCurrentFile();
showToast(`已创建:${result.name}`, 'success', 1500);
// 主动打开新文件(不等 files:changed
await openFile({
path: result.path,
name: result.name,
size: result.size,
mtimeMs: result.mtimeMs,
});
}
/**
* 文件列表右键菜单:重命名 / 在文件夹中显示 / 删除
*
* Stage 8folder 条目上隐藏「重命名」—— file:rename 主进程逻辑强制 .md 后缀,
* 对目录并不合适;文件夹的批量整理通过 shell 文件管理器(系统级)操作更直接,
* 这里至少保留删除 + 在文件夹中显示。
*/
function showFileListContextMenu(entry, x, y) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) {
items.push({ label: '重命名', value: 'rename' });
}
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
contextMenu.show({
x, y, items,
onSelect: (value) => {
if (value === 'rename') renameCurrentOrFile(entry);
else if (value === 'reveal') revealFile(entry);
else if (value === 'delete') deleteFile(entry);
},
});
}
return {
showFileListContextMenu,
createNewFile,
// 暴露给测试 / 调试用
renameCurrentOrFile,
deleteFile,
revealFile,
formatStampForName,
};
}

173
src/focus-trap.js Normal file
View File

@@ -0,0 +1,173 @@
// 焦点陷阱 —— 通用最小可工作版
//
// 解决两件事:
// 1. Tab / Shift+Tab 在容器内循环,不外溢到背景 DOM
// 2. 关闭时把焦点还给打开前的元素
//
// 不做(避免过度工程):
// - 自动找"第一个 focusable"作为初始焦点 —— 由调用方传入 initialFocus 更明确
// - 屏幕阅读器特殊处理 —— aria-modal + aria-hidden 已交给调用方
// - 焦点恢复时滚屏校正 —— focus({ preventScroll: true }) 即可
//
// 调用约定:
// const trap = createFocusTrap(overlay, {
// initialFocus: overlay.querySelector('input, button, [tabindex]:not([tabindex="-1"])'),
// signal: abortController.signal, // 可选signal abort 时自动 dispose
// });
// ...
// trap.dispose(); // 把焦点还给 prevFocus
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function createFocusTrap(container, { initialFocus = null, signal = null, fallback = null } = {}) {
if (!container) throw new Error('createFocusTrap: container 必填');
// audit fix原条件只排除 body但 <html>documentElement也是
// document.activeElement 的常见值(焦点漂移到根节点 —— 例如布局 reflow
// 期间、点击非可聚焦区域后)。<html>.focus() 是合法但无视觉焦点的
// no-op导致 dispose 后屏幕阅读器 / 键盘用户的虚拟焦点被困在已关闭
// 的模态内。这里把 documentElement / body 都视为「无效 prevFocus」
// 返回 nulldispose 会直接跳过 focus 调用,焦点自然落到 body 上。
const ae = document.activeElement;
const prevFocus = (ae && ae !== document.body && ae !== document.documentElement
&& typeof ae.focus === 'function') ? ae : null;
// 容器自身需要能接收焦点,否则 Tab 从末尾跳到第一个时容器不会被激活
const hadTabindex = container.hasAttribute('tabindex');
if (!hadTabindex) {
container.setAttribute('tabindex', '-1');
}
/** @returns {HTMLElement[]} */
function getFocusable() {
// H1 fix (audit):原 filter 用 `el.offsetParent !== null` 判断可见性,
// 但 `position: fixed` 元素的 offsetParent === null会被误判成「不可见」
// 从 Tab 序列里剔除。模态里有 sticky 按钮 / 浮动操作按钮(如未来加的
// 「在此打开文件夹」Tab 会跳过它们到末尾后跳回首项,无法到达。
// 用 `getClientRects().length > 0` 兜住 fixed / sticky且对 display:none
// 仍然返回空hidden 元素没有 layout box。已聚焦的 fixed 元素保留。
//
// 审计修复 (Round 11):还要排除以下三类元素:
// - visibility: hidden —— 仍有 client rectsfilter 里已放过),但 .focus()
// 是 silent no-opTab 序列走到这里 → first.focus() 落在 no-op 元素上 →
// 「Tab 死掉」的假象
// - inert 子树 —— W3C 新标准把子树标记为不可交互closest('[inert]')
// 命中即剔除
// - aria-hidden=true 子树 —— 屏幕阅读器跳过;但键盘 Tab 仍可能命中,
// 排除防止 Tab 看着「消失」
// contenteditable 显式加入选择器CM 编辑器嵌入 modal 时能正常 Tab 进。
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR + ',[contenteditable]:not([contenteditable="false"])'))
.filter((el) => {
if (el.getClientRects().length === 0 && el !== document.activeElement) return false;
if (el.closest && el.closest('[inert],[aria-hidden="true"]')) return false;
// visibility:hidden 元素的 clientWidth/Height 仍可能 > 0但 getComputedStyle
// 拿到 visibility === 'hidden'。已经聚焦的元素activeElement保留——可能
// 是「用户主动聚焦后外部样式改了 visibility」的边角dispose 时回退路径不应
// 把已聚焦元素从序列里踢出。
if (el !== document.activeElement) {
const cs = (typeof window !== 'undefined' && window.getComputedStyle) ? window.getComputedStyle(el) : null;
if (cs && cs.visibility === 'hidden') return false;
}
return true;
});
}
function onKeydown(e) {
// audit fix (Phase O-L14)Tab 也守 IME 合成。
// 与 shortcuts.js:84 / markdown-editor.js 风格一致CJK 拼音输入中途按 Tab
// 选候选词Windows IME 习惯focus-trap 不能把焦点甩到下一个按钮。
// 当前 modal 没有 IME 重输入控件settings / confirm / prompt 都是普通
// input潜在风险。但保持一致性所有 document-level keydown listener
// 都守 isComposing / keyCode=229。
if (e.isComposing || e.keyCode === 229) return;
if (e.key !== 'Tab') return;
const items = getFocusable();
if (items.length === 0) {
// 没有可聚焦元素:把焦点留在容器上,避免跳出去
e.preventDefault();
container.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || !container.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last || !container.contains(active)) {
e.preventDefault();
first.focus();
}
}
}
container.addEventListener('keydown', onKeydown);
let disposed = false;
function dispose() {
if (disposed) return;
disposed = true;
container.removeEventListener('keydown', onKeydown);
// audit fix (renderer-M10):只在是我们自己注入的 tabindex 时移除,
// 宿主原有值(如 HMR 复用场景下节点本身带 tabindex原样保留。
if (!hadTabindex) {
container.removeAttribute('tabindex');
}
if (prevFocus && typeof prevFocus.focus === 'function') {
// 审计修复 (Round 11)dispose 时重验 isConnected。
// prevFocus 在 capture 阶段(第 38-40 行)只检查 !== body/rootElement
// 但 modal 期间 prevFocus 可能被外部重渲染 / 撕下 DOM —— 例如:
// - file-list 右键菜单 → 「重命名」 → 弹 prompt-dialog
// 菜单 close 时整块被 unmountprevFocus 已 detached
// .focus() 是 silent no-op焦点落到 <body>,键盘用户失位。
// - 文件列表 items 在 files:changed 事件里重建prevFocus 同样 detached。
// 重新检查 isConnected连接不上时回退到稳定锚点caller 提供
// 的 fallback—— 而不是让焦点散落到 <body>。
//
// audit fix (Settings P3 / focus-trap fallback lift):原来这里
// 硬编码 `document.getElementById('file-list')` 作为兜底 —— 把
// 「renderer 侧具体 DOM 节点」写进通用 focus-trap跨上下文错位
// - settings-dialog 关掉 → 焦点跳到 #file-list语义错位
// (用户在设置里改东西,期望焦点回设置前的位置)
// - confirm / prompt / 未来 AI 弹窗同样问题
// - 通用包里写死具体 id一旦 #file-list 改名/删名静默退化到 <body>
// 现在让 caller 通过 fallback 选项传一个语义匹配的节点;未传则按
// fallback 节点 → document.body 的顺序找第一个仍 connected 的。
// 之前 modal.js 调用点没传 fallback下面 mountModal 那侧会传入
// 一个 caller 级别的稳定锚点modal.options.fallbackFocus
let restoreTarget = prevFocus;
if (!prevFocus.isConnected) {
const candidates = [fallback, document.body].filter(Boolean);
restoreTarget = candidates.find((el) => {
try { return el && typeof el.isConnected === 'boolean' && el.isConnected; }
catch { return false; }
}) || document.body;
}
try { restoreTarget.focus({ preventScroll: true }); } catch { /* ignore */ }
}
}
if (signal) {
if (signal.aborted) dispose();
else signal.addEventListener('abort', dispose, { once: true });
}
// 初始焦点:异步让出渲染时间,避免被外层 setTimeout(0) 抢走
queueMicrotask(() => {
if (disposed) return;
const target = initialFocus || getFocusable()[0] || container;
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
});
return { dispose };
}

445
src/markdown-editor.js Normal file
View File

@@ -0,0 +1,445 @@
// Markdown 编辑器CodeMirror 6 包装)
// 职责:装载文件、跟踪脏状态、暴露 save 回调。
//
// 不负责:文件 IO走 app.js、确认对话框走 confirm-dialog.js
//
// 与 markdown-viewer.js 的关系viewer 只关心「渲染这一段字符串」,
// editor 只关心「让用户编辑这一段字符串」;二者由 app.js 协调。
import { EditorState, Prec, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { getTextStats } from './stats.js';
import { pickEditorTheme } from './editor-theme.js';
import { createDisposable } from './component.js';
/**
* Markdown 编辑器CodeMirror 6 包装)
* 职责:装载文件、跟踪脏状态、暴露 save 回调。
*
* 不负责:文件 IO走 app.js、确认对话框走 confirm-dialog.js
* 主题定义(走 editor-theme.js
*
* 与 markdown-viewer.js 的关系viewer 只关心「渲染这一段字符串」,
* editor 只关心「让用户编辑这一段字符串」;二者由 app.js 协调。
*/
export class MarkdownEditor {
/**
* @param {object} options
* @param {HTMLElement} options.element - 容器 div需有 height: 100%
* @param {() => void} options.onChange - 内容变化回调(用于脏状态 UI
* @param {() => void} options.onSave - Ctrl/Cmd+S 触发(也供 app.js 直接调用)
* @param {(progress:number) => void} [options.onScroll] - 滚动/光标变化回调 (0..1),用于双栏同步滚动
*/
constructor({ element, onChange, onSave, onScroll }) {
this.element = element;
this.onChangeCallback = onChange;
this.onSaveCallback = onSave;
this.onScrollCallback = onScroll || null;
this.view = null;
this.lastSavedDoc = '';
this.editableCompartment = new Compartment();
this.themeCompartment = new Compartment();
// audit fix (renderer-M4):滚动/光标 geometryChanged 在 split 模式下
// 会被 app.js 同步调到 viewer.setScrollProgressgetScrollProgress 读
// scrollHeight/clientHeight 强制 layout flushsetScrollProgress 又写
// scrollTop 触发再次 layout —— 每个 keystroke + 移动光标都走一遍 N 次
// layout 计算,长文档能感知到 jank。用 rAF 把同帧内的多次触发合并到
// 一次rAF 回调里只做一次 getScrollProgressreader 与 writer 之间也
// 是同一帧,避免交错布局)。
this._scrollFrame = null;
// H1 fix (audit P0):统一 dispose 入口。MarkdownEditor 不直接挂 DOM listener
// CodeMirror 自己处理),但需要暴露 dispose() 让 app.js 在切数据目录 / 重启
// 窗口 / HMR 时显式释放。createDisposable 也提供 addAbort 注册 _destroyView
// 之外的清理(如 rAF 取消、Compartment 引用置空。dispose() 幂等,可重复调用。
createDisposable(this);
// 兜底清理:即便外部忘记调 disposerAF 取消 + view.destroy 仍会执行。
// 注意createDisposable 会在实例上挂 this.dispose —— 不能在 class 原型上
// 另写同名的 dispose() 覆盖它。所有清理逻辑都通过 addAbort 注册dispose()
// 触发 abort 时统一执行。
this.addAbort(() => {
this._destroyView();
this.lastSavedDoc = '';
// 解除 callback 闭包对 viewer / 业务对象的引用,便于 GC。
this.onChangeCallback = null;
this.onSaveCallback = null;
this.onScrollCallback = null;
// audit fix (Round 7 LOW):补齐 dispose 引用清理,与 onChangeCallback 等
// 对称。_composing 标志若残留 true下次 new MarkdownEditor 拿旧实例时会
// 误判 IME虽然 module-scope 的 editor 实例永不释放,但显式置 false
// 让 _destroyView 路径上的状态机更易预测。compartment 显式置 null 释放
// 引用。_onComposition* 函数引用故意保留(顶部 view null 守卫兜底)——
// dispose 后极端时序再 fire 这些 handler 仍然 noop不会抛 TypeError。
this._composing = false;
this.editableCompartment = null;
this.themeCompartment = null;
});
}
/**
* 当前主题dark / light—— 决定是否套用 oneDark
*/
get currentTheme() {
return document.body.dataset.theme === 'light' ? 'light' : 'dark';
}
/**
* 是否处于 IME 合成状态。CM6 不暴露 composing API通过 contentDOM
* compositionstart/end listener 跟踪。app.js 在 IME 期间要跳过:
* - auto-savesave() 写入 IME-buffer 中的未确认字符)
* - AI 应用applyUserChange 在合成期会破坏 IME buffer
* 暴露 getter 而不是让外部读 _composing 下划线字段。
* @returns {boolean}
*/
isComposing() {
return this._composing === true;
}
/**
* 加载文件内容到编辑器。多次调用会销毁旧 view。
* @param {{path:string,name:string}} file
* @param {string} content
*/
loadFile(file, content) {
// audit fix (Round 7 MED)element 早失败。CodeMirror 内部 parent.appendChild
// 会因 null/undefined 抛 TypeError把整个 bootstrap 拖到 unhandled rejection。
// 显式校验调用方若传错loadFile 直接抛可读错误app.js 顶层 catch 能定位。
if (!this.element || typeof this.element.appendChild !== 'function') {
throw new Error('MarkdownEditor: element required (must be a DOM node)');
}
this._destroyView();
this.lastSavedDoc = content || '';
const state = EditorState.create({
doc: this.lastSavedDoc,
extensions: [
EditorView.lineWrapping,
basicSetup,
markdown(),
this.themeCompartment.of(pickEditorTheme(this.currentTheme)),
this.editableCompartment.of(EditorView.editable.of(true)),
Prec.highest(keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => {
if (this.onSaveCallback) this.onSaveCallback();
return true;
},
},
])),
EditorView.updateListener.of((update) => {
// audit fix (K2-H2)CJK IME 合成期间CM6 把每个临时字符 commit 成
// 独立 transactionuserEvent: 'input.type.compose')—— 一个 20 字的
// 中文段落会触发 20 次 updateListener每次 docChanged=true 都跑
// onChange 全链路state.isDirty 立即翻 true用户还没打完就显示
// 脏态、save 按钮进 is-dirty 提示态、scheduleAutoSave 反复 clearTimeout、
// scheduleLivePreview 200ms 后 viewer.loadContentLive 整页 innerHTML
// 重建。合成中的 IME 字符不会真的写盘,对外只 commit 一次compositionend
// —— 跳过中间过程,让脏态 / live preview / auto-save 在 compositionend
// 时一次性 flush。
// scrollgeometryChanged不依赖 docChangedIME 期间用户也可能滚动
// 列表,跟之前一样保留。
const isComposing = update.transactions.some((t) => t.isUserEvent('input.type.compose'));
if (update.docChanged && !isComposing && this.onChangeCallback) {
this.onChangeCallback();
}
// P1-10只把「真实滚动」转发给 onScroll。
// selectionSet光标移动也走到了这里 —— 但 app.js 的 onScroll 只是
// viewer.setScrollProgress(...) 同步右侧预览位置,光标移动跟预览无关,
// 不应该触发。getScrollProgress 会读 scrollHeight/clientHeight强制
// layout flush10K 行文档下每个 keystroke + 移动光标都做几次 N 次
// layout 计算,体感明显。
if (update.geometryChanged && this.onScrollCallback) {
// audit fix (renderer-M4)geometryChanged 在 split 模式下会被高
// 频触发rAF 合并到一帧一次。rAF 在浏览器不可见标签页里会自然暂停,
// 也避免背景 tab 里持续 layout flush。
if (this._scrollFrame !== null) {
cancelAnimationFrame(this._scrollFrame);
}
this._scrollFrame = requestAnimationFrame(() => {
this._scrollFrame = null;
// view 已经在 _destroyView 走完时被置 nullrAF 回调里再读会抛
// getScrollProgress 已经做 null 检查,但显式守一遍更稳。
if (!this.view || !this.onScrollCallback) return;
this.onScrollCallback(this.getScrollProgress());
});
}
}),
],
});
this.view = new EditorView({
state,
parent: this.element,
});
// audit fix (Phase O-M1):跟踪 IME 合成状态。
// CM6 没有公开 composing API这是 contenteditable 的概念),最准确的方式
// 是直接在 contentDOM 上挂 compositionstart/compositionend listener。
// applyUserChangeAI 应用 diff合成期间调用会摧毁 IME buffer让 AI 控
// 制器在 catch 里给用户「请稍后再试」提示。
// listener 用 addAbort 注册dispose 时统一解绑。
//
// audit fix (IME-dirty-flush)compositionend 后补发 onChange。
// updateListener 里 `!isComposing` 守卫在 IME 合成期间屏蔽 onChange
// (避免中文每字触发一次 scheduleLivePreview / scheduleAutoSave
// 但 compositionend 后 CM6 不一定再产生新 transaction —— 此时 onChange
// 永远不触发state.isDirty 保持 falsesave() 走 !isDirty 早退,
// 用户看到的中文已上屏但磁盘没写入。修复compositionend 时主动比较
// doc 与 lastSavedDoc不同则补发 onChange 让 app 层正确翻脏态。
this._composing = false;
// audit fix (Round 7):三个 IME handler 都先守 view 存在dispose 后
// _destroyView 把 view 置 null再 fire 这些 handler极端时序不会抛
// TypeError 而是 noop。原 onChange 路径里也有 `this.view &&` 守卫,这次
// 提到函数顶部,让整个 dispose-after-fire 路径完全幂等。
this._onCompositionStart = () => {
if (!this.view) return;
this._composing = true;
};
this._onCompositionEnd = () => {
if (!this.view) return;
this._composing = false;
// IME 提交后补发 onChangedoc 已变但 updateListener 在合成期没调过
// onChange → state.isDirty 还是 false。这里主动调一次让 app 翻脏。
// 防御性校验view 可能已被 dispose、callback 可能已解绑。
if (this.onChangeCallback) {
const currentDoc = this.view.state.doc.toString();
if (currentDoc !== this.lastSavedDoc) {
this.onChangeCallback();
}
}
};
// audit fix (Round 7 HIGH)compositioncancel listener。
// 部分 OS / IME 实现(如 macOS 日语 IME 切换、Windows 部分第三方输入法)
// 在用户按 Esc 取消 / 外部抢焦点 / IME 引擎切换时只发 compositioncancel
// 不发 compositionend —— _composing 会永远停在 true所有 dispatch
// applyUserChange / setExternalContent抛 EDITOR_COMPOSING编辑器
// 永久锁死直到用户 reload view。处理同 compositionend清标记 + 补 onChange。
this._onCompositionCancel = () => {
if (!this.view) return;
this._composing = false;
if (this.onChangeCallback) {
const currentDoc = this.view.state.doc.toString();
if (currentDoc !== this.lastSavedDoc) {
this.onChangeCallback();
}
}
};
this.view.contentDOM.addEventListener('compositionstart', this._onCompositionStart);
this.view.contentDOM.addEventListener('compositionend', this._onCompositionEnd);
this.view.contentDOM.addEventListener('compositioncancel', this._onCompositionCancel);
this.addAbort(() => {
if (!this.view || !this.view.contentDOM) return;
this.view.contentDOM.removeEventListener('compositionstart', this._onCompositionStart);
this.view.contentDOM.removeEventListener('compositionend', this._onCompositionEnd);
this.view.contentDOM.removeEventListener('compositioncancel', this._onCompositionCancel);
});
}
/**
* 切换编辑器主题(不重建 view
* @param {'dark'|'light'} theme
*/
setTheme(theme) {
if (!this.view) return;
const ext = pickEditorTheme(theme);
this.view.dispatch({
effects: this.themeCompartment.reconfigure(ext),
});
}
/**
* 卸载当前编辑器(不写盘)
*/
unload() {
this._destroyView();
this.lastSavedDoc = '';
// Phase N-2 修复(关键 bug · 保存按钮失灵):
// K2-L10 的旧实现会在这里清掉 onChange/onSave/onScroll 三个 callback。
// 后果clearCurrentFile() → unload() 把 callback 置空 → 下次 openFile
// 走 editor.loadFile() 时只重建 view**不重建 callback** → 用户的
// 编辑不再触发 state.isDirty 翻转 → 保存按钮永远不亮 → 用户以为保存
// 按钮坏了。
//
// 触发场景:当前打开的文件被外部删除 → applyEntries 调 clearCurrentFile
// → 卸载旧 view 并清 callback → 用户再开新文件 → loadFile 只重建 view
// → callback 仍是 null → 编辑器从此对所有用户输入静默忽略。
//
// K2-L10 的原意HMR/切数据目录/Ctrl+R 不挂旧闭包):这一层不该由
// unload() 兜底,应该由 dispose() 兜底pagehide 清理在 app.js:3497
// 已正确调 editor.dispose()dispose() 走 addAbort 注册的清理路径
// 会把 callback 置 null。unload() 只清 view 状态,不动 callback。
//
// 关于 callback 闭包反向持有 viewer/state/ai 的"内存延长"顾虑 —— editor
// 实例本身是 module-scope 的app.js:475 const editor = new MarkdownEditor(...)
// 永不释放callback 持有那几个引用等价于 editor 持有那几个引用,不存在
// "延长 lifecycle"的差异。让 callback 跟 editor 一起活到 dispose()。
}
/**
* 取当前编辑器内容(字符串)
*/
getContent() {
if (!this.view) return '';
return this.view.state.doc.toString();
}
/**
* 是否脏(未保存改动)
*/
isDirty() {
if (!this.view) return false;
return this.view.state.doc.toString() !== this.lastSavedDoc;
}
/**
* 标记当前内容为「已保存」基线。下次 isDirty() 比较的就是这次保存的版本。
*/
markSaved() {
if (!this.view) return;
this.lastSavedDoc = this.view.state.doc.toString();
}
/**
* 用外部内容替换当前编辑器内容(例如外部修改覆盖本地)。
* 同时更新 lastSavedDoc —— 因为这是从磁盘来的「最新权威」。
*
* 注意:必须先更新 lastSavedDoc 再 dispatch否则 updateListener 会同步触发 onChange
* 此时 lastSavedDoc 仍是旧值isDirty() 会错误返回 true。
*
* audit fix (K2-M4)dispatch 前快照当前 selectiondispatch 时回填到
* clamp 后的位置 —— CM6 默认把光标落到新 doc末尾若用户正读到第 50 行做
* diff 标注,外部一次保存就把编辑器滚到底部,等于打断阅读。
*/
setExternalContent(content) {
if (!this.view) return;
// audit fix (Round 4 F2)IME 合成期拒绝 setExternalContent。
// fs-watcher 在 _composing=true 期间推「外部修改」→ view.dispatch 全量替换
// doc 会摧毁 IME 合成 buffer已输入的拼音字符消失。applyUserChange 已有同样
// 守卫setExternalContent 同性质入口行为必须对齐。抛 EDITOR_COMPOSING 让
// 调用方 catch 后给用户「请稍后再试」提示或推迟到 compositionend 后再 dispatch。
if (this._composing) {
throw new Error('EDITOR_COMPOSING');
}
const sel = this.view.state.selection.main;
this.lastSavedDoc = content || '';
const insert = content || '';
const newLen = insert.length;
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert },
selection: {
anchor: Math.min(sel.anchor, newLen),
head: Math.min(sel.head, newLen),
},
});
}
/**
* 用新内容替换当前编辑器内容,并把这次替换标记为"未保存改动"。
* 与 setExternalContent 的区别lastSavedDoc 不跟着更新,
* 替换后 isDirty() 仍返回 true自动保存 / Ctrl+S 能正确触发)。
*
* 用于 AI 修改应用:用户能 Ctrl+Z 撤销 AI 的改动、自动保存会落盘、
* 保存按钮进入 is-dirty 提醒态。
*
* userEvent: 'input.fromAI' 让 CodeMirror 把它当作一次用户输入(影响 undo history
* 单次 dispatch 完整替换(而不是 splice所以 undo 一步就回到旧内容。
*/
applyUserChange(content) {
if (!this.view) return;
// audit fix (Phase O-M1)IME 合成期间拒绝 applyUserChange。
// AI 控制器chat dock 自动 apply / 用户鼠标误点 chat调用本方法时
// 若用户正在 CJK 拼音输入view.dispatch 全量替换 doc 会摧毁 IME 合成
// buffer已输入的拼音字符消失。抛特定 message 让调用方 catch 后给用户
// 「请稍后再试」提示ai-controller.js 已 try/catch + 按 message 区分)。
if (this._composing) {
throw new Error('EDITOR_COMPOSING');
}
// audit fix (K2-M3)dispatch 前快照 selection.mainAI 应用 region/all
// 整页替换后CM6 默认把光标落到新 doc 末尾。clamp 到新 doc 内,至少
// 保留用户下次编辑起点(接受 AI 改动后想继续写,不至于被甩到文末)。
const sel = this.view.state.selection.main;
const insert = content || '';
const newLen = insert.length;
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert },
userEvent: 'input.fromAI',
selection: {
anchor: Math.min(sel.anchor, newLen),
head: Math.min(sel.head, newLen),
},
});
}
// L11 fix (audit):原参数名 `enabled` 是双重否定陷阱 —— 调用方
// setReadOnly(true) 实际是「启用只读」跟「enable writing」直觉反着。
// 改名 `readOnly` 后 setReadOnly(true) = 只读setReadOnly(false) = 可写。
// CM6 内部 EditorView.editable.of(true) 表示「可编辑」,所以仍然要取反。
setReadOnly(readOnly) {
if (!this.view) return;
this.view.dispatch({
effects: this.editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
});
}
focus() {
if (this.view) this.view.focus();
}
/**
* 编辑器的滚动进度0..1
*
* 用滚动视口顶部在文档中的位置(即「已滚过的距离 / 总可滚距离」)作为比例,
* 与 viewer 的 getScrollProgress() 口径一致,双栏同步时直接套用即可。
*
* 如果文档没溢出max<=0返回 0 —— 避免 NaN。
* @returns {number}
*/
getScrollProgress() {
if (!this.view) return 0;
const scroller = this.view.scrollDOM;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return 0;
return Math.min(1, Math.max(0, scroller.scrollTop / max));
}
/**
* 把编辑器滚动到指定比例0..1。供双栏反向同步viewer → editor
* 与 viewer.setScrollProgress() 是一对。
* @param {number} progress
*/
setScrollProgress(progress) {
if (!this.view) return;
const scroller = this.view.scrollDOM;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
const target = Math.min(1, Math.max(0, progress)) * max;
scroller.scrollTop = target;
}
/**
* 当前编辑器的字数统计CJK 友好,统一走 stats.js
* 行数基于源文本非空行数,与 viewer 一致 —— 切换预览/编辑时不会跳变。
* @returns {{chars:number, words:number, lines:number}}
*/
getStats() {
if (!this.view) return { chars: 0, words: 0, lines: 1 };
return getTextStats(this.view.state.doc.toString());
}
_destroyView() {
// audit fix (renderer-M4):先取消可能挂着的 rAF —— 否则 destroy 后 rAF
// 回调里读 this.view / this.onScrollCallback 还能命中内存中尚未 GC 的
// 引用,触发到旧组件实例的回调链。
if (this._scrollFrame !== null) {
cancelAnimationFrame(this._scrollFrame);
this._scrollFrame = null;
}
if (this.view) {
this.view.destroy();
this.view = null;
}
}
}

736
src/markdown-viewer.js Normal file
View File

@@ -0,0 +1,736 @@
// Markdown 阅读视图组件
// 职责:把 markdown 字符串渲染成 HTML、更新状态栏、拦截链接点击。
//
// 与 markdown-editor.js 的关系viewer 只关心「显示」。
// 加载文件的 dirty 检查、IO 协调全部交给 app.js。
import { formatSize, formatDate, dirnameOf, resolvePath, toFileUrl, safeRenderMarkdown, isPathInside } from './utils.js';
import { getTextStats } from './stats.js';
import { createDisposable } from './component.js';
/**
* 转义要放进 [id="..."] 属性选择器里的值。
* 中文 / 数字开头的 id 直接拼 '#' 会是非法选择器,属性选择器最省事,
* 只需要处理反斜杠和双引号。
*/
function cssAttrEscape(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* 渲染 markdown 字符串为安全的 HTML
* - 委托给 utils.safeRenderMarkdownmarked + DOMPurify 实际初始化在 preload.js 里完成
* - 失败 / 不可用时降级为转义后的 <pre>(永远不返回未转义的原文)
*/
function renderMarkdown(markdown) {
if (!markdown || typeof markdown !== 'string') return '';
return safeRenderMarkdown(markdown);
}
export class MarkdownViewer {
/**
* @param {object} options
* @param {HTMLElement} options.bodyElement - <article> 渲染容器
* @param {HTMLElement} options.emptyElement - 空状态元素
* @param {HTMLElement} options.statusPath - 状态栏路径
* @param {HTMLElement} options.statusMeta - 状态栏元信息
* @param {(url:string) => void|Promise<void>} options.onExternalLink - 外部链接点击回调
* @param {() => string|Promise<string>} [options.getDataDir] - 返回当前数据目录绝对路径
* 异步 callbackresolveRelativeImages 用它做安全边界检查audit C3
* 不传则跳过越界检查,回退到「只挡 ../.. 越界字符串」的原行为。
* @param {() => string|null} [options.getCurrentDir] - 返回当前浏览目录的绝对路径
* 没打开文件时状态栏用它替代「—」显示当前文件夹位置(用户从路径就能定位到根/子目录)。
* app.js 在 scanDir / loadFileList 完成后调 refreshCurrentDir() 让 chip 跟随刷新。
*/
constructor({ bodyElement, emptyElement, statusPath, statusMeta, onExternalLink, getDataDir, getCurrentDir }) {
this.bodyElement = bodyElement;
this.emptyElement = emptyElement;
this.statusPath = statusPath;
this.statusMeta = statusMeta;
this.onExternalLink = onExternalLink;
// 数据目录提供者 —— 用 callback 而非缓存值,因为 dataDir 会随用户切换设置而变。
this.getDataDir = typeof getDataDir === 'function' ? getDataDir : null;
// 当前浏览目录提供者 —— 仅用于无文件时状态栏路径展示。
this.getCurrentDir = typeof getCurrentDir === 'function' ? getCurrentDir : null;
this.currentFile = null;
this.currentContent = '';
this.currentHeadingId = null; // 当前在视口里最高可见的 heading id
this.onHeadingChangeCallbacks = new Set();
// 当前文档所有 heading 的快速查表id → {level, text, id}
// 由 _rebuildHeadingObserver 同步构建;状态栏 heading chip 用它把
// currentHeadingId 还原成可读文本。
this._headingMap = new Map();
// IntersectionObserver跟踪"当前在视口顶部的 heading"
this._headingObserver = null;
// 每个打开过的文件保留一份滚动位置。
// 上限 SCROLL_POSITION_CACHE_LIMIT = 200超出后按插入顺序淘汰最旧未访问的条目
// 防止打开几千个文件后 Map 无限增长(每个 entry ≈ 8 字节 key + 8 字节 number
// Map 保留插入顺序;重新访问某文件时先 delete 再 set等价于移到队尾。
this.scrollPositions = new Map();
this.SCROLL_POSITION_CACHE_LIMIT = 200;
// 跨文件竞态守卫resolveRelativeImages 是 fire-and-forget 的 async
// A→B 快速切换文件时A 的回调可能在 B 已经接管 DOM 后才 resume。
// 用单调递增的 generation token每次 resolveRelativeImages 入口自增,
// 关键 await 之后再比一次 —— 不等就 return避免 A 的解析污染 B 的图片。
this._resolveGen = 0;
// 缓存空态元素初始 innerHTML —— showUnsupportedFile 会临时改写它,
// 下一次 loadContent/clear 再还原回 index.html 里的默认文案(避免
// 「上次打开 .txt 残留的'不支持预览'文字」污染下次空态)。
this._defaultEmptyHTML = emptyElement ? emptyElement.innerHTML : '';
// 统一 lifecycledispose() 一次性 unbind 所有 listener。
createDisposable(this);
// audit fix (Phase L1-M1)onHeadingChange 回调 Set
// 不在 listener 信号路径上 —— 持有 app.js / state / editor / ai 等大闭包,
// dispose 时不显式 clear 就会一直挂到 pagehide。HMR / 调试路径下 viewer
// 替换后旧回调还能触发,让新组件的 state 被污染。abort 时一次性清空。
this.addAbort(() => {
this.onHeadingChangeCallbacks.clear();
// audit fix (Round 12 P3)_headingObserver 是 IntersectionObserver
// _rebuildHeadingObserver / clear / showUnsupportedFile 三处各自
// disconnect但 dispose 路径没有兜底。detach 后的 heading 节点
// 被 IntersectionObserver 持有 → Detached HTMLHeadingElement 不被 GC
// 直到 observer disconnect。
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
});
// 拦截链接点击:所有 .markdown-body 内的 <a> 都通过 onExternalLink 走系统默认浏览器
if (this.bodyElement) {
this.bodyElement.addEventListener('click', (e) => {
const anchor = e.target.closest('a[href]');
if (!anchor) return;
const href = anchor.getAttribute('href');
// 统一先 preventDefault空 href ('') / 锚点 / 任意相对路径,
// 都不让浏览器按默认行为跑(空 href 不阻止会让 Electron 重载页面)。
e.preventDefault();
if (!href) return; // 空 href什么都不做合法占位不触发系统行为
// 文内锚点(#xxx自己滚动不改地址栏 hash。
// marked 会把 href 里的中文百分号编码,而 id 是原文,必须先解码再匹配。
if (href.startsWith('#')) {
this.scrollToAnchor(href.slice(1));
return;
}
if (this.onExternalLink) {
// audit fix (Round 12 P2)onExternalLink 在调用方是 async 函数
// src/app.js:419 走 openFile + openExternal IPC同步调不会等
// promiseIPC 抛 throw非 ok=false会变成 unhandledRejection。
// 用户点击外链没反应、只能去主进程 console 翻日志。在 viewer
// 这一层 catch 住所有异常 + 打 console + 不影响 click 后续。
// 调用方已用 {ok,message} envelope 走友好提示,这里只兜
// 「真 throw」这一条路径。
try {
const ret = this.onExternalLink(href);
if (ret && typeof ret.catch === 'function') {
ret.catch((err) => {
console.error('[markdown-viewer] onExternalLink promise rejected:', err);
});
}
} catch (err) {
console.error('[markdown-viewer] onExternalLink threw synchronously:', err);
}
}
}, { signal: this.signal });
}
}
/**
* 滚动到文内锚点。
* @param {string} rawHash - 不含 '#' 的片段,可能是百分号编码的
* @returns {boolean} 是否找到目标
*/
scrollToAnchor(rawHash) {
if (!rawHash || !this.bodyElement) return false;
let id = rawHash;
try {
id = decodeURIComponent(rawHash);
} catch {
// 非法编码就按原样匹配
}
// 用属性选择器而不是 '#'+id中文/数字开头的 id 在 CSS 选择器里需要转义
const target = this.bodyElement.querySelector(`[id="${cssAttrEscape(id)}"]`)
|| this.bodyElement.querySelector(`[id="${cssAttrEscape(rawHash)}"]`);
if (!target) return false;
target.scrollIntoView({ block: 'start' });
return true;
}
/**
* 渲染 markdown 字符串到视图。
* @param {{path:string,name:string,size?:number,mtimeMs?:number}} file
* @param {string} content - markdown 内容
*/
async loadContent(file, content) {
if (!file || !this.bodyElement) return;
// 恢复空态为 index.html 里的默认文案showUnsupportedFile 可能改写过了)
this._resetEmptyState();
// 保存当前文件的滚动位置LRU删除再 set 移到末尾)
if (this.currentFile && this.bodyElement.parentElement) {
const path = this.currentFile.path;
const top = this.bodyElement.parentElement.scrollTop;
if (this.scrollPositions.has(path)) this.scrollPositions.delete(path);
this.scrollPositions.set(path, top);
// 超出上限删最早插入的Map 头部)
while (this.scrollPositions.size > this.SCROLL_POSITION_CACHE_LIMIT) {
const oldest = this.scrollPositions.keys().next().value;
this.scrollPositions.delete(oldest);
}
}
this.currentFile = file;
this.currentContent = typeof content === 'string' ? content : '';
// Phase N M-1 fix空文件走「空文件」占位文案而不是渲染空 body。
// 旧实现 renderMarkdown('') 返回 '' → bodyElement.innerHTML='' → 用户看到
// 一片空白面板,没有任何提示文件是「真的空」还是「渲染挂了」。
// 与「未打开文件」空态emptyElement 显示区分当前打开了文件emptyElement 必须
// 继续隐藏,否则用户以为文件没打开。
if (!this.currentContent || this.currentContent.trim() === '') {
this.bodyElement.innerHTML = '<div class="md-empty-file" role="note">(空文件)</div>';
this.bodyElement.hidden = false;
if (this.emptyElement) this.emptyElement.hidden = true;
this._rebuildHeadingObserver();
this.updateStatusBar(file);
return;
}
const html = renderMarkdown(content);
// 自增 generation token让旧文件的 in-flight resolveRelativeImages 在
// await getDataDir 之后读 _resolveGen 时失配自行放弃A→B 切文件场景)。
this._resolveGen += 1;
this.bodyElement.innerHTML = html;
// resolveRelativeImages 内部 await getDataDir() —— loadContent 是 async 不阻塞调用方
// viewer 不暴露 promise调用方 fire-and-forget 即可,与原同步行为对齐)
this.resolveRelativeImages(file.path).catch((e) => {
// 数据目录暂时取不到时preload 还未注入等极端情况)→ 跳过图片解析,
// 但不阻断主渲染流程。日志走 console.warn 让用户能看到。
console.warn('[viewer] resolveRelativeImages 失败:', e && e.message);
});
this.bodyElement.hidden = false;
if (this.emptyElement) this.emptyElement.hidden = true;
// 重绑 heading observer_rebuildHeadingObserver 内部重建 _headingMap
this._rebuildHeadingObserver();
// 更新状态栏
this.updateStatusBar(file);
// 恢复滚动位置
// audit fix (L36):捕获 file.path 用于 rAF 内部的「当前文件没变」校验。
// 之前直接用 this.currentFile.pathrAF 回调里 this 已经是新文件A→B 切得很快
// 时 this.currentFile 已被 B 替换),但 callback 里读 this.scrollPositions.get('B')
// 等于「给 B 应用 A 的滚动位置」。A 的 scrollTop 被顺手存进了 scrollPositions.set('B', A的top)
// —— 第二次开 B 会继承错误的滚动。
// 现在 rAF 内先比对 capturedPath === this.currentFile.path不一致就放弃 restore。
const capturedPath = file.path;
requestAnimationFrame(() => {
if (!this.currentFile || this.currentFile.path !== capturedPath) return;
// 命中:删除再 set等价于把这条移到 Map 末尾最近访问LRU 语义
if (this.scrollPositions.has(capturedPath)) {
const v = this.scrollPositions.get(capturedPath);
this.scrollPositions.delete(capturedPath);
this.scrollPositions.set(capturedPath, v);
}
const restore = this.scrollPositions.get(capturedPath) || 0;
if (this.bodyElement.parentElement) {
this.bodyElement.parentElement.scrollTop = restore;
}
});
}
/**
* 显示「该文件不支持预览」的空态。
*
* Stage 8 扩展:可编辑文件不一定是 Markdowntxt/py/json/...)—— 这些文件
* 仍然能加载到编辑器,但 viewer 不渲染。空态改成「仅编辑模式」提示,
* 状态栏仍展示文件名 / 大小 / mtime与 loadContent 行为一致)。
*
* 不修改持久化的 state.editorMode —— 调用方负责把编辑器切到 edit 模式。
* 后续 loadContent / clear 会自动还原空态默认文案。
*
* @param {{path:string,name:string,size?:number,mtimeMs?:number}} file
*/
showUnsupportedFile(file) {
if (!file) return;
this.currentFile = file;
this.currentContent = '';
this.currentHeadingId = null;
this._headingMap = new Map();
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
// 隐藏正文,显示空态并改写文案
if (this.bodyElement) {
this.bodyElement.innerHTML = '';
this.bodyElement.hidden = true;
}
if (this.emptyElement) {
// 直接 innerHTML 改写,保留外层 .empty-state 的布局/动画样式
this.emptyElement.innerHTML = `
<div class="empty-state-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
</div>
<p>该文件不支持预览</p>
<p class="hint">仅 <code>.md</code> 与 <code>.markdown</code> 文件支持 Markdown 预览。<br>其他文本格式(<code>.txt</code> / <code>.py</code> / <code>.json</code> 等)仅支持编辑。</p>
`;
this.emptyElement.hidden = false;
}
// 状态栏仍展示文件名 / 大小 / mtime与 markdown 文件一致)
this.updateStatusBar(file);
// heading 清空:通知订阅者
this._emitHeadingChange();
}
/**
* 恢复空态元素到 index.html 里的默认文案。
* 由 loadContent / clear 在替换显示内容前调用,避免上次 showUnsupportedFile
* 改写过的「不支持预览」文字残留。
*/
_resetEmptyState() {
if (this.emptyElement && this._defaultEmptyHTML !== undefined) {
this.emptyElement.innerHTML = this._defaultEmptyHTML;
}
}
/**
* 把 <img> 的相对 src 改写成 file:// 绝对地址。
*
* 不改写的话相对路径会相对 index.html应用安装目录解析
* 笔记旁边的图片永远加载不出来。
*
* 只处理相对路径绝对路径、data:、http(s): 等一律不动。
*
* audit C3 安全修复:解析后的绝对路径必须仍在数据目录内 —— 否则恶意 markdown
* 可以写 `<img src="../../../Users/victim/secret.jpg">` 直接通过 file:// 读到
* 用户机器上的任意可读文件。getDataDir 回调由构造时注入app.js 传
* `() => window.api.getDataDir()`),取不到时退化为「只挡 ../.. 越界字符串」的旧行为。
*
* @param {string} filePath - 当前 markdown 文件的绝对路径
*/
async resolveRelativeImages(filePath) {
if (!this.bodyElement || !filePath) return;
// audit fix (perf-MEDIUM)A→B 快速切文件时,旧 loadContent 启动的
// resolveRelativeImages(A) 是 fire-and-forgetawait getDataDir 期间
// currentFile 已被新 loadContent 替换为本文件 B但 this.bodyElement
// 也指向已被替换的 DOMB 的内容。若不挡A 的解析结果会污染 B 的图片。
//
// audit fix (renderer-M3)bodyElement 身份永远不会变(同一个 DOM 节点反复
// innerHTML用 generation token 保证:每次调用入口自增,**关键 await 之后**
// 比对 —— 不一致就 return。token 由 loadContent / loadContentLive 在替换
// innerHTML 之前自增,迫使所有早于本次的内层 await 回调放弃。
//
// B-1 fix (audit):之前在 ++myGen 之后立刻 `if (myGen !== this._resolveGen) return;`
// —— 这是 no-op自增后立即比对token 一定相等,要等到 await 期间才可能变化。
// 现在只保留 await 后的真正检查line ~301
const myGen = ++this._resolveGen;
const baseDir = dirnameOf(filePath);
if (!baseDir) return;
// 异步取当前数据目录 —— 失败/缺失时 dataDir 为 null跳过边界检查旧行为
let dataDir = null;
if (this.getDataDir) {
try {
const got = this.getDataDir();
dataDir = (got && typeof got.then === 'function') ? await got : got;
if (typeof dataDir !== 'string' || !dataDir) dataDir = null;
} catch {
dataDir = null;
}
}
// await 之后再判一次 —— 期间可能被新 loadContent / loadContentLive 接管
if (myGen !== this._resolveGen) return;
for (const img of this.bodyElement.querySelectorAll('img[src]')) {
const src = img.getAttribute('src');
if (!src) continue;
// 带协议(含 data:/file:/http:)或以 / 开头的绝对路径 → 原样保留
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('//')) continue;
let decoded = src;
try {
decoded = decodeURIComponent(src);
} catch {
// 非法编码就按原文当路径
}
const abs = resolvePath(baseDir, decoded);
if (!abs) continue; // '../..' 越界
// 安全边界:解析后的路径必须在数据目录内。越界直接剥 src不暴露外部文件路径。
if (dataDir && !isPathInside(abs, dataDir)) {
img.removeAttribute('src');
// 保留视觉占位:把 alt 提到 title并加一个占位属性让 CSS 可选择
img.setAttribute('data-blocked', 'external');
const alt = img.getAttribute('alt');
if (alt) img.setAttribute('title', `外部图片已拦截:${alt}`);
continue;
}
img.setAttribute('src', toFileUrl(abs));
// audit fix (P3):图片懒加载 + 异步解码 —— 一篇含 20 张图的笔记,
// 之前 marked 直接输出 <img src>20 个图同时启动下载 + 都参与初始布局,
// 长文档里看不见的图也抢带宽CLS 也因为没有 width/height 一图一抖。
// loading="lazy" 让浏览器在图进入视口前不下载decoding="async" 让解码
// 不阻塞主线程(避免大图 decode 卡顿)。这两个属性 setAttribute 已设就
// 不会重复设getAttribute 检查避免每次重渲都重写)。
if (!img.hasAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.hasAttribute('decoding')) img.setAttribute('decoding', 'async');
}
}
/**
* 同步文件元数据(路径 / 名称 / mtime / size不重渲染。
*
* P1-5文件被重命名或重载后state.currentFile.path/name/mtimeMs/size 都更新了,
* 但 viewer.currentFile 还是旧值 —— viewer 的 resolveRelativeImages 走旧 baseDir
* 笔记里 `<img src="./foo.png">` 全部失效。
*
* 不全量 reloadContent 是因为:内容没变,只是元数据变了,重渲染会丢滚动位置、
* IntersectionObserver 全量重建 —— 完全不必要。
*
* @param {{path?:string,name?:string,size?:number,mtimeMs?:number}} meta
*/
setFileMeta(meta) {
if (!meta || !this.currentFile) return;
if (meta.path && meta.path !== this.currentFile.path) {
// 路径变了(重命名 / dataDir 切换)→ 必须把 <img src="./..."> 重新解析到新 baseDir
this.currentFile = { ...this.currentFile, ...meta };
// audit fix (Round 4 F8)resolveRelativeImages 抛错兜底。setFileMeta 在
// 重命名 / 切目录高频路径上被调用,里面 for 循环可能因 getDataDir 异常或
// setAttribute 触发 CSP 拦截抛错 —— 之前无 .catch未捕获的 Promise rejection
// 在 Electron 主进程 default handler 那里打 log。与 loadContent line 186-190 对齐。
Promise.resolve(this.resolveRelativeImages(this.currentFile.path))
.catch((e) => console.warn('[viewer] setFileMeta resolveRelativeImages:', e));
} else {
// 路径没变(只是 mtime / size 变化)→ 仅更新元数据即可
this.currentFile = { ...this.currentFile, ...meta };
}
// 状态栏文件名 / mtime 也要刷新
this.updateStatusBar(this.currentFile);
}
/**
* 清空视图,回到空状态。
*
* audit C2 修复:保留 scrollPositions —— 之前 clear() 一刀切全清,
* 导致「切换文件 → clear → 切回原文件」丢失滚动位置,用户体验差。
* 现在只清当前文件的 entry其它文件保留等价于「清空当前视图但 LRU 缓存仍在」。
*/
clear() {
// 恢复空态默认文案showUnsupportedFile 可能改写过了)
this._resetEmptyState();
if (this.bodyElement) {
this.bodyElement.innerHTML = '';
this.bodyElement.hidden = true;
}
if (this.emptyElement) this.emptyElement.hidden = false;
this.currentFile = null;
this.currentContent = '';
this.currentHeadingId = null;
this._headingMap = new Map();
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
// 只删当前文件的滚动位置 —— loadContent 已经在切换前把 top 存进 scrollPositions
// 再清一次反而覆盖。clear 的语义就是「不再有 currentFile」所以 entry 留着无害。
if (this.statusPath) this.statusPath.textContent = this._folderLabel();
if (this.statusMeta) this.statusMeta.textContent = '';
this._emitHeadingChange();
}
/**
* 当前浏览目录刷新 —— 没有打开文件时把状态栏路径切到当前文件夹。
* 由 app.js 在 scanDir / loadFileList 成功后调用,路径信息同步到 chip。
* 有文件打开时不影响(文件路径优先级更高,由 updateStatusBar 控制)。
*/
refreshCurrentDir() {
if (!this.statusPath) return;
if (this.currentFile) return; // 文件路径优先,不覆盖
this.statusPath.textContent = this._folderLabel();
}
/** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到 '—'。 */
_folderLabel() {
const dir = this.getCurrentDir ? this.getCurrentDir() : '';
return dir || '—';
}
/**
* 更新状态栏
*/
updateStatusBar(file) {
if (this.statusPath) {
this.statusPath.textContent = file.path || file.name;
}
if (this.statusMeta) {
const parts = [];
if (typeof file.size === 'number') parts.push(formatSize(file.size));
if (typeof file.mtimeMs === 'number') parts.push(formatDate(file.mtimeMs));
this.statusMeta.textContent = parts.join(' · ');
}
}
/**
* 当前阅读视图的字数统计CJK 友好)
*
* 词数基于渲染后的纯文本(读者看到的文字,不含 markdown 标记),
* 行数基于源文档 —— 这样切换预览/编辑模式时状态栏的「N 行」不会跳变。
* 之前这里数的是渲染后的块级元素个数,同一个文件在预览下显示 3 行、
* 切到编辑就变成 6 行。
*
* @returns {{chars:number, words:number, lines:number}}
*/
getStats() {
// 行数基于源文档(不是渲染后的 textContent切预览/编辑时不会跳变。
const source = this.currentContent || '';
return getTextStats(source);
}
/**
* 阅读视图的滚动进度0..1
* @returns {number}
*/
getScrollProgress() {
if (!this.bodyElement) return 0;
const scroller = this.bodyElement.parentElement;
if (!scroller) return 0;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return 0;
return Math.min(1, Math.max(0, scroller.scrollTop / max));
}
/**
* 把阅读视图滚动到指定比例0..1。供双栏反向同步editor → viewer / viewer → editor
* @param {number} progress
*/
setScrollProgress(progress) {
if (!this.bodyElement) return;
const scroller = this.bodyElement.parentElement;
if (!scroller) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
const target = Math.min(1, Math.max(0, progress)) * max;
scroller.scrollTop = target;
}
/**
* 监听阅读视图自身的滚动(用于反向同步到编辑器)。
*
* audit fix (H1-renderer)listener 必须挂在组件自己的 signal 上 —— 之前
* 没传 signaldispose() 时 listener 仍存在,闭包持住 viewer + 回调对象,
* 单例 dispose 后再开新组件(旧 cb 在 rAF 里跑 → 抛"viewer disposed"
* 类错误)。现在挂 signaldispose() 一次性 unbind同时返回 unsubscribe
* 让调用方可以单独取消(多重订阅场景)。
*
* @param {(progress:number) => void} cb
* @returns {() => void} unsubscribe
*/
onScroll(cb) {
if (!this.bodyElement || !this.bodyElement.parentElement) return () => {};
const scroller = this.bodyElement.parentElement;
const handler = () => {
if (typeof cb === 'function') cb(this.getScrollProgress());
};
scroller.addEventListener('scroll', handler, { passive: true, signal: this.signal });
return () => scroller.removeEventListener('scroll', handler);
}
/**
* 轻量刷新(仅替换 body HTML不动滚动位置 / 不触发 status bar 更新 / 不存历史)。
*
* 用于「编辑实时预览」:用户敲键盘时右侧实时跟手,又不希望每次都重置滚动。
* 与 loadContent 的关键差异:
* - 不保存当前滚动位置(用户自己滚动到哪里就是哪里)
* - requestAnimationFrame 后不恢复滚动(保持原状)
* - 不更新状态栏
*
* @param {string} content - 最新的 markdown 字符串
* @returns {{changed:boolean}} changed=true 表示内容确实变了
*/
loadContentLive(content) {
if (!this.bodyElement) return { changed: false };
const next = typeof content === 'string' ? content : '';
// M-series fix (audit Phase M-H3):即便内容没变(早返回),也要 bump
// generation token让上一轮 in-flight 的 resolveRelativeImages 在
// await getDataDir 之后读到旧 myGen → 自行放弃。否则 A→B→A 序列里
// 中间那次 loadContentLive 早返回pending 的 resolveRelativeImages
// 仍会写入 body覆盖新内容里的图片路径用 baseDir 复用旧文件)。
// loadContentLive 的 _resolveGen 跳号不致命:后续 loadContent / 下一次
// loadContentLive 仍会 bump 到新值,最终一致性靠单调递增保证。
if (next === this.currentContent) {
this._resolveGen += 1;
return { changed: false };
}
this.currentContent = next;
const html = renderMarkdown(next);
// 自增 generation token与 loadContent 同源 —— 每次 innerHTML 替换都让
// 上一轮的 resolveRelativeImages 失效M7 fixloadContentLive 内层
// resolveRelativeImages 也必须走 generation 比对,单靠 capturedBody 不够
// bodyElement 身份不变)。
this._resolveGen += 1;
this.bodyElement.innerHTML = html;
this.resolveRelativeImages(this.currentFile ? this.currentFile.path : null);
// P1-1增量更新 IntersectionObserver 而不是每次 innerHTML 后全量重建。
// 编辑实时预览里 200ms 防抖触发一次10K 行的笔记每次重建 observer
// 会做 disconnect + new + N 次 observe长文档能感知到卡顿。
// 仅当 heading 集合发生变化(增 / 删 / id 变)才重建,否则完全跳过。
// audit fix (Round 4 F11):直呼底层 _rebuildHeadingObserver。_syncHeadingObserver
// 是仅做一层转发的薄壳,签名上的优化语义已被 _rebuildHeadingObserver 全量重建
// 替代,留着壳子只会让读代码的人多绕一层。直接调底层 + 注释说明「全量重建代价
// 可忽略」。
this._rebuildHeadingObserver();
return { changed: true };
}
/**
* 当前在视口里"最顶部可见"的 heading
* @returns {{level:1|2|3, text:string, id:string}|null}
*/
getCurrentHeading() {
if (!this.currentHeadingId || !this._headingMap.size) return null;
return this._headingMap.get(this.currentHeadingId) || null;
}
/**
* 订阅"当前 heading 变化"事件
*
* 用 IntersectionObserver 跟踪每个 heading 元素是否进入/离开视口顶部
* 一带状区域rootMargin: '0px 0px -80% 0px' 表示视口顶部 20% 那一条带)。
* 多个 heading 可能在同一带里 → 取"位置最高且仍在带内"的最后一个。
*
* @param {(heading: {level:1|2|3, text:string, id:string}|null) => void} cb
* @returns {() => void} 取消订阅函数
*/
onHeadingChange(cb) {
if (typeof cb !== 'function') return () => {};
this.onHeadingChangeCallbacks.add(cb);
// 立刻用当前 heading 触发一次,让订阅者能立刻同步状态
try { cb(this.getCurrentHeading()); } catch { /* ignore */ }
return () => this.onHeadingChangeCallbacks.delete(cb);
}
/**
* 重建 heading IntersectionObserver
*
* 每次 loadContent / loadContentLive 都会调用 —— 因为 DOM 全换了,
* 旧的 observer 持有的 DOM 节点已经无效,必须重建。
*/
_rebuildHeadingObserver() {
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
if (!this.bodyElement) return;
const headings = this.bodyElement.querySelectorAll('h1, h2, h3');
// 同步构建 heading 快查表id → {level, text, id}
// 让 getCurrentHeading 直接从 DOM 读取。
this._headingMap = new Map();
for (const h of headings) {
if (!h.id) continue;
this._headingMap.set(h.id, {
level: Number(h.tagName.charAt(1)), // 'H1' → 1
text: h.textContent || '',
id: h.id,
});
}
// 记录当前观察的 heading id 列表(调试 / 复用参考)
this._observedHeadingIds = headings.length === 0 ? [] : Array.from(headings).map(h => h.id);
if (headings.length === 0) {
this.currentHeadingId = null;
this._emitHeadingChange();
return;
}
// 把"当前可见的 heading 集合"实时维护:进入带 → 加入;离开带 → 移除。
// 最后 emit 的 currentHeadingId 是"位置最低且仍在带内"的(最接近顶部
// 但还没滚走的那个 —— 这就是用户在阅读的章节)。
const visibleIds = new Set();
this._headingObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
const id = e.target.id;
if (!id) continue;
if (e.isIntersecting) visibleIds.add(id);
else visibleIds.delete(id);
}
// 找在 visibleIds 里、且 DOM 顺序中位置最低(最后)的 heading
let next = null;
for (const h of headings) {
if (visibleIds.has(h.id)) next = h.id;
}
// 如果一个都不在带内(滚过头了),保持上一次
if (next === null && visibleIds.size === 0) {
// 不主动改 currentHeadingId —— 让"上次已知"留在状态栏
return;
}
// 滚过头(带内全空,且最后位置在视口下方) → 选中最后一个 heading
if (next === null) {
// 用 viewport 顶部坐标最近的 heading
const scroller = this.bodyElement.parentElement;
if (scroller) {
// offsetTop 相对最近的 positioned 祖先;本布局里 .viewer / .markdown-body
// 都是 position: staticoffsetParent 一路爬到 <body> —— 与
// scroller.scrollTop 完全不在同一坐标系,会导致「滚过头时永远选不中」。
// 改用 getBoundingClientRect 拿视口坐标scroller.scrollTop 在两边都加
// 上 → 相互抵消,等价于比较 h.rect.top <= scroller.rect.top。
const scrollerTop = scroller.getBoundingClientRect().top;
let best = null;
let bestDist = Infinity;
for (const h of headings) {
const hTop = h.getBoundingClientRect().top;
if (hTop <= scrollerTop) {
const d = scrollerTop - hTop;
if (d < bestDist) { bestDist = d; best = h.id; }
}
}
next = best;
}
}
if (next !== this.currentHeadingId) {
this.currentHeadingId = next;
this._emitHeadingChange();
}
}, {
root: this.bodyElement.parentElement || null,
// 顶部 0-20% 的"激活带" —— 进入这个带才算"当前正在阅读"
rootMargin: '0px 0px -80% 0px',
// P2-9单阈值 0 即可。原来的 [0, 1] 会在「进入带」与「完全在带里」各发一次,
// 状态栏 heading chip 在边界来回闪烁;激活带由 rootMargin 控制已经够准确。
threshold: 0,
});
for (const h of headings) this._headingObserver.observe(h);
}
/**
* 增量更新 heading observer曾经是 P1-1 的 id-only 优化)
*
* 编辑实时预览loadContentLive每次都会 innerHTML 替换全部正文。
*
* audit fix (renderer-M1)旧实现靠「heading id 列表相等就跳过重建」来
* 优化,但 innerHTML 替换之后观察的目标节点本身已经被 detach —— id 列表
* 相等(用户改的是正文而非 heading时 observer 继续盯着 detach 节点,
* 新 heading 节点没有任何 observer「当前 heading」追踪直接冻死。
*
* heading 数量是 O(N) 且 N 通常 5-50全量重建代价可忽略。直接走全量重建
* 简单且正确。
*
* audit fix (Round 4 F11):原 _syncHeadingObserver 是仅做一层转发的薄壳,
* 已被删除调用方loadContentLive直接调 _rebuildHeadingObserver。
*/
_emitHeadingChange() {
if (!this.onHeadingChangeCallbacks.size) return;
const heading = this.getCurrentHeading();
for (const cb of this.onHeadingChangeCallbacks) {
try { cb(heading); } catch (e) { console.error('[viewer] onHeadingChange callback 异常:', e); }
}
}
}

55
src/modal-stack.js Normal file
View File

@@ -0,0 +1,55 @@
// 模态栈(最小版:单槽冲突拒绝)
//
// 现象:
// settingsDialog 与 confirmDialog 都把 DOM 挂到同一个 #modal-root
// 用 root.replaceChildren(overlay) 覆盖写入。一个还没关就被另一个顶掉,
// 原来 await 的 Promise 永远不会 resolve —— 整条交互挂死。
//
// 策略(用户已选):
// 已有对话框时拒绝新开(不排队、不顶替),调用方自行处理 toast。
//
// 接口:
// isOpen() → boolean
// register(overlay) → void当前 overlay 入栈
// unregister(overlay) → void从栈中移除
//
// 设计上保持简单:不监听 DOM避免和 dialog 自己的 dispose 顺序打架。
// 调用方负责 register(open) / unregister(close) 的配对。
//
// 注:早期版本还有 tryReject() / openOrReject() —— 返回 {ok, reason} 让调用方
// 决定是否 toast。该模式与「isOpen + 早返回 + 自己 toast」相比无收益前者要
// 调用方再分支),实际零调用方,已删。
let currentOverlay = null;
export function isOpen() {
return currentOverlay !== null && currentOverlay.isConnected;
}
export function register(overlay) {
// audit fix拒收非 DOM 元素 / null / undefined / 字符串等。旧版不校验,
// 任何传错都会让 isOpen() 在 currentOverlay.isConnected 处抛 TypeError
// 把「当前是否有模态」这个 hot-path 探测点炸掉,进而影响 IPC 回复路径。
if (!(overlay instanceof Element)) {
throw new TypeError('modal-stack.register: overlay 必须是 DOM Element');
}
currentOverlay = overlay;
}
export function unregister(overlay) {
// audit fixidempotent —— 不匹配时也清掉已断连的引用。否则overlay A 被
// 外部 .remove() 后没调 unregister再 register(B) → currentOverlay=B。
// 之后某个调试路径 unregister(A)(按旧引用传)会发现不等、什么都不做,
// 但 B 的 unregister(B) 仍能把 currentOverlay 清成 null —— 这里其实
// 没问题。真正问题是A 没调 unregister 又被外部断连,下次 isOpen() 走
// isConnected 检查返回 falseUI 显示「无模态」但栈里仍持有 dead ref
// 调试时 dump 出来是误导的。
if (currentOverlay === overlay) {
currentOverlay = null;
return;
}
// 不匹配:若 currentOverlay 已不连到 DOM外部 .remove()),顺手清掉。
if (currentOverlay && !currentOverlay.isConnected) {
currentOverlay = null;
}
}

328
src/modal.js Normal file
View File

@@ -0,0 +1,328 @@
// 通用 modal 骨架 —— 集中 ARIA / Esc / overlay-click / focus-trap / 单槽冲突
//
// 之前 settings-dialog.js 与 confirm-dialog.js 字符级复制了同样的 modal-overlay
// 骨架 + 一套关闭事件overlay-click / Esc / × 按钮)+ focus-trap 接线。
// 抽到一处后aria 属性、focus 行为、生命周期都只有一份代码可改。
//
// 设计目标:
// - 调用方只关心「标题 / 正文 / 按钮」三件事
// - 关闭路径Esc / 点背景 / × 按钮)由 modal 自身统一处理
// - 调用方可以自定义「关闭时取什么值」resolve(value)
// - 提供 closeValue / updateBody 等逃生口用于更新对话框内容
//
// 调用方式:
// const modal = mountModal({
// title: '...',
// body: '<p>...</p>', // 直接 HTML 字符串(调用方负责 escape
// footer: '<button>...</button>',
// onAction: (action, modal) => { ... }, // data-action 的按钮触发
// onBackdropClose: () => 'cancel', // 点背景/Esc/× → 哪个 value
// initialFocus: el,
// });
// // 需要关闭时modal.close(value)
// // 需要更新正文modal.setBody(html)
import { escapeHtml } from './utils.js';
import { isOpen as modalIsOpen, register as modalRegister, unregister as modalUnregister } from './modal-stack.js';
import { createFocusTrap } from './focus-trap.js';
import { showToast } from './feedback.js';
/**
* 关闭按钮 SVG×—— 单一来源
*/
const CLOSE_BUTTON_SVG = `
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1"/>
</svg>
`;
/**
* 挂一个 modal 到 #modal-root并返回控制器。
*
* @param {object} options
* @param {string} options.title - 标题文本
* @param {string} [options.body=''] - 正文 HTML调用方负责 escape可用 setBody() 后续更新)
* @param {string} [options.footer=''] - 底部 HTML一般放按钮按钮带 data-action触发 onAction
* @param {(action: string, modal: ModalController) =>
* ('close' | undefined | void | Promise<'close' | undefined | void>)} [options.onAction]
* 任意 data-action 按钮点击时触发。返回 'close' 时 modal 会自动 close(action)
* 返回 undefined / void 时 modal 不关,由调用方决定何时 close。
*
* **同步 / 异步两种形态都支持**(审计修复 Round 11 vol.2
* - 同步函数返回 'close' → 立即同步路径关闭(零延迟)
* - 同步函数返回 undefined → 不关
* - 异步函数 resolve 'close' → 微任务里关闭
* - 异步函数 reject → toast「操作失败…」(2.4s) + modal 保持打开
* - 同步函数 throw → 同上toast + 不关)
*
* 实现细节modal.js 内部用 `Promise.resolve(result).then(...).catch(...)` 把
* 两种形态统一成一个 Promise 链;同步 throw 由外层 try/catch 接。**onAction
* 只会被调用一次** —— 不要在同步与异步路径里各实现一遍业务逻辑。
* @param {string} [options.onBackdropClose='cancel']
* 点 overlay 背景 / 按 Esc / 点 × 按钮时调 close() 用的 value。
* @param {HTMLElement|string} [options.initialFocus]
* 初始焦点元素(或选择器)。默认聚焦 footer 最后一个按钮,否则首个可聚焦元素。
* @param {string} [options.titleId] - aria-labelledby 指向的元素 id默认自动生成 modal-title-{counter}
* @param {HTMLElement} [options.fallbackFocus]
* focus-trap 关闭时,若打开前 activeElement 已从 DOM 移除(菜单项被 unmount /
* files:changed 重建列表等),焦点回不到原位的兜底锚点。默认 document.body。
* 一般传「打开 modal 之前最常交互的元素」file-list 右键菜单 → 焦点会回
* 那个 itemsearch 输入触发的搜索结果 dialog → 回搜索框。**不传**的话
* focus-trap 内置回 document.body。
* @returns {ModalController|null} null = 没拿到 #modal-root或已有 modal 占位
*/
export function mountModal({
title,
body = '',
footer = '',
onAction,
onBackdropClose = 'cancel',
initialFocus,
titleId,
fallbackFocus = null,
}) {
const root = document.getElementById('modal-root');
if (!root) {
console.error('[modal] #modal-root 缺失');
return null;
}
// 占位检测:与 modal-stack 单槽策略一致;调用方若需要排队,应自己处理。
if (modalIsOpen()) return null;
const abort = new AbortController();
const id = titleId || `modal-title-${Date.now().toString(36)}`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', id);
overlay.innerHTML = `
<div class="modal" role="document">
<div class="modal-header">
<h2 id="${escapeHtml(id)}" class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" type="button" data-action="__modal_close__" aria-label="关闭">${CLOSE_BUTTON_SVG}</button>
</div>
<div class="modal-body" data-modal-body></div>
<div class="modal-footer" data-modal-footer></div>
</div>
`;
// 正文 / footer 用单独容器而非 innerHTML 整段注入,让调用方可以走安全的文本/HTML 边界。
const bodyEl = overlay.querySelector('[data-modal-body]');
const footerEl = overlay.querySelector('[data-modal-footer]');
if (body) bodyEl.innerHTML = body;
if (footer) footerEl.innerHTML = footer;
root.replaceChildren(overlay);
modalRegister(overlay);
// 审计修复 (Round 11 vol.2):挂 inert 背景 app 根节点。
// modal-overlay 本身在 #modal-root 里(独立容器),它的子树不影响背景 DOM。
// 但 background DOM#app / #file-list / 编辑器 / 工具栏)默认仍然可交互——
// 屏幕阅读器会朗读背景、键盘 Tab 在某些浏览器上仍能跳出 modal、IME 焦点
// 路径不直观。aria-modal="true" 在 AT 上一致性差,给背景打 inert 是
// W3C 推荐的标准化隔离方式。
//
// 选择 #app 而非 document.body —— body 上 inert 会同时让 modal 自身失活;
// #app 是背景应用的稳定根,生命周期长于单次 modal。
// 多 modal 嵌套:当前是单槽设计,不会同时开两个;嵌套场景在 modal-stack 升级到
// 真栈后单独处理。
const appRoot = document.getElementById('app');
const prevAppInert = appRoot ? appRoot.hasAttribute('inert') : false;
if (appRoot && !prevAppInert) {
appRoot.setAttribute('inert', '');
}
// 关闭逻辑单一入口所有路径overlay-click / Esc / × / onAction 触发)最终都走这里。
// onClose 改成回调数组:调用方多次 setOnClose 不再互相覆盖(之前 settings-dialog
// 注册的「清空 API Key」就被后注册的 resolve 静默丢弃P1-1 修复失效)。
let closed = false;
const onCloseCallbacks = [];
const close = (value) => {
if (closed) return;
closed = true;
abort.abort();
if (overlay.parentElement) overlay.parentElement.removeChild(overlay);
modalUnregister(overlay);
// 复位背景 inert —— 之前没设置过就别删(防御上游已经手动设 inert 的场景,
// 例如正在跑加载遮罩modal 不应擦掉它的标记)。
if (appRoot && !prevAppInert && appRoot.hasAttribute('inert')) {
appRoot.removeAttribute('inert');
}
// 按注册顺序全部调用;单个 throw 不应阻断其它清理(例如清空敏感字段必须在 resolve 之前)
for (const cb of onCloseCallbacks) {
try {
cb(value);
} catch (e) {
console.error('[modal] onClose callback threw:', e);
}
}
};
// 点背景(点击 overlay 本身而非 modal → onBackdropClose
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(onBackdropClose);
}, { signal: abort.signal });
// Esc → onBackdropClose
//
// 审计修复 (Round 11)Esc 必须守 IME 合成期 + stopPropagation。
// 1) isComposing / keyCode=229 守卫CJK 拼音期按 Esc 是「关闭候选词」而不是「关弹窗」。
// 旧版误关 → 用户的拼音/候选词全废、rename/new-note resolve(null) 静默吞掉。
// 与 focus-trap.js:67 / shortcuts.js:80 / prompt-dialog.js:148 保持一致。
// 2) e.stopPropagation()modal 关掉的同一帧 focus 回到 prevFocus搜索框
// event 继续冒泡到 documentshortcuts.js:75 看到 activeElement 是 #search-input
// 且 !modalOpen → 调 onClearSearch() 把用户搜索词一并清掉,与用户意图无关的副作用。
overlay.addEventListener('keydown', (e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
close(onBackdropClose);
}
}, { signal: abort.signal });
// [data-action] 按钮:× 按钮__modal_close__+ footer 自定义按钮。
// **不拦截 body 内的 [data-action]** —— 那是 dialog 自己的 in-body action
// 由 dialog 各自 bind 处理(例如 settings-dialog 的「显示/隐藏 API Key」「清空
// AI 配置」按钮)。之前无差别拦截所有 [data-action]body 内这些按钮会被
// onAction 误判为 modal-level 动作onAction 默认返回 'close' → 立即关 modal
// 点「显示」结果 modal 直接关掉UX 直接坏掉。
// 文档化契约data-action 是 modal-levelfooter/headerin-body 行为用别的属性。
overlay.addEventListener('click', (e) => {
const target = e.target.closest('[data-action]');
if (!target || !overlay.contains(target)) return;
const action = target.dataset.action;
if (action === '__modal_close__') {
close(onBackdropClose);
return;
}
// 只处理 footer 内的 data-actionbody 内的交给 dialog 自己的 listener
if (!footerEl.contains(target)) return;
if (!onAction) return;
// audit fix (Round 8 M-3)onAction 抛同步错时 modal 不会关footer 按钮
// 「死」了用户没法关窗。try/catch 后错误打 console模态保持打开让用户
// 看到具体错误dialog 自己负责 catch 业务错误;这里是兜底防御)。
//
// 审计修复 (Round 11)onAction 现在支持同步 / 异步两种形态。
// - 同步 throw → try/catch 接住
// - 同步 return 'close' → 同步路径直接 close(action)
// - 同步 return 其他 → 不关(与旧行为一致)
// - 异步 reject → Promise.resolve().catch() 接escape 到
// window.onunhandledrejectionmodal 永不关、footer 按钮「死」
// - 异步 resolve 'close' → .then(r => r === 'close' && close(action))
// 把同步 + 异步统一成一个 Promise 链,避免双重调用 onAction否则 rename
// / 清 Key 等会被执行两次)。
let result;
try {
result = onAction(action, controller);
} catch (e) {
console.error('[modal] dialog onAction 抛出异常:', action, e);
try {
const msg = (e && typeof e.message === 'string' && e.message) || '操作失败';
showToast(`操作失败:${msg}`, 'error', 2400);
} catch { /* toast 自身抛错就吞 */ }
return;
}
// 同步 'close' 直接关(保持旧路径零延迟);否则交给 Promise.resolve
// 处理(同步非 promise 值 / Promise 都接得住)。
if (result === 'close') {
close(action);
return;
}
Promise.resolve(result).then((r) => {
if (r === 'close') close(action);
}).catch((e) => {
console.error('[modal] dialog onAction 异步异常:', action, e);
try {
const msg = (e && typeof e.message === 'string' && e.message) || '操作失败';
showToast(`操作失败:${msg}`, 'error', 2400);
} catch { /* ignore */ }
});
}, { signal: abort.signal });
// 焦点陷阱focus-trap 内部已订阅 abort.signal 自动 dispose这里不需保留引用
// audit fix (Settings P3 / focus-trap fallback lift)fallbackFocus 由 caller
// 传入prevFocus 被外部重渲染/撕下 DOM 时焦点回这个锚点(见 focus-trap.js
// dispose 路径)。默认 null → focus-trap 内部回 document.body。
createFocusTrap(overlay, {
initialFocus: resolveInitialFocus(initialFocus, footerEl),
signal: abort.signal,
fallback: fallbackFocus,
});
const controller = {
/** overlay 根节点 */
overlay,
/** 内层 .modal */
modal: overlay.querySelector('.modal'),
/** 正文容器(可写入 innerHTML */
body: bodyEl,
/** footer 容器 */
footer: footerEl,
/** 显式关闭(带 value可由 onClose 拦截) */
close,
/** 替换正文 HTML */
setBody(html) { bodyEl.innerHTML = html; },
/** 替换 footer HTML */
setFooter(html) {
footerEl.innerHTML = html;
// 重新绑定 footer 按钮的事件已经在 overlay 上委托,无需重绑
},
/** 暴露 AbortController方便调用方挂自己的 listener 到同生命周期 */
abortSignal: abort.signal,
/**
* 注册关闭回调。每次 close(value) 时按注册顺序全部调用一次。
* 多次注册不会被覆盖 —— 调用方可以分别挂上「清理副作用」「resolve promise」
* 等独立关注点,而不必合并到一个回调里。
* @param {(value: string) => void} cb
*/
setOnClose(cb) { onCloseCallbacks.push(cb); },
};
return controller;
}
/**
* 解析 initialFocusHTMLElement 直接用;字符串 → querySelectorundefined →
* footer 第一个安全按钮。
*
* audit fix (H2-renderer):之前默认焦点是「最后一个按钮」—— 对 confirm-dialog
* 之类 [取消, 删除] / [取消, 确认] 布局,最右侧往往是 primary/danger按 Enter
* 就直接执行破坏性操作。改成「第一个 .btn-secondary安全的取消类没有再
* 回退到第一个按钮」:用户按 Enter 默认是安全的取消/返回,符合 WAI-ARIA
* "least destructive option" 原则;需要主动执行破坏性动作时再鼠标/Tab 选。
*
* @param {HTMLElement|string|undefined} initialFocus
* @param {HTMLElement} footerEl
* @returns {HTMLElement|null}
*/
function resolveInitialFocus(initialFocus, footerEl) {
if (initialFocus instanceof HTMLElement) return initialFocus;
if (typeof initialFocus === 'string') {
return document.querySelector(initialFocus);
}
if (footerEl) {
const safe = footerEl.querySelector('.btn.btn-secondary');
if (safe) return safe;
const btns = footerEl.querySelectorAll('.btn');
if (btns.length > 0) return btns[0];
}
return null;
}
/**
* @typedef {object} ModalController
* @property {HTMLElement} overlay
* @property {HTMLElement} modal
* @property {HTMLElement} body
* @property {HTMLElement} footer
* @property {(value: string) => void} close
* @property {(html: string) => void} setBody
* @property {(html: string) => void} setFooter
* @property {AbortSignal} abortSignal
* @property {(cb: (value: string) => void) => void} setOnClose
*/

242
src/prompt-dialog.js Normal file
View File

@@ -0,0 +1,242 @@
// 通用输入对话框 —— Electron 渲染端不支持 window.prompt() / window.alert()
// 我们基于 mountModal 自己搭一个。
//
// 用法(语义与 window.prompt() 完全一致,便于替换):
// const value = await showPromptDialog({
// title: '重命名',
// message: '重命名为(自动补 .md 后缀)', // 可选;放在输入框上方
// defaultValue: '当前名', // 可选;默认空串
// placeholder: '请输入…', // 可选
// inputLabel: '文件名', // 可选;屏幕阅读器朗读用
// confirmLabel: '确定', // 可选
// cancelLabel: '取消', // 可选
// selectAllOnOpen: true, // 可选;默认 true模仿 window.prompt()
// validate: (raw) => { // 可选;客户端预校验
// if (!raw.trim()) return '文件名不能为空';
// if (/[/\\]/.test(raw)) return '文件名不能包含路径分隔符';
// return null; // null = 通过
// },
// });
// if (value == null) return; // 用户取消 → null
// const trimmed = value.trim(); // 确认 → 原样字符串(含前后空格,由调用方决定是否 trim
//
// 设计要点:
// - 完全跑在 mountModal 上,复用 ARIA / focus-trap / Esc / overlay-click / 单槽冲突
// - Enter 提交(焦点在 input 内时Esc / × / 点背景 = 取消
// - 打开时全选默认文字(模仿 window.prompt() 行为,便于「重命名」场景直接覆盖)
// - mountModal 失败(#modal-root 缺失 / 已有 modal→ 退化到 window.prompt()
// (与 confirm-dialog.js 的兜底一致;理论上 Electron renderer 里没有 prompt
// 但保持接口形态一致)
// - 可选 validate返回 null = 通过;返回字符串 = 在 input 下方显示为错误,
// dialog 保持打开、不关闭。空字符串 = 通过(无错误信息)。
//
// 边界:
// - 只读 / 不写 ctx 之外的全局状态
// - 调用方负责校验空字符串、文件名合法性等
import { escapeHtml, escapeAttr } from './utils.js';
import { mountModal } from './modal.js';
// audit fix (Phase L2 #7)modal 被另一个对话框占位时给用户 toast避免
// 重要操作(重命名 / 新建文件)静默失败。
import { showToast } from './feedback.js';
const DEFAULT_CONFIRM_LABEL = '确定';
const DEFAULT_CANCEL_LABEL = '取消';
const INPUT_ID = 'prompt-dialog-input';
const ERROR_ID = 'prompt-dialog-error';
/**
* 显示一个输入对话框
* @param {object} options
* @param {string} options.title - 标题(必填)
* @param {string} [options.message] - 正文(可选;放在输入框上方)
* @param {string} [options.defaultValue=''] - 输入框初始值
* @param {string} [options.placeholder] - 输入框 placeholder
* @param {string} [options.inputLabel] - 输入框 label屏幕阅读器朗读视觉上仍可见
* @param {string} [options.confirmLabel='确定'] - 确认按钮文本
* @param {string} [options.cancelLabel='取消'] - 取消按钮文本
* @param {boolean} [options.selectAllOnOpen=true] - 打开时是否全选默认文字
* @param {(raw: string) => (string|null|undefined)} [options.validate] -
* 客户端预校验;返回 null/undefined = 通过;返回字符串 = 错误消息dialog 保持打开)
* @returns {Promise<string|null>} 用户输入的字符串;取消时返回 null
*/
export function showPromptDialog({
title,
message = '',
defaultValue = '',
placeholder = '',
inputLabel = '',
confirmLabel = DEFAULT_CONFIRM_LABEL,
cancelLabel = DEFAULT_CANCEL_LABEL,
selectAllOnOpen = true,
validate = null,
} = {}) {
return new Promise((resolve) => {
if (!title) {
// 没有标题 → 与 mountModal 行为一致地退化,避免静默卡住调用方
resolve(null);
return;
}
const messageHtml = message
? `<p class="confirm-message">${escapeHtml(message)}</p>`
: '';
// 审计修复 (Round 11 vol.2):输入框下方预留给 validate 错误信息;
// 初始为空无错误态validate 触发时填文案 + aria-invalid。
const bodyHtml = `
${messageHtml}
<div class="form-row">
<label class="form-label" for="${INPUT_ID}">${escapeHtml(inputLabel || title)}</label>
<input
id="${INPUT_ID}"
class="form-input"
type="text"
${placeholder ? `placeholder="${escapeAttr(placeholder)}"` : ''}
value="${escapeAttr(defaultValue)}"
maxlength="255"
autocomplete="off"
spellcheck="false"
aria-describedby="${ERROR_ID}"
>
<p id="${ERROR_ID}" class="form-error" role="alert" hidden></p>
</div>
`;
const footerHtml = `
<button class="btn btn-secondary" type="button" data-action="cancel">${escapeHtml(cancelLabel)}</button>
<button class="btn btn-primary" type="button" data-action="confirm">${escapeHtml(confirmLabel)}</button>
`;
// tryConfirm 必须先声明(即便 body 还没渲染)—— 因为 mountModal 的 onAction
// 回调是延迟触发的user 真正点按钮 / 按 Enter 时 inputEl / errorEl 都已就绪。
// 这里只放一个 forward declaration函数体和引用在 inputEl 之后才生效。
// audit fix (Phase L1-Settings P1)Enter 与按钮 click 必须走完全同一条
// validate 路径。早期版本 Enter 直接 modal.close('confirm') 跳过校验,
// 导致重命名场景输入非法名 → dialog 静默关闭 → IPC 拒、用户输入丢失。
let tryConfirm = () => undefined;
const modal = mountModal({
title,
body: bodyHtml,
footer: footerHtml,
// 初始焦点放到输入框focus-trap 在 queueMicrotask 里调 focus
initialFocus: `#${INPUT_ID}`,
// Esc / × / 点背景 = 取消(与浏览器原生 prompt 一致)
onBackdropClose: 'cancel',
onAction: (action) => {
// cancel 直接让 mountModal 关闭confirm 委托给 tryConfirm
// 由它决定「validate 失败保持打开」或「成功 close」。
if (action === 'cancel') return 'close';
if (action === 'confirm') {
// 返回 undefinedmountModal 不再负责关闭),因为 tryConfirm 内部
// 已经按需调用 modal.close('confirm')。
tryConfirm();
return undefined;
}
return undefined;
},
});
if (!modal) {
// mountModal 失败(#modal-root 缺失 / 已有 modal 占位)
// 注意:不能 fallback 到 window.prompt() —— Electron 渲染端 prompt() 会抛错
// 'prompt() is and will not be supported'),正好是我们要绕开的坑。
// 兜底行为当作取消处理resolve null并 toast 提示用户,让重要
// 操作(重命名 / 新建文件不会静默无反应audit L2 #7
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
resolve(null);
return;
}
const inputEl = modal.body.querySelector(`#${INPUT_ID}`);
const errorEl = modal.body.querySelector(`#${ERROR_ID}`);
if (!inputEl) {
// 极端兜底input 没渲染出来 —— 直接关掉并 resolve(null),不挂死调用方
modal.close('cancel');
resolve(null);
return;
}
// 错误显示/清除:单点维护行内错误 UI 行为aria-invalid + 文本 + hidden
function showError(msg) {
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.hidden = false;
inputEl.setAttribute('aria-invalid', 'true');
inputEl.classList.add('is-invalid');
}
function clearError() {
if (!errorEl) return;
errorEl.textContent = '';
errorEl.hidden = true;
inputEl.removeAttribute('aria-invalid');
inputEl.classList.remove('is-invalid');
}
// 用户每次输入都清错误 —— 让他们看到「错误已识别,现在改正」反馈。
// 守卫:在没 validate 的场景下也保留clearError 是 no-op 安全)。
inputEl.addEventListener('input', () => {
if (inputEl.getAttribute('aria-invalid') === 'true') clearError();
}, { signal: modal.abortSignal });
// 单一 resolve 出口所有关闭路径Cancel / × / Esc / 背景 / Confirm / Enter都走这里
modal.setOnClose((value) => {
// Promise resolve 是幂等的,重复调用安全
if (value === 'cancel') resolve(null);
else resolve(inputEl.value);
});
// IME composition 跟踪 —— CJK 输入法按 Enter 是「确认候选词」而非「提交表单」。
// 不跟踪会让正在打拼音的用户按 Enter 直接关闭弹窗,丢掉半截输入。
// 参考 src/ai/ai-chat-panel.js 的 _composing 模式(更严谨的 compositionstart/end
let composing = false;
inputEl.addEventListener('compositionstart', () => { composing = true; }, { signal: modal.abortSignal });
inputEl.addEventListener('compositionend', () => { composing = false; }, { signal: modal.abortSignal });
inputEl.addEventListener('blur', () => { composing = false; }, { signal: modal.abortSignal });
// 单一确认入口:按钮 click 与 Enter 键都路由到这里。
// audit fix (Phase L1-Settings P1):早期 Enter 直接 modal.close('confirm')
// 完全绕过 validate 校验。重命名场景输入非法名(路径分隔符 / 保留设备名)
// 按 Enter → dialog 静默关闭 → IPC 拒、用户 typed text 丢失、错误只通过
// 通用 toast 模糊暴露。现在 validate 失败时显示行内红字并保持打开,
// 与按钮 click 走完全同一条路径。
tryConfirm = () => {
// 客户端预校验Round 11 vol.2):返回 null/undefined = 通过;
// 返回字符串 = 错误消息dialog 保持打开)。
if (typeof validate === 'function') {
let errMsg = null;
try {
errMsg = validate(inputEl.value);
} catch (e) {
// validate 自身抛错 → 当成错误消息展示(不静默吞)
errMsg = (e && typeof e.message === 'string' && e.message) || '输入校验失败';
}
if (errMsg) {
showError(errMsg);
// input 焦点留在原位(用户改完再确认)
try { inputEl.focus({ preventScroll: true }); } catch { /* ignore */ }
return;
}
clearError();
}
modal.close('confirm');
};
// Enter 提交(焦点在 input 内时);与 form submit 等价但更直接。
// 守卫 e.isComposing + composing flag拼音 / 日语 IME 用 Enter 确认候选词时不应触发提交。
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing && !composing) {
e.preventDefault();
tryConfirm();
}
}, { signal: modal.abortSignal });
// 打开时全选默认文字(模仿 window.prompt() 行为)。
// focus-trap 内部已用 queueMicrotask 调 focus我们在它之后排一个 microtask 即可。
if (selectAllOnOpen && defaultValue) {
queueMicrotask(() => {
try { inputEl.select(); } catch { /* input 已不在 DOM */ }
});
}
});
}

46
src/save-button-ui.js Normal file
View File

@@ -0,0 +1,46 @@
// 保存按钮 UI 同步 —— 纯函数,与 src/app.js 解耦,便于单测。
//
// 设计:
// - 始终显示按钮(见 [[feedback-save-button-always-visible]]),靠 class / aria
// 表达三态:保存中 / 有待保存改动 / 干净。
// - 「保存中」用 savingCount 计数器(>0 即为保存中)—— 共享 inFlightSave 期间
// 多次进入 save() 不会因共享 then 路径无 finally 而闪烁;归零才真正熄灭。
// - 不设 disabled 属性 —— Ctrl+S 走 EditorView keymap与 DOM disabled 无关),
// 设了反而与 click / hover 反馈不一致。视觉提示用 cursor + opacity。
/**
* 把 state.isDirty / state.savingCount 同步到按钮的 class / aria-busy / aria-label / title
* 并把 isDirty 同步到状态栏「未保存」chiphidden + 文案)。
* 同时把 isDirty 通过 setDirtyFn 回调传出(用于 IPC 通知主进程)。
*
* @param {Object} args
* @param {HTMLElement|null} args.btnSave 保存按钮 DOM可为 null函数静默 return
* @param {HTMLElement|null} [args.statusUnsaved] 状态栏未保存 chip可选缺省不更新
* @param {Object} args.state renderer state用 isDirty / savingCount
* @param {(dirty: boolean) => any} [args.setDirty] 异步通知外部IPC缺省空操作
*/
export function syncSaveButton({ btnSave, statusUnsaved, state, setDirty = () => {} }) {
if (!btnSave) return;
const isSaving = state.savingCount > 0;
btnSave.classList.toggle('is-dirty', state.isDirty);
btnSave.classList.toggle('is-saving', isSaving);
btnSave.setAttribute('aria-busy', isSaving ? 'true' : 'false');
// 三态文案:保存中 > 有待保存改动 > 干净
const label = isSaving
? '保存中…'
: (state.isDirty ? '保存(有待保存的改动)' : '保存');
btnSave.setAttribute('aria-label', label);
btnSave.title = isSaving
? '保存中…'
: (state.isDirty ? '保存(有待保存的改动) (Ctrl+S)' : '保存 (Ctrl+S)');
// 状态栏「未保存」chippreview / 全屏等场景下,工具栏按钮可能被遮挡,
// 状态栏右下角的红点 chip 是更显眼的脏态指示。
// 只反映 isDirty —— is-saving 是过渡态,飞行结束会立刻切回脏/干净,
// 不需要重复在状态栏展示(避免一明一灭的视觉抖动)。
if (statusUnsaved) {
statusUnsaved.hidden = !state.isDirty;
statusUnsaved.textContent = '未保存';
statusUnsaved.setAttribute('aria-label', '当前文件有未保存的改动');
}
setDirty(state.isDirty);
}

83
src/save-toast.js Normal file
View File

@@ -0,0 +1,83 @@
// 保存路径 toast 决策Phase N Q-fix
//
// 把 save() 主路径与 shared path 都需要的 toast 决策抽成纯函数,方便单测
// 覆盖所有 silent × result 组合,避免两端 inline if/else 漂移。
//
// 历史问题save() IIFE 内部曾直接 `if (!silent) showToast('已保存')`
// shared path 复用 IIFE 返回值时用的是 IIFE 创建者的 silent —— 用户连按
// 两次 Ctrl+Sauto-save silent=true 飞行期 user silent=false 手动silent
// 标志被 IIFE 吞掉manual caller 收不到「已保存」反馈。重构后:
// - IIFE 内部不再弹 success toast统一返回 {ok, reason}
// - save() outer主路径 + shared path按自己的 silent 决定是否弹
// - shared path 还有 stale 场景(飞行期切文件 → IIFE 静默 return STALE
// silent=false manual caller 必须弹「文件已切换,未保存到磁盘」
//
// 错误 toastFILE_CHANGED_EXTERNALLY 各分支 / FILE_NOT_FOUND / PATH_NOT_ALLOWED
// / SYMLINK_NOT_ALLOWED / errno 兜底 / IPC_ERROR仍在 IIFE 各分支里按 result.error
// 弹,与 silent 无关 —— 所有 silent 都弹错误(用户必须感知失败)。本 helper
// 只管 success + STALE 两种 toast 决策。
//
// @param {boolean} silent
// @param {{ok:boolean, reason?:string}|null|undefined} result
// @param {(message: string, type: 'success'|'warning'|'error'|'info', duration: number) => void} showToast
// @returns {void}
/**
* 根据调用方 silent 标志 + IIFE 返回结果决定是否弹 toast。
*
* 决策矩阵:
* - silent=false + ok=true无 STALE / NO_CHANGES 标记) → 「已保存」(success, 2500ms)
* - silent=false + reason='STALE'(无论 ok → 「文件已切换,未保存到磁盘」(warning)
* Phase 2 audit 修复 (P1 #3)STALE 不再等同「写盘失败」。飞行期用户切
* 走时 writeFile 已 resolvedata 可能已落 targetPath。ok=true 让关窗路径
* 能正确退出用户视角的反馈manual Ctrl+S silent=false应该总是 STALE
* 提示(因为 editor 已不在 targetPath 上),文案保持中性「文件已切换,未
* 保存到磁盘」—— 如果用户想知道数据到底有没有存,去看 A 文件本身。
* - silent=false + reason='NO_CHANGES' → 「已是最新」(info, 1500ms)
* fix(audit 2026-08)auto-save 静默写盘后 state.isDirty=false用户手动
* 点保存按钮落入 !state.isDirty 早退路径,旧逻辑静默 return 让用户觉得
* 「按钮坏了 / 没保存上」—— 实际文件已最新。给一个简短 info 让点击有反馈。
* auto-save silent=true 路径不影响silent 早退)。
* - silent=false + reason='NO_FILE' → 「没有打开文件」(info, 1500ms)
* fix(audit Round 7 S-3):旧版与 NO_CHANGES 共用「已是最新」文案,但语义
* 错位 ——「已是最新」暗示「有文件且未改动」,用户视角「什么都没打开,
* 按 Ctrl+S 当然没东西存」需要准确的「没有打开文件」。auto-save silent
* 路径不影响。
* - silent=true → 啥都不弹auto-save / 关窗 silent=true 静默场景)
* - 其它失败 reasonCONFLICT_* / FILE_NOT_FOUND / PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED
* / WRITE_FAILED / IPC_ERROR→ 错误 toast 已在 IIFE 各分支按 result.error 弹过,
* 本 helper 不重复弹(避免误导性「保存失败(请重试)」覆盖 IIFE 已弹的具体原因)。
*
* 「已保存」duration 从 1200ms 提到 2500msfix audit 2026-081200ms 太快,
* 用户实际只看到约半秒淡出动画,根本来不及形成「保存完成」的认知,导致
* 「保存按钮失灵」类错觉反复出现。2500ms 是大多数 IDEVS Code / Sublime
* 「Saved」toast 的常用区间,足够看见。
*/
export function decideSaveToast(silent, result, showToast) {
if (silent) return;
if (!result) return;
if (result.reason === 'STALE') {
showToast('文件已切换,未保存到磁盘', 'warning', 2500);
return;
}
if (result.reason === 'NO_CHANGES') {
showToast('已是最新', 'info', 1500);
return;
}
if (result.reason === 'NO_FILE') {
// audit fix (Round 7 S-3):与 NO_CHANGES 区分。语义是「没有打开文件」
// 而不是「文件已是最新」,文案准确反映状态。
showToast('没有打开文件', 'info', 1500);
return;
}
if (result.reason === 'DELEGATED') {
// audit fix (Round 8 S-1)force_overwrite 把写盘委托给递归 inner save()
// inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责
// 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。
return;
}
if (result.ok) {
showToast('已保存', 'success', 2500);
}
// 其它失败 reasonIIFE 内部已按 result.error 弹过原因 toast这里不重复。
}

1002
src/settings-dialog.js Normal file

File diff suppressed because it is too large Load Diff

225
src/settings-store.js Normal file
View File

@@ -0,0 +1,225 @@
// 设置存储
// 封装主进程 IPC 的 getSettings / saveSettings提供本地缓存和 change 事件订阅。
//
// 持久化由主进程负责userData/config.json这里只做内存缓存 + 同步广播。
//
// 关键能力Stage 7 增强):
// - preUpdateHook写主进程前可拦截返回 false 取消(用于「未保存改动时改 dataDir」之类场景
// - 单调 token并发的 update 调用后到的响应不会覆盖后到的状态audit #9
import { EventEmitter } from './event-emitter.js';
// schema 经 preload 的 contextBridge 过桥,不能直接 import
// shared/settings-schema.js —— 那是 CommonJS而 renderer 是 Chromium
// 原生 ESMnodeIntegration:false没有 CJS 互操作import 会让整个应用起不来。
// preload 先于 renderer 模块求值,所以这里在顶层取值是安全的。
const DEFAULT_SETTINGS = (typeof window !== 'undefined'
&& window.api?.settingsSchema?.DEFAULT_SETTINGS) || {};
/**
* 归一化设置对象。桥不可用时退化为「默认值 + 已知键覆盖」。
* @param {*} raw
*/
function coerceLoadedSettings(raw) {
if (typeof window !== 'undefined' && typeof window.api?.coerceLoadedSettings === 'function') {
return window.api.coerceLoadedSettings(raw);
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...DEFAULT_SETTINGS };
const out = { ...DEFAULT_SETTINGS };
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (key in raw) out[key] = raw[key];
}
return out;
}
export class SettingsStore extends EventEmitter {
/**
* @param {object} api - window.api由 preload 注入的 contextBridge API
*/
constructor(api) {
super();
this.api = api;
this._settings = { ...DEFAULT_SETTINGS };
this._loaded = false;
// preUpdateHook: (partial, currentSettings) => boolean | Promise<boolean>
// 返回 false 取消 update不会写入内存也不会触发 IPC
// 用于 app.js 在 dataDir 变更时先检查编辑器是否有未保存改动audit #1
this._preUpdateHook = null;
// 单调递增 token每次 update 自增;响应回来时只接受 token 匹配的写入。
// 防止「A 提交 → B 提交 → B 响应先到 → A 响应后到覆盖 B 的状态」audit #9
this._updateToken = 0;
}
/**
* 注册 preUpdateHook。同一时刻只允许一个 hook后注册的覆盖前者。
* hook 抛错视作「拒绝更新」。
* @param {(partial: object, current: object) => boolean | Promise<boolean>} hook
*/
setPreUpdateHook(hook) {
this._preUpdateHook = typeof hook === 'function' ? hook : null;
}
/**
* 取当前注册的 preUpdateHook。返回函数或 null。
* 供需要在临时覆盖前先备份、用完再恢复的场景(例如 app.js 的 settings 对话框
* 临时挂一个 dataDir 脏检查的 hook关闭后还原
*/
getPreUpdateHook() {
return this._preUpdateHook;
}
/**
* 从主进程拉取设置到内存
*/
async load() {
if (!this.api || typeof this.api.getSettings !== 'function') {
console.warn('[settings-store] window.api.getSettings 不可用,使用默认值');
this._settings = { ...DEFAULT_SETTINGS };
this._loaded = true;
this.emit('change', { settings: this._settings });
return this._settings;
}
try {
const remote = await this.api.getSettings();
this._settings = coerceLoadedSettings(remote);
} catch (e) {
console.error('[settings-store] load 失败:', e);
this._settings = { ...DEFAULT_SETTINGS };
}
this._loaded = true;
this.emit('change', { settings: this._settings });
return this._settings;
}
/**
* 读取单个设置
*/
get(key) {
return this._settings[key];
}
/**
* 读取所有设置
*/
getAll() {
return { ...this._settings };
}
/**
* 部分更新设置(异步写入主进程)
*
* 策略:乐观更新 —— 先在本地应用,立刻 emit 'change' 通知监听者;
* 然后异步写主进程。**失败时回滚到写入前的值并再 emit 一次 'change'**
* 否则界面会停留在一个磁盘上并不存在的状态(比如显示着一个校验失败的 dataDir
*
* 并发安全:每次 update 取一个单调递增 token响应回来时若 token 已被新调用顶掉,
* 直接丢弃响应不在本地应用避免后到的旧响应覆盖新状态audit #9
*
* @param {object} partial
* @returns {Promise<object>} 持久化后的完整设置
* @throws {Error} 主进程校验失败(例如 dataDir 不存在、IPC 异常、或 preUpdateHook 拒绝
*/
async update(partial) {
if (!partial || typeof partial !== 'object') return this._settings;
// preUpdateHook 拦截audit #1
if (this._preUpdateHook) {
let proceed = true;
try {
proceed = await this._preUpdateHook(partial, this._settings);
} catch (e) {
console.error('[settings-store] preUpdateHook 抛错:', e);
proceed = false;
}
if (!proceed) {
throw new Error('SETTINGS_UPDATE_CANCELLED');
}
}
// 取 token并发的 update 会拿到不同 token旧的 IPC 响应会被丢弃
const myToken = ++this._updateToken;
// 立刻在本地应用(乐观更新)。
// L3 fix (audit):屏蔽 aiApiKey —— 真实值仍通过 api.saveSettings 发给主进程,
// 但 _settings 缓存不存真值。主进程 echo 的 settings 也是脱敏后的aiApiKey=''
// 写盘后 getAll() 也只返回 ''。这样乐观窗口期内IPC 飞行中)任何 read path
// getAll / emit 的 settings都拿不到真值。
// L9 fix (audit)emit 的 `changes` payload 也 redact aiApiKey —— 之前
// partial.aiApiKey 是明文发出去的,虽然当前唯一监听器 syncBehaviorPrefsUI
// 只读 theme/font 等无关字段,但防御性 redact 让「将来新增监听器不会
// 因为忘了过滤而拿到真值」。'***' 而不是 '',保留「字段被改过」的语义
// partial.aiApiKey === '' 不一定代表用户清空 Key也可能是 reveal 后又改)。
const before = { ...this._settings };
const cachedPartial = { ...partial };
const emitChanges = { ...partial };
if ('aiApiKey' in cachedPartial) {
cachedPartial.aiApiKey = ''; // 不缓存真值
emitChanges.aiApiKey = '***'; // 通知监听器「Key 字段有变」但不暴露值
}
this._settings = { ...this._settings, ...cachedPartial };
this.emit('change', { settings: this._settings, changes: emitChanges, previous: before });
if (!this.api || typeof this.api.saveSettings !== 'function') {
return this._settings;
}
const rollback = (reason) => {
// 只有当我们仍是「最新调用」时才回滚;否则让新调用的状态保留
if (myToken !== this._updateToken) return;
const failed = { ...this._settings };
this._settings = before;
// audit fix (H2-settings):原 emit 用 `changes: before`,那是「写之前」的
// 旧值快照,下游若按正常语义解读会把旧值当成新变更去应用。回滚的语义
// 应当是「刚才那批 partial 提交失败、状态已退回」—— changes 给 partial
// 试图落盘但失败的内容previous 给 failed被撤回的中间态并附
// rolledBack=true + reason 让监听器按异常路径分支处理。
//
// Phase N 修复redact aiApiKey —— partial 里可能含用户刚输入的真值 Key。
// emitChanges 已用 '***' 占位L9 fix这里同样把 partial 的 aiApiKey 抹掉,
// 防止监听器从 changes payload 里拿回真值。
const emitChanges = { ...partial };
if ('aiApiKey' in emitChanges) emitChanges.aiApiKey = '***';
this.emit('change', {
settings: this._settings,
changes: emitChanges,
previous: failed,
rolledBack: true,
reason,
});
};
let res;
try {
res = await this.api.saveSettings(partial);
} catch (e) {
console.error('[settings-store] update IPC 异常:', e);
rollback(e?.message);
throw e;
}
// 响应已过时:被新调用顶掉了;直接丢弃(不应用也不回滚,新调用有自己的 before
if (myToken !== this._updateToken) {
return this._settings;
}
// 主进程返回 { ok:false, error } 表示校验失败(如目录不存在)
if (res && res.ok === false) {
const message = res.error || '保存设置失败';
rollback(message);
throw new Error(message);
}
// 当前协议:{ ok:true, settings }
if (res && res.settings && typeof res.settings === 'object') {
this._settings = coerceLoadedSettings(res.settings);
}
return this._settings;
}
/**
* 是否已从主进程加载
*/
isLoaded() {
return this._loaded;
}
}

206
src/shortcuts.js Normal file
View File

@@ -0,0 +1,206 @@
// 全局快捷键
//
// 10 个常用快捷键(详见下文清单):
// - Ctrl/Cmd + F 聚焦文件搜索框(编辑器内让给 CM6 查找面板)
// - Ctrl/Cmd + S 保存当前编辑的文件(脏状态时)
// - Ctrl/Cmd + E 切换视图模式preview → edit → split
// - Ctrl/Cmd + N 新建文件
// - Ctrl/Cmd + , 打开设置
// - Ctrl/Cmd + R 重新加载界面(与主菜单 reload 等价)
// - Ctrl/Cmd + Shift+T 切换主题
// - Ctrl/Cmd + Shift+F 切换聚焦模式(隐藏工具栏/侧栏/状态栏)
// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6
// - Esc搜索框内 清空搜索
//
// 注意CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f在编辑器聚焦时
// 但事件仍会继续冒泡到 document —— 所以这里必须主动避让,否则
// 在编辑器里按 Ctrl+F 会「同时」打开 CM 搜索面板并把焦点抢到侧栏搜索框。
//
// 模态对话框打开期间(#modal-root 有 overlayCtrl+, / Ctrl+F / Ctrl+Shift+T
// 应当静默不触发,否则会把焦点抢走、或者顶掉现有 dialog详见 modal-stack.js
// 模态自己的 Esc → 关闭 在 dialog 内部已经独立处理。
// Ctrl+R 不抑制:模态挂死时这反而是逃生口。
import { isOpen as modalIsOpen } from './modal-stack.js';
/**
* 事件是否发生在 CodeMirror 编辑器内部。
* @param {Event} e
* @returns {boolean}
*/
function isInsideEditor(e) {
const target = e.target;
if (!target || typeof target.closest !== 'function') return false;
return !!target.closest('.cm-editor');
}
/**
* @param {object} handlers
* @param {() => void} handlers.onFocusSearch
* @param {() => void} handlers.onClearSearch
* @param {() => void} handlers.onSettings
* @param {() => void} handlers.onToggleTheme
* @param {() => void} handlers.onSave - Ctrl/Cmd+S
* @param {() => void} handlers.onToggleEditorMode - Ctrl/Cmd+E
* @param {() => void} [handlers.onNewFile] - Ctrl/Cmd+N
* @param {() => void} [handlers.onToggleFocusMode] - Ctrl/Cmd+Shift+F
* @param {() => void} [handlers.onToggleAi] - Ctrl/Cmd+Shift+AAI 修改面板)
* @param {() => void} [handlers.onReload] - Ctrl/Cmd+RREADME 之前提到但未注册——audit 补全)
*/
export function mountKeyboardShortcuts({
onFocusSearch,
onClearSearch,
onSettings,
onToggleTheme,
onSave,
onToggleEditorMode,
onNewFile,
onToggleFocusMode,
onToggleAi,
onReload,
}) {
// audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。
// 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount
// 会让 keydown listener 累积(每个按键触发 N 个回调);生产路径虽然只 mount
// 一次不致命,但 devtools 反复 Ctrl+R 重载 + HMR 场景下 listener 长期持有
// handlers 闭包。
const handler = (e) => {
const ctrl = e.ctrlKey || e.metaKey;
const modalOpen = modalIsOpen();
// Esc 清空搜索(仅当焦点在搜索框内,且不在模态内——模态自身监听 Esc
if (e.key === 'Escape' && document.activeElement?.id === 'search-input' && !modalOpen) {
// audit fix (Round 4 F3)CJK IME 合成期间按 Esc 想收候选窗口时document
// 仍可能 fire keydown。本分支若不拦会调用 onClearSearch 清空搜索词 + IME
// 半截输入一起丢。下方 Ctrl+letter 分支已有 isComposing/keyCode=229 守卫,
// Esc 分支同样加上保持对称。
if (e.isComposing || e.keyCode === 229) return;
if (onClearSearch) onClearSearch();
return;
}
if (!ctrl) return;
// C3 fix (audit)CJK IME 合成期间CJ / 中文拼音 / 日文 IME 等)浏览器
// 仍会 fire keydown 给 Ctrl+letter 组合,且 key 已是真实字符(如 'e')。
// 此时触发「切换视图模式 / 新建文件 / 切换 AI」会让用户
// 拼音还没敲完视图就跳了 —— 而且 editor 是 CM6 容器时,模式切换会
// destroy view合成中的 IME buffer 跟着丢。
// keyCode === 229 是历史 fallbackFirefox / 旧 Chromium 用 keyCode 标识
// composition 状态),与 isComposing 一起兜底。
if (e.isComposing || e.keyCode === 229) return;
const inEditor = isInsideEditor(e);
// 模态打开时禁用所有 Ctrl/Cmd 类快捷键,避免抢焦点或顶替对话框。
// 注意:编辑器内的 Ctrl+S/Ctrl+F 已经在 inEditor 分支里 return 了,
// 所以这里再补一个 modalOpen 不会影响 CM6 内部的快捷键。
if (modalOpen && !inEditor) {
// 但 Ctrl+S 仍允许 —— 在模态内按 Ctrl+S 不该有副作用
// 真正会被拦下的Ctrl+, / Ctrl+F / Ctrl+Shift+T / Ctrl+E / Ctrl+N / Ctrl+Shift+F / Ctrl+Shift+A
// 用 toLowerCase() 同时覆盖 Ctrl 与 Cmd按 Shift 时 key 已是 'F'/'T' 等大写
// —— 所以 'f' 一行就同时兜住 Ctrl+F 和 Ctrl+Shift+F不用再写一份 shiftKey 子句。
const k = e.key.toLowerCase();
// P3 fix用 Set 集中管理「modal 时需要吞掉的快捷键」白名单,
// 之前手写 6 个 k === X 链式条件,新增快捷键容易漏写一份。
// shiftKey-only 的 't' / 'a' 用 `${k}:shift` 作 key加 shift 后缀区分。
const SUPPRESS_KEYS = new Set([
',',
'f',
't:shift',
'e',
'n',
'a:shift',
// M1 fix (audit):模态打开时拦截 Ctrl+W —— 否则 Electron 默认行为
// 是直接关闭窗口(带「未保存」的 settings dialog 也一起没了)。
// 'w' 无 shift 修饰token 写法同 'f' / 'e' / 'n'。
'w',
]);
const token = e.shiftKey && (k === 't' || k === 'a') ? `${k}:shift` : k;
if (SUPPRESS_KEYS.has(token) && !e.altKey) {
e.preventDefault();
return;
}
}
// Ctrl/Cmd + F → 聚焦搜索(不带 Shift让位给 Ctrl+Shift+F = 聚焦模式)
// 编辑器内让给 CM6 的查找面板(文内查找显然比跳到文件搜索更符合预期)
if ((e.key === 'f' || e.key === 'F') && !e.shiftKey) {
if (inEditor) return;
e.preventDefault();
if (onFocusSearch) onFocusSearch();
return;
}
// Ctrl/Cmd + , → 设置
if (e.key === ',') {
e.preventDefault();
if (onSettings) onSettings();
return;
}
// Ctrl/Cmd + Shift + T → 切换主题
if ((e.key === 'T' || e.key === 't') && e.shiftKey) {
e.preventDefault();
if (onToggleTheme) onToggleTheme();
return;
}
// Ctrl/Cmd + S → 保存(无 shift 修饰,避免误触)
// 编辑器内 CM6 的 Prec.highest keymap 已经调过 onSave 了,
// 这里再调一次会触发两次写盘 + 两个 toast。
if ((e.key === 's' || e.key === 'S') && !e.shiftKey && !e.altKey) {
if (inEditor) return;
e.preventDefault();
if (onSave) onSave();
return;
}
// Ctrl/Cmd + E → 切换视图模式CM6 没占用,编辑器内也生效)
if ((e.key === 'e' || e.key === 'E') && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (onToggleEditorMode) onToggleEditorMode();
return;
}
// Ctrl/Cmd + N → 新建文件
// 注意Ctrl+N 是 Electron「新建窗口」的传统快捷键但本应用不提供多窗口
// 直接复用为「新建文件」更符合笔记应用直觉。
if ((e.key === 'n' || e.key === 'N') && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (onNewFile) onNewFile();
return;
}
// Ctrl/Cmd + Shift + F → 聚焦模式(隐藏工具栏/侧栏/状态栏,纯阅读)
// 与 Ctrl+F 区别开 —— 后者是文内查找(编辑器内)/聚焦文件搜索(其它位置)。
if ((e.key === 'F' || e.key === 'f') && e.shiftKey && !e.altKey) {
e.preventDefault();
if (onToggleFocusMode) onToggleFocusMode();
return;
}
// Ctrl/Cmd + Shift + A → 切换 AI 修改面板(与聚焦模式同用 Shift 修饰,
// 避开 Ctrl+S/E/N/F/T 等已占用位置)
// C2 fix (audit):编辑器聚焦时让位给 CM6 的 keymapShift+A 在编辑器里是
// "全选" 之外的扩展键位),避免与编辑器内选择行为冲突。
if ((e.key === 'A' || e.key === 'a') && e.shiftKey && !e.altKey) {
if (inEditor) return;
e.preventDefault();
if (onToggleAi) onToggleAi();
return;
}
// Ctrl/Cmd + R → 重新加载界面README 之前提到但未注册——audit 补全)。
// 与主菜单「重新加载」等价(两者都是 webContents.reload / location.reload
// 不走 modalOpen 抑制:模态挂死时用户可能就需要这个来强制刷新。
// 也不走 inEditor 抑制CM6 没占 Ctrl+R。
if ((e.key === 'r' || e.key === 'R') && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (onReload) onReload();
return;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}

260
src/splitter.js Normal file
View File

@@ -0,0 +1,260 @@
// 通用拖拽分割条工厂
// ============================================================================
//
// 起源Stage 4d 抽出src/app.js 里有三段几乎一模一样的 mountSplitter*
// - mountSplitter (editor ↔ viewer3480-3640)
// - mountSidebarSplitter (sidebar3652-3850)
// - mountAiSplitter (AI panel3865-3984)
//
// 三段共享的样板:
// - 鼠标 + 触屏 drag 状态机dragging / pointerId / startX / startWidth
// - cursor: col-resize + userSelect: none 切换
// - is-dragging class 切换
// - 命中区扩展(仅 sidebar / AI±HIT_RADIUS 像素也算命中)
// - document 级 mousemove / touchmove / mouseup / touchend / touchcancel
//
// 不共享的部分(由调用方注入):
// - 边界 clampeditor 走 frSpace 动态算sidebar/AI 走静态 [MIN, MAX]
// - 持久化splitRatio / sidebarWidth / aiWidth 三个字段
// - 命中区距离计算sidebar 缓存中线Phase M-H6AI 每次重读
//
// 设计:
// - createSplitter(opts) 接受 handle / setSize / getSize / clampSize / onDragEnd
// 等回调;工厂不耦合业务(不知道是 px 还是 ratio不知道写到 :root 还是
// .appShell保持单一职责。
// - 内部维护自己挂的 listener 列表dispose() 一次性解除 —— 调用方在 pagehide
// 兜底调一次,避免 15 个 document 级 listener 长期持有闭包。
// ============================================================================
/**
* @typedef {Object} HitZoneOpts
* @property {number} radius - 鼠标距 splitter 中线 ± radius 像素都算命中
* @property {() => boolean} [isAvailable] - 当前是否启用命中区AI 关闭时返回 false
* 默认 true
* @property {(coord: number) => number} distanceTo - 鼠标坐标 → 到 splitter 中线的距离
* sidebar 缓存中线版本由调用方实现,工厂不关心怎么算)
* @property {(inZone: boolean) => void} [onHover] - 进/出命中区的视觉反馈
* 默认:改 document.body.style.cursor仅 axis='x' → col-resize
*/
/**
* @typedef {Object} SplitterOpts
* @property {HTMLElement} handle - 拖拽条元素
* @property {'x' | 'y'} [axis='x'] - 'x' = 水平拖动改宽度;'y' = 垂直拖动改高度
* @property {() => number} getSize - 当前目标元素的尺寸px
* 拖动期间会读多次(每次 mousemove 都会读mount 时也会读一次确认初值
* @property {(px: number) => void} setSize - 把 px 写到目标CSS 变量 / inline style
* 调用方负责写哪儿(:root / .appShell / 元素自身)
* @property {(px: number) => number} clampSize - clamp 到合法范围(动态边界由调用方负责)
* @property {(px: number) => void} onDragEnd - 拖完调一次,参数是已 clamp 的 px
* 调用方负责持久化 / 反算 ratio / 弹错误提示
* @property {() => number} [initialSize] - 启动初始化。mount 时会用 setSize 应用一次
* @property {HTMLElement} [body] - 设 cursor/userSelect 的元素,默认 document.body
* @property {HitZoneOpts} [hitZone] - 扩展命中区(不传 = 不启用)
* @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners]
* 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。
* dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。
*/
/**
* 创建通用拖拽分割条。
*
* 鼠标 / 触屏 拖拽的语义与原三段实现一致:
* - mouse / touch 都能起拖;触屏用 touch.identifier 关联 touchmove/touchend
* - 拖动期间document.body.style.cursor = 'col-resize' 或 'row-resize'
* userSelect = 'none';拖完恢复
* - 拖完一次:调 onDragEnd(clampSize(getSize())),由调用方持久化
*
* 命中区扩展hitZone语义
* - document 级 capture mousedown鼠标在 ±radius 内且目标不是 handle 自己,
* 启动拖动capture 阶段确保在 file-list / editor 等之前拿到事件)
* - document 级 mousemove拖动中按 axis 改尺寸;空闲时按 distanceTo 算 inZone
* 通知 onHover 做视觉反馈(默认 cursor
* - 调用方传 isAvailable 返回 false 时整个命中区逻辑跳过AI 关闭时)
*
* @param {SplitterOpts} opts
* @returns {{
* dispose: () => void,
* rebalance?: () => void,
* }}
* dispose 解除挂载的所有 listenerdocument 级 + handle 级 + window 级)
* rebalance 仅当传了 initialSize 时返回 —— 调用方在 window resize / layout 变化
* 时调,按最新 frSpace / DPI 重算尺寸(不影响持久化值)
*/
export function createSplitter(opts) {
const {
handle,
axis = 'x',
getSize,
setSize,
clampSize,
onDragEnd,
initialSize,
body = (typeof document !== 'undefined' ? document.body : null),
hitZone = null,
windowListeners = null,
} = opts;
if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素');
if (typeof getSize !== 'function') throw new Error('[splitter] getSize 必须是函数');
if (typeof setSize !== 'function') throw new Error('[splitter] setSize 必须是函数');
if (typeof clampSize !== 'function') throw new Error('[splitter] clampSize 必须是函数');
if (typeof onDragEnd !== 'function') throw new Error('[splitter] onDragEnd 必须是函数');
const cursor = axis === 'x' ? 'col-resize' : 'row-resize';
// axis 对应的 clientX/Y 字段名
const coordKey = axis === 'x' ? 'clientX' : 'clientY';
// 触屏坐标取 touches[i[]. 的 clientX/Y命名与 coordKey 一致
/** 当前挂的所有 listenerdispose 时一次性解除 */
const listeners = [];
function addListener(target, type, listener, options) {
target.addEventListener(type, listener, options);
listeners.push({ target, type, listener, options });
}
// 拖拽状态机 —— 与原三段实现语义一致
let dragging = false;
let pointerId = null;
let startCoord = 0;
let startSize = 0;
function coordOf(eventLike) {
return eventLike[coordKey];
}
function startDrag(coord, id) {
dragging = true;
pointerId = id;
handle.classList.add('is-dragging');
startCoord = coord;
startSize = getSize();
if (body) {
body.style.cursor = cursor;
body.style.userSelect = 'none';
}
}
function applyDrag(coord) {
const dCoord = coord - startCoord;
setSize(clampSize(startSize + dCoord));
}
function endDrag() {
if (!dragging) return;
dragging = false;
pointerId = null;
handle.classList.remove('is-dragging');
if (body) {
body.style.cursor = '';
body.style.userSelect = '';
}
onDragEnd(clampSize(getSize()));
}
// ----- 自身 handle 上的 mousedown / touchstart -----
addListener(handle, 'mousedown', (e) => {
startDrag(coordOf(e), null);
e.preventDefault();
});
addListener(handle, 'touchstart', (e) => {
const t = e.touches[0];
if (!t) return;
startDrag(coordOf(t), t.identifier);
}, { passive: true });
// ----- document 级 mousemove / touchmove / ----end -----
addListener(document, 'mousemove', (e) => {
if (!dragging || pointerId !== null) return;
applyDrag(coordOf(e));
});
addListener(document, 'touchmove', (e) => {
if (!dragging || pointerId === null) return;
const t = Array.from(e.touches).find((x) => x.identifier === pointerId);
if (!t) return;
applyDrag(coordOf(t));
}, { passive: true });
addListener(document, 'mouseup', endDrag);
addListener(document, 'touchend', endDrag);
addListener(document, 'touchcancel', endDrag);
// ----- 命中区扩展(可选)-----
if (hitZone) {
const { radius, isAvailable = () => true, distanceTo, onHover } = hitZone;
// document 级 capture mousedown捕获阶段拿到事件避免被 file-list 等吞掉
addListener(document, 'mousedown', (e) => {
if (dragging) return;
if (e.button !== 0) return; // 只接左键
if (!isAvailable()) return;
// handle 自己 / handle 内子节点(含 reset 按钮等)不重入 —— 子节点自己
// 处理 mousedown一般带 stopPropagation否则会出现「点了按钮又拖动」。
if (handle.contains(e.target)) return;
if (distanceTo(coordOf(e)) > radius) return;
e.preventDefault();
e.stopPropagation();
startDrag(coordOf(e), null);
}, true);
// 拖动中 + 空闲命中区视觉反馈(合并到一个监听器,少派发一次)
let lastHover = null;
addListener(document, 'mousemove', (e) => {
if (dragging) {
if (pointerId === null) applyDrag(coordOf(e));
return;
}
if (!isAvailable()) return;
const inZone = distanceTo(coordOf(e)) <= radius;
if (inZone !== lastHover) {
lastHover = inZone;
if (onHover) onHover(inZone);
else if (body) body.style.cursor = inZone ? cursor : '';
}
});
}
// ----- 扩展 window 监听器(可选)-----
// 例如 sidebar splitter 用 resize / scroll 失效命中区中线缓存。
// 走 addListener 同一张表dispose() 一次性解除。
if (windowListeners && windowListeners.length) {
for (const { type, listener, options } of windowListeners) {
addListener(window, type, listener, options);
}
}
// ----- 启动初始化 -----
if (typeof initialSize === 'function') {
setSize(clampSize(initialSize()));
}
// ----- 返回 -----
const result = {
/** 解除所有挂载的 listener。可重复调用。 */
dispose() {
for (const { target, type, listener, options } of listeners) {
try { target.removeEventListener(type, listener, options); } catch { /* ignore */ }
}
listeners.length = 0;
if (body) {
body.style.cursor = '';
body.style.userSelect = '';
}
handle.classList.remove('is-dragging');
},
};
// 仅当调用方传 initialSize 时才暴露 rebalance —— 让"窗口 resize 时按 frSpace 重算"
// 这类外部触发走单点真理(避免多个 mountSplitter 都各自挂 resize 监听器)。
if (typeof initialSize === 'function') {
result.rebalance = () => {
if (dragging) return;
setSize(clampSize(initialSize()));
};
}
return result;
}

76
src/stats.js Normal file
View File

@@ -0,0 +1,76 @@
// 文本统计 —— 词数 / 行数 / 字符数 的单一来源
//
// 单一来源原因:
// 之前 editor.getStats() 走 view.state.doc.linesCM6 逻辑行viewer.getStats()
// 走 source.split('\n').length —— 同一份内容在 preview / edit 模式下状态栏
// 「N 行」会跳变(例如尾部空行 / 长内容折行)。这里把行数算法收敛为:
// - 源文本 = source 给的(无论从 CM6 还是从 viewer 缓存)
// - 行数 = 非空行数(至少 1
//
// CJK 分词算法保留 utils.countWords 的版本(已迭代到正确版本):
// - 中文 / 日文 / 韩文字符按字符计
// - 拉丁 / 数字按空白分词后再过滤纯标点
// - 不重叠计数
/**
* 统计 CJK 友好的词数。
* 实现细节见 utils.countWords语义相同只是换了模块
*
* @param {string} text
* @returns {number}
*/
export function countWords(text) {
if (!text || typeof text !== 'string') return 0;
const trimmed = text.trim();
if (!trimmed) return 0;
const cjkRe = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
const cjkCount = (trimmed.match(cjkRe) || []).length;
const latinPart = trimmed.replace(cjkRe, ' ');
const latinCount = (latinPart.match(/\S+/g) || [])
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
return cjkCount + latinCount;
}
/**
* 统一统计:基于「源文档」字符串同时给出 chars/words/lines。
*
* - chars = source.length不含末尾 trimmingCM6 doc.toString() 不含末尾换行)
* - words = countWords(source)CJK 单字 + 拉丁词)
* - lines = 非空行数(至少 1纯空白内容 = 1 行)
*
* 用 source 而不是 bodyElement.textContent
* 切预览 / 编辑时状态栏「N 行」不会跳变。textContent 是渲染后的纯文本,
* 与 markdown 源(特别是 HTML / 列表 / 代码块)数量上不同。
*
* @param {string} source
* @returns {{chars:number, words:number, lines:number}}
*/
export function getTextStats(source) {
const text = typeof source === 'string' ? source : '';
if (!text) return { chars: 0, words: 0, lines: 0 };
// 非空行数split 出所有行,去掉纯空白,再取长度。
// 纯空白内容(只有 \n / 空格)→ 0 行,让 formatStatusStats 走「0 词」分支。
// 之前用 `|| 1` 把「空」和「刚好 1 行内容」都映射成 1新打开的空白文件
// 状态栏立刻显示「1 行」误导用户以为已有内容audit L8
const lines = text.split('\n').filter((ln) => ln.trim().length > 0).length;
return {
chars: text.length,
words: countWords(text),
lines,
};
}
/**
* 渲染状态栏 chip 文案。
* - 0 行 / 0 词时不显示(由 caller 决定)
* - 单文件被删或为空 → `${words} 词`
* - 正常 → `${words} 词 · ${lines} 行`
*
* @param {{words:number, lines:number}} stats
* @returns {string}
*/
export function formatStatusStats(stats) {
const words = (stats.words || 0).toLocaleString('zh-CN');
const lines = stats.lines || 0;
return lines > 0 ? `${words} 词 · ${lines}` : `${words}`;
}

18
src/styles.css Normal file
View File

@@ -0,0 +1,18 @@
/* ===========================================================================
Notes — 样式入口Stage 4a分片
通过 @import 按顺序串联 01..12。
浏览器原生 CSS @import 性能成本可忽略12 个文件并行或合并);本应用零打包。
=========================================================================== */
@import url('./styles/01-tokens.css');
@import url('./styles/02-palettes.css');
@import url('./styles/03-base.css');
@import url('./styles/04-toolbar.css');
@import url('./styles/05-sidebar.css');
@import url('./styles/06-markdown.css');
@import url('./styles/07-statusbar.css');
@import url('./styles/08-modal.css');
@import url('./styles/09-misc.css');
@import url('./styles/10-a11y.css');
@import url('./styles/11-ai.css');
@import url('./styles/12-print.css');

105
src/styles/01-tokens.css Normal file
View File

@@ -0,0 +1,105 @@
/* ===========================================================================
Design Tokens
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ===========================================================================
Notes — 全局样式
设计理念:现代简约 + 排版优先
- 设计令牌:间距、字号、颜色、阴影、动画曲线统一管理
- 浅色 / 深色主题:暖色调低饱和度,长时间阅读舒适
- 排版:行高 1.85、字号 16px、优化的中英文字体栈
- 组件:浮动卡片、柔和阴影、克制的圆角
=========================================================================== */
/* ---------- Design Tokens ---------- */
:root {
/* 间距系统(基于 4 的倍数) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-9: 48px;
/* 圆角 */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px; /* 仅 .modal 消费 (styles.css:1892) */
/* 字号(基于 1.2 比例)
--fs-base 14 → 15UI 文字比 markdown 正文小一档会看着发「瘦」;
提到 15px 后配合 font-weight: 500 可读性显著提升,但只 +1px
不破坏现有 52px 工具栏 / 38px 状态栏的纵向布局 */
--fs-xs: 11px;
--fs-xs-readable: 12px; /* 11px 在低 DPI/小屏幕上易"糊"settings 对话框内的 hint / 副标题用它 */
--fs-sm: 13px;
--fs-base: 15px;
--fs-lg: 17px;
--fs-xl: 20px;
--fs-2xl: 24px;
--fs-4xl: 38px;
/* 行高 */
--lh-snug: 1.4;
--lh-normal: 1.55;
--lh-relaxed: 1.75;
--lh-loose: 1.85;
/* 字距 */
--tracking-tight: -0.01em;
--tracking-wide: 0.02em;
/* 字体栈:英文优先 Inter中文用系统字体保性能 */
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Helvetica Neue", Helvetica, Arial, sans-serif;
/* 阅读字体(用于 markdown 正文CJK 优先 */
--font-reading:
"Source Serif Pro", "Noto Serif CJK SC", "Source Han Serif SC",
"Songti SC", "STSong", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", serif;
/* 等宽字体JetBrains Mono 优先,回落到系统等宽 */
--font-mono:
"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular,
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* 高度 */
--h-toolbar: 52px;
--h-statusbar: 38px;
--w-sidebar: 220px;
--sidebar-width: var(--w-sidebar);
--w-sidebar-narrow: 200px;
--w-reader-max: 720px;
/* 阅读排版:默认值与现有 --fs-lg / --lh-loose 同步;
在 <html> 行内 style 上由 renderer 动态覆盖,切主题不会重置 */
--reader-font-size: var(--fs-lg);
--reader-line-height: var(--lh-loose);
/* Z-index */
--z-sidebar: 5;
--z-toolbar: 10;
--z-statusbar: 10;
--z-modal: 1000;
--z-toast: 1100;
--z-boot-error: 1200; /* 比 toast 高一档,确保启动错误盖在所有内容之上 */
/* 过渡曲线 */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--transition-fast: 120ms var(--ease-out);
--transition-normal: 200ms var(--ease-out);
--transition-slow: 320ms var(--ease-out);
/* 遮罩modal / drawer / popover 背后的暗化层) —— 与主题正交,
light 主题用更轻的 alpha否则会把 modal 卡片压成灰底破坏文字对比 */
--scrim: rgba(0, 0, 0, 0.5);
--scrim-soft: rgba(0, 0, 0, 0.12);
}

500
src/styles/02-palettes.css Normal file
View File

@@ -0,0 +1,500 @@
/* ===========================================================================
Palettes × Themes —— 收敛表达
Stage 4a.2:用「主题骨架 + palette 差异补丁」替代原 10 块字符级复制
设计思路:
所有 palette × theme 共享同一组 token 表达bg-hover / bg-active / 阴影
/ scrollbar / 边框等),但每个 palette 在每个 theme 下各自有自己的
视觉个性accent 色、带色相的 bg / fg
所以分两层:
1. 主题骨架(深 / 浅):定义所有「主题相关但 palette 无感」的 token。
主题骨架只装「公共部分」,不装任何 palette 个性。
2. palette 差异补丁:每块只覆盖该 palette 的 accent 系列 + 带色相的
bg/fg/text + markdown 配色 + toolbar-divider。其余 token 走主题骨架。
选择器特异性:
- [data-theme="dark"] (0,1,1)
- [data-theme-palette="X"][data-theme="Y"] (0,2,0)
后者更高,所以 palette 块能覆盖主题骨架。
=========================================================================== */
/* ---------- Dark Theme Skeleton ----------
暗色通用 token —— 5 个 palette 的暗色版共享这些。
palette 块只覆盖带色相部分accent + 自身 bg/fg/md-*)。
*/
[data-theme="dark"] {
/* 背景交互态:所有暗色 palette 都用白叠加 */
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-active: rgba(255, 255, 255, 0.08);
/* 边框:所有暗色 palette 浅边用白叠加、强边用更深的白 */
--border-subtle: rgba(255, 255, 255, 0.04);
--border-strong: rgba(255, 255, 255, 0.12);
/* 滚动条 */
--scrollbar-thumb: rgba(255, 255, 255, 0.10);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.20);
/* 阴影:暗色下更深 */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.32), 0 2px 4px rgba(0, 0, 0, 0.16);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.42), 0 4px 8px rgba(0, 0, 0, 0.25);
/* --shadow-glow 由 palette 设(带 accent-soft */
/* 语义色:所有暗色 palette 统一
audit fix (H4/H5)--danger 由 #ff6b6b → #d23636white-on-bg 对比从 2.2:1
提到 4.86:1AA 通过。btn-danger / error chip 都用这个 token统一收敛。 */
--danger: #d23636;
--btn-on-danger: #ffffff;
--warning: #ffb454;
--success: #5ad19a;
/* Markdown 通用(不带色相):所有暗色 palette 都用白叠加 */
--md-table-border: rgba(255, 255, 255, 0.08);
--md-table-stripe: rgba(255, 255, 255, 0.02);
--md-table-header-bg: rgba(255, 255, 255, 0.04);
--md-hr: rgba(255, 255, 255, 0.08);
/* --md-blockquote-* / --md-inline-code-bg / --md-code-bg / --md-link 由 palette 设 */
--md-heading: var(--fg-primary);
/* ── 文字版语义色 ───────────────────────────────────────────────
用途:状态栏 chip 字、AI 错误小字、表单 hint、设置卡 selected label
等 11-14px 的有色正文。装饰色warning / danger / success / accent
为图标、边框、圆点、单字符AI diff +/-)设计,直接套到正文字符上
会因高彩度小字"振动"看着累。这里取 60-65% 装饰色 + 35-40%
fg-primary 调和:色相仍可识别(暖橙 / 红 / 绿 / 蓝调),但 chroma
降到不会刺眼的程度。
比例选择:
dark 用 60% 装饰色 —— 深底 fg-primary 偏白,混出"暖白"效果。
light 用 70% 装饰色 —— 浅底 fg-primary 偏黑,需要更高的色比保证
文字可见度70% 仍比 100% 纯色温和一档。
边界borders / dots / icons / 单字符 +/- 继续用纯 *(不退色),
详见各组件 CSS 注释。 */
--warning-text: color-mix(in srgb, var(--warning) 60%, var(--fg-primary));
--danger-text: color-mix(in srgb, var(--danger) 60%, var(--fg-primary));
--accent-text: color-mix(in srgb, var(--accent) 65%, var(--fg-primary));
}
/* ---------- Light Theme Skeleton ---------- */
[data-theme="light"] {
--bg-hover: rgba(0, 0, 0, 0.04);
--bg-active: rgba(0, 0, 0, 0.07);
--border-subtle: rgba(0, 0, 0, 0.04);
--border-strong: rgba(0, 0, 0, 0.14);
--scrollbar-thumb: rgba(0, 0, 0, 0.14);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.26);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.035), 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.035);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.09), 0 4px 12px rgba(0, 0, 0, 0.05);
/* --shadow-glow 由 palette 设(带 accent-soft */
/* audit fix (H4/H5)light --danger 由 #e54848 → #c52424white-on-bg 对比从
3.93:1 提到 5.84:1AA 通过)。--warning / --success 本身对比足够,未动。 */
--danger: #c52424;
--btn-on-danger: #ffffff;
--warning: #d97706;
--success: #10b981;
--md-table-border: rgba(0, 0, 0, 0.08);
--md-table-stripe: rgba(0, 0, 0, 0.02);
--md-table-header-bg: rgba(0, 0, 0, 0.03);
--md-hr: rgba(0, 0, 0, 0.08);
--md-heading: var(--fg-primary);
/* 文字版语义色(与 dark 段对应注释。light 底 fg-primary 偏黑,
用 70% 装饰色让色相仍清晰,但比纯色温和:避免红字"扎眼"、橙字"泛酸"。 */
--warning-text: color-mix(in srgb, var(--warning) 70%, var(--fg-primary));
--danger-text: color-mix(in srgb, var(--danger) 70%, var(--fg-primary));
--accent-text: color-mix(in srgb, var(--accent) 70%, var(--fg-primary));
}
/* ===========================================================================
Palette overrides —— 每个块只覆盖该 palette 个性化的 token
=========================================================================== */
/* ---------- Default (Linear 风格:近黑中性底 + indigo) ----------
兜底body 上没有 data-theme-palette 时也应用 default兼容未挂属性场景。 */
body:not([data-theme-palette]),
[data-theme-palette="default"][data-theme="dark"] {
--bg-app: #08090a;
--bg-toolbar: rgba(8, 9, 10, 0.85);
--bg-sidebar: rgba(255, 255, 255, 0.022);
--bg-viewer: #08090a;
--bg-elevated: #16171a;
--bg-card: #101113;
--bg-card-hover: #191a1d;
--fg-primary: #f7f8f8;
--fg-secondary: #b0b6c0;
--fg-tertiary: #8e95a1;
--fg-muted: #6c7178;
--border-default: rgba(255, 255, 255, 0.06);
/* audit fix (H4/H5)--accent 由 #7883e0 → #4f5ec7white-on-bg 对比从 3.46:1
提到 5.6:1AA 通过)。其它 indigo 调色板都是浅 accent 配深色字(--btn-on-accent
= #1a232e已经满足 AA无需调。 */
--accent: #4f5ec7;
--btn-on-accent: #ffffff;
--accent-hover: #6473d3;
--accent-soft: rgba(79, 94, 199, 0.22);
--accent-soft-hover: rgba(79, 94, 199, 0.28);
--accent-selection: rgba(79, 94, 199, 0.50);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #101113;
--md-code-text: #e6e9f1;
--md-inline-code-bg: rgba(120, 131, 224, 0.20);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
/* indigo 在近黑底只有 4.2:1链接调亮一档2026-08 进一步提亮到 ~7:1 */
--md-link: #a3aaef;
--md-link-hover: #b8bef4;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(255, 255, 255, 0.08) 50%, transparent 100%);
}
[data-theme-palette="default"][data-theme="light"] {
--bg-app: #fbfbfc;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #f4f5f8;
--bg-viewer: #fbfbfc;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f7f8f9;
--fg-primary: #282a30;
--fg-secondary: #525968;
--fg-tertiary: #676e7d;
--fg-muted: #767b83;
--border-default: rgba(0, 0, 0, 0.07);
--accent: #5e6ad2;
--btn-on-accent: #ffffff;
--accent-hover: #4d58bd;
--accent-soft: rgba(94, 106, 210, 0.12);
--accent-soft-hover: rgba(94, 106, 210, 0.16);
--accent-selection: rgba(94, 106, 210, 0.28);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #f4f5f8;
--md-code-text: #282a30;
--md-inline-code-bg: rgba(94, 106, 210, 0.10);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
/* light 主题的 --md-link 用比 accent 更深的色modal 卡片底色浅(#eceefa / #e2eff5 / #e6f1ea / #faeae7
直接用 accent 只剩 3.5-4.0:1不达 WCAG AA4.5:1。这里给每个 light 调色板独立加深。 */
--md-link: #4244a8;
--md-link-hover: #2f3082;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(0, 0, 0, 0.08) 50%, transparent 100%);
}
/* ---------- Ocean (深海军 + 青绿) ---------- */
[data-theme-palette="ocean"][data-theme="dark"] {
--bg-app: #0b141d;
--bg-toolbar: rgba(11, 20, 29, 0.85);
--bg-sidebar: rgba(180, 210, 235, 0.04);
--bg-viewer: #0b141d;
--bg-elevated: #131e2a;
--bg-card: #16212e;
--bg-card-hover: #1c2837;
--fg-primary: #e3ecf3;
--fg-secondary: #c4d2dc;
--fg-tertiary: #96b1c6;
--fg-muted: #6c8090;
--border-default: rgba(180, 210, 235, 0.08);
--accent: #6cc5e6;
--btn-on-accent: #1a232e;
--accent-hover: #85d2ee;
--accent-soft: rgba(108, 197, 230, 0.20);
--accent-soft-hover: rgba(108, 197, 230, 0.26);
--accent-selection: rgba(108, 197, 230, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #111923;
--md-code-text: #cbd6e0;
--md-inline-code-bg: rgba(108, 197, 230, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #85d2ee;
--md-link-hover: #9cdcf2;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(180, 210, 235, 0.08) 50%, transparent 100%);
}
[data-theme-palette="ocean"][data-theme="light"] {
--bg-app: #eef4f8;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #e5eef5;
--bg-viewer: #eef4f8;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #eef4f8;
--fg-primary: #0f1d2a;
--fg-secondary: #34465a;
--fg-tertiary: #4f697a;
--fg-muted: #6a7d8d;
--border-default: rgba(15, 40, 70, 0.08);
--accent: #0e7da8;
--btn-on-accent: #ffffff;
--accent-hover: #0a688d;
--accent-soft: rgba(14, 125, 168, 0.12);
--accent-soft-hover: rgba(14, 125, 168, 0.16);
--accent-selection: rgba(14, 125, 168, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #e7eef4;
--md-code-text: #0f1d2a;
--md-inline-code-bg: rgba(14, 125, 168, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #084060;
--md-link-hover: #05304a;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(15, 40, 70, 0.08) 50%, transparent 100%);
}
/* ---------- Forest (松黑 + 翠玉) ---------- */
[data-theme-palette="forest"][data-theme="dark"] {
--bg-app: #0e1410;
--bg-toolbar: rgba(14, 20, 16, 0.85);
--bg-sidebar: rgba(180, 210, 190, 0.04);
--bg-viewer: #0e1410;
--bg-elevated: #16201a;
--bg-card: #19251e;
--bg-card-hover: #1f2d26;
--fg-primary: #e6efe8;
--fg-secondary: #c4d4ca;
--fg-tertiary: #9eb3a4;
--fg-muted: #6c7d72;
--border-default: rgba(180, 210, 190, 0.08);
--accent: #76c78d;
--btn-on-accent: #1a232e;
--accent-hover: #8dd4a3;
--accent-soft: rgba(118, 199, 141, 0.20);
--accent-soft-hover: rgba(118, 199, 141, 0.26);
--accent-selection: rgba(118, 199, 141, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #141c16;
--md-code-text: #cad7ce;
--md-inline-code-bg: rgba(118, 199, 141, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #8dd4a3;
--md-link-hover: #a4e0b9;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(180, 210, 190, 0.08) 50%, transparent 100%);
}
[data-theme-palette="forest"][data-theme="light"] {
--bg-app: #f0f5f1;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #e6efe9;
--bg-viewer: #f0f5f1;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f0f5f1;
--fg-primary: #102018;
--fg-secondary: #34483c;
--fg-tertiary: #4f6356;
--fg-muted: #6d7e73;
--border-default: rgba(20, 55, 30, 0.08);
--accent: #2c8c52;
--btn-on-accent: #000000;
--accent-hover: #227544;
--accent-soft: rgba(44, 140, 82, 0.12);
--accent-soft-hover: rgba(44, 140, 82, 0.16);
--accent-selection: rgba(44, 140, 82, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #e8efe9;
--md-code-text: #102018;
--md-inline-code-bg: rgba(44, 140, 82, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #1a5a30;
--md-link-hover: #0f4220;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(20, 55, 30, 0.08) 50%, transparent 100%);
}
/* ---------- Lavender (茄紫 + 粉紫) ---------- */
[data-theme-palette="lavender"][data-theme="dark"] {
--bg-app: #14101a;
--bg-toolbar: rgba(20, 16, 26, 0.85);
--bg-sidebar: rgba(200, 180, 220, 0.04);
--bg-viewer: #14101a;
--bg-elevated: #1d1726;
--bg-card: #211a2c;
--bg-card-hover: #282034;
--fg-primary: #ece6f2;
--fg-secondary: #d0c5dc;
--fg-tertiary: #a99cc0;
--fg-muted: #76688c;
--border-default: rgba(200, 180, 220, 0.08);
--accent: #c89ce8;
--btn-on-accent: #1a232e;
--accent-hover: #d4abee;
--accent-soft: rgba(200, 156, 232, 0.20);
--accent-soft-hover: rgba(200, 156, 232, 0.26);
--accent-selection: rgba(200, 156, 232, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #1a1423;
--md-code-text: #d3cce0;
--md-inline-code-bg: rgba(200, 156, 232, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #d4abee;
--md-link-hover: #e0baf3;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(200, 180, 220, 0.08) 50%, transparent 100%);
}
[data-theme-palette="lavender"][data-theme="light"] {
--bg-app: #f5f0fa;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #ebe4f3;
--bg-viewer: #f5f0fa;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f5f0fa;
--fg-primary: #1a1228;
--fg-secondary: #3d3252;
--fg-tertiary: #5d4f7a;
--fg-muted: #6c5f86;
--border-default: rgba(60, 30, 90, 0.08);
--accent: #7c3aaf;
--btn-on-accent: #ffffff;
--accent-hover: #6a2f95;
--accent-soft: rgba(124, 58, 175, 0.12);
--accent-soft-hover: rgba(124, 58, 175, 0.16);
--accent-selection: rgba(124, 58, 175, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #ebe2f2;
--md-code-text: #1a1228;
--md-inline-code-bg: rgba(124, 58, 175, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #5e1f8a;
--md-link-hover: #421560;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(60, 30, 90, 0.08) 50%, transparent 100%);
}
/* ---------- Sunset (酒红 + 桃粉) ---------- */
[data-theme-palette="sunset"][data-theme="dark"] {
--bg-app: #1a0e14;
--bg-toolbar: rgba(26, 14, 20, 0.85);
--bg-sidebar: rgba(220, 170, 180, 0.04);
--bg-viewer: #1a0e14;
--bg-elevated: #261520;
--bg-card: #2c1a26;
--bg-card-hover: #34212e;
--fg-primary: #f4e6ea;
--fg-secondary: #d8c5cc;
--fg-tertiary: #b598a8;
--fg-muted: #806878;
--border-default: rgba(220, 170, 180, 0.08);
--accent: #ffa091;
--btn-on-accent: #1a232e;
--accent-hover: #ffb1a4;
--accent-soft: rgba(255, 160, 145, 0.20);
--accent-soft-hover: rgba(255, 160, 145, 0.26);
--accent-selection: rgba(255, 160, 145, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #20131a;
--md-code-text: #dccdd2;
--md-inline-code-bg: rgba(255, 160, 145, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #ffb1a4;
--md-link-hover: #ffc2b6;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(220, 170, 180, 0.08) 50%, transparent 100%);
}
[data-theme-palette="sunset"][data-theme="light"] {
--bg-app: #fbf2ee;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #f3e6e1;
--bg-viewer: #fbf2ee;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #fbf2ee;
--fg-primary: #2a121a;
--fg-secondary: #48303a;
--fg-tertiary: #6b4d58;
--fg-muted: #7b636c;
--border-default: rgba(80, 30, 40, 0.08);
--accent: #d94f3a;
--btn-on-accent: #000000;
--accent-hover: #bf3f2d;
--accent-soft: rgba(217, 79, 58, 0.12);
--accent-soft-hover: rgba(217, 79, 58, 0.16);
--accent-selection: rgba(217, 79, 58, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #f1e3dd;
--md-code-text: #2a121a;
--md-inline-code-bg: rgba(217, 79, 58, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #8e1f10;
--md-link-hover: #621208;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(80, 30, 40, 0.08) 50%, transparent 100%);
}

159
src/styles/03-base.css Normal file
View File

@@ -0,0 +1,159 @@
/* ===========================================================================
Reset + Focus Visibility
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ---------- Reset ---------- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
-webkit-text-size-adjust: 100%;
}
/*
长文件名截断fix长文件名导致 toolbar 偏移):
- html/body 是顶层容器,需要 min-width:0 防止内部 grid item 的 min-content
把整个 grid 撑得比视口宽(虽然 .toolbar 自己已加 min-width:0但 body grid
在 grid-template-columns 未指定的情况下默认按 max-content 自适应,多加
一层防御能避免任何子 grid item 把 body 撑出去)
*/
html, body { min-width: 0; }
body {
font-family: var(--font-sans);
font-size: var(--fs-base);
line-height: var(--lh-normal);
color: var(--fg-primary);
background: var(--bg-app);
/* font-weight: 500 全局提升,让 UI 文字statusbar / file-meta / 空状态等
没有显式 font-weight 的元素)默认就更厚重,
解决「看着发淡」的问题。Markdown body 等显式 400 的不受影响 */
font-weight: 500;
/* 字体平滑策略:默认开启 ClearTypesubpixel-antialiased
在 Windows LCD 上比 grayscale 抗锯齿更锐利、字形更黑。
旧值 antialiased 在 Chromium 上会强制关闭 ClearType反而让字变细变糊。
Retina / 高 DPI 屏幕下用下方媒体查询切回 antialiased 避免色边 */
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: optimizeLegibility;
font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "cv11" 1;
/* 让浏览器能在字体缺少对应字重时合成粗体(如 system Segoe UI 落到 600
避免某些 weight 实际渲染为 400 看着发淡 */
font-synthesis: weight;
user-select: none;
display: grid;
/* 四行:顶部工具栏 / 主区 / AI 对话 dock / 状态栏fr 单位随窗口缩放自适应。
AI dock 默认 hiddenauto 高度 = 0展开时挤占 app-shell 高度,
状态栏始终钉在最底。 */
grid-template-rows: var(--h-toolbar) 1fr auto var(--h-statusbar);
/*
长文件名截断fix长文件名导致 toolbar 偏移):
- 单列 grid 显式声明 minmax(0, 1fr) 防止任何 grid item 的 min-content
把整列撑得比视口宽(虽然 .toolbar 自己已加 min-width:0但显式 1fr
让浏览器明确按可用空间分配,防御性更好)
*/
grid-template-columns: minmax(0, 1fr);
/* 防止内容溢出导致整个 body 高度超过视口,破坏 grid */
height: 100vh;
overflow: hidden;
/* 切主题时让背景/前景色平滑过渡(避免一帧硬切) */
transition: background var(--transition-slow), color var(--transition-slow);
}
/* 高 DPI / Retina 屏幕:关闭 subpixel否则字形边缘出现红蓝色彩边看着脏。
非高 DPI 屏幕(普通 LCD保留 subpixel字形更黑更锐利 */
@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* 把四个「chrome」元素显式钉到对应 grid 行 —— 防御性。
即使 DOM 里在它们之前有其他 grid 子元素(如 .focus-hotzone 在某些路径下
被加进 grid 流),也不会错位。
.ai-chat-dock 默认 hidden展开时占据第 3 行auto状态栏被推到第 4 行。 */
.toolbar { grid-row: 1; }
.app-shell { grid-row: 2; }
.ai-chat-dock { grid-row: 3; }
.statusbar { grid-row: 4; }
/* [hidden] 必须真正生效:
浏览器 UA 默认 [hidden] { display: none } 与 .status-chip { display: inline-flex }
同特异性 (0,1,0),用户样式后写胜出 → 隐藏时仍占位、推挤布局。
状态栏的 status-stats / status-heading 一旦被设 [hidden] 还会拉伸 17.5px
把右侧 chip 簇向右推;其他模块也有类似问题。统一加 !important 兜底。 */
[hidden] {
display: none !important;
}
/* ---------- 键盘焦点可见性 ----------
- 鼠标点击不应画外圈:浏览器原生行为 (Chromium / WebKit 已支持 :focus-visible)
- 键盘 Tab 走到哪儿都该看到一个 2px accent 环
- 不破坏现有「假轮廓」元素(.search-input / .form-input它们各自有
:not(:focus-visible) 块把鼠标态的 box-shadow 收回,外圈再叠加
*/
:where(a, button, [role="option"], [tabindex], input, select, textarea, summary, label):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: inherit;
}
/* 列表项的 outline 不需要 offset否则会贴住左侧色条 */
.file-item:focus-visible {
outline-offset: -2px;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
a {
/* 全局链接默认色:用 --accent-text65% 装饰色 + 35% fg-primary
之前纯 --accent 在 13-14px 链接上"扎眼":锚点链接有时是 4-5 字短词,
在一片中性正文里像贴了荧光贴纸。markdown-body 自己的链接走 --md-link
(更亮一档),全局默认行内/工具栏链接走这一档,视觉权重一致 */
color: var(--accent-text);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
/* hover 提亮一档:从文字版(--accent-text→ 纯 --accent给出"可点"的明确反馈 */
color: var(--accent);
}
input {
font: inherit;
color: inherit;
}
ul {
list-style: none;
}
code, pre, kbd {
font-family: var(--font-mono);
user-select: text;
}
::selection {
/* 用 --accent-selectionalpha 0.28-0.50)而非 --accent-soft0.12-0.22
旧值在亮色主题上 alpha 仅 0.12,选区看起来像没选中,新建笔记标题、
重命名、设置输入框、AI dock 等所有 input 的文字选中态都受影响。
CM 编辑器早就走 --accent-selection见 src/editor-theme.js全局规则
跟它对齐,保证 input 内 / 编辑器内 / Markdown viewer 任意位置选中态一致 */
background: var(--accent-selection);
color: var(--fg-primary);
}

565
src/styles/04-toolbar.css Normal file
View File

@@ -0,0 +1,565 @@
/* ===========================================================================
Toolbar + App Shell
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ============================================================
Toolbar (custom titlebar)
============================================================ */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--h-toolbar);
/* 右侧用更小的内边距,避免在默认 1200px 窗口下按钮组被裁;
顶部标题区仍保留 --space-5 的呼吸感 */
padding: 0 4px 0 var(--space-5);
background: var(--bg-toolbar);
/* audit fix (P2):去掉 backdrop-filter —— toolbar 在 bg-toolbar 实色之上,
毛玻璃的 saturate(180%) blur(20px) 强制把整条 1200×40 px 区域放到独立
compositor layer且每帧滚动主内容时 GPU 都要重算这条带覆盖区域的模糊。
视觉上完全无差异(实色背景上看不出毛玻璃),但每次滚动都是几十 KB 像素的
重复 GPU 工作。 */
border-bottom: 1px solid var(--border-subtle);
z-index: var(--z-toolbar);
position: relative;
/*
长文件名截断fix长文件名导致 toolbar 偏移):
- toolbar 是 body grid 子元素min-width 默认是 auto = min-content。
.app-title-text 的 white-space:nowrap + 长文本让 min-content 极大,
toolbar 默认会撑到比视口还宽,把 .toolbar-right关闭等按钮挤出视口。
- min-width:0 覆盖默认 min-content让 toolbar 接受 body grid 给它的宽度
- overflow:hidden 兜底万一有其他内容溢出clip 在 toolbar 内部而不是溢出到 body
body 也是 overflow:hidden但那是最后一道防线视觉上能看到 toolbar 自身的偏移)
*/
min-width: 0;
overflow: hidden;
}
.toolbar::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 1px;
background: var(--toolbar-divider);
}
.drag-region {
-webkit-app-region: drag;
flex: 1;
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
height: 100%;
padding-left: 2px;
}
.no-drag {
-webkit-app-region: no-drag;
display: flex;
align-items: center;
gap: var(--space-1);
}
/* 工具栏分组:将同一类别的按钮聚拢(视觉相邻),组间用 divider 隔开 */
.toolbar-group {
display: inline-flex;
align-items: center;
gap: 2px;
}
.toolbar-divider {
display: inline-block;
width: 1px;
height: 18px;
/* 用半透明白色渐变,比纯色 border 更精致;亮主题用对应浅色 */
background: linear-gradient(180deg,
transparent 0%,
var(--border-default) 25%,
var(--border-default) 75%,
transparent 100%);
/* 从 6px 减到 5px默认 1200px 窗口下 6 个分组 + 7 个 divider 累计 84px gap
改 5px 后 70px让工具栏横向更紧凑 */
margin: 0 5px;
flex-shrink: 0;
}
.app-title {
font-size: var(--fs-base);
font-weight: 600;
color: var(--fg-primary);
letter-spacing: var(--tracking-wide);
display: flex;
align-items: center;
gap: 10px;
/* 选中态:避免拖拽时被高亮 */
user-select: none;
/*
长文件名截断fix长文件名导致 toolbar 偏移):
- min-width:0 是关键 —— flex 容器默认 min-content 是整段文本,
没有它 .app-title 会拒绝收缩,把 .toolbar-right按钮组挤出视口右侧
- overflow:hidden 让超出部分不可见,配合 .app-title-text 的 ellipsis 截断
*/
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
/*
长文件名截断fix长文件名导致 toolbar 偏移):
- 真正承载文本的子 span —— 直接文本节点在 flex 容器里无法触发 ellipsis
- min-width:0 + flex:1 1 auto 让它优先吃光可用空间,超出才走截断
- text-overflow:ellipsis + white-space:nowrap 让长文件名从右侧省略
*/
.app-title-text {
display: block;
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-title::before {
content: "";
width: 20px;
height: 20px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
/* 固定图标尺寸不参与收缩flex-shrink:0 */
flex: 0 0 auto;
}
/* 应用图标(来自 icon.ico 的 PNG data URL
- 20×20 + 6px 圆角,与 .app-title::before 占位形状一致,替换它而不是叠加
- draggable="false" 防选中后拖出浏览器(默认 <img> 选中状态可拖)
- 与 .app-title 共享同一行display:flex 在 .toolbar-left */
.app-icon {
width: 20px;
height: 20px;
border-radius: 6px;
flex: 0 0 auto;
/* 与 ::before 占位的多层 box-shadow 对齐,亮 / 暗主题下都不漂浮 */
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
/* 真图标加载成功 → 隐藏 .app-title::before 占位,避免双图标 */
body.has-app-icon .app-title::before {
display: none;
}
/* Toolbar 按钮:图标按钮 + 悬浮效果
默认色用 secondary暗色 ~8.5:1, 亮色 ~8:1
旧值 secondary 在暗色仅 ~5.5:1加上 13px 字号看着发灰2026-08 统一提亮 */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
color: var(--fg-secondary);
/* 加 transition 阴影和位移,让 hover 反馈更细腻 */
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--fg-primary);
}
.btn-icon:active {
/* scale 从 0.94 减到 0.96:原值 0.94 在 34px 按钮上缩 2px
视觉跳动明显0.96 只缩 1px反馈更克制 */
background: var(--bg-active);
transform: scale(0.96);
}
.btn-icon.is-active {
background: var(--accent-soft);
color: var(--accent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
.btn-icon.is-active:hover {
background: var(--accent-soft-hover);
}
.window-controls {
display: flex;
align-items: center;
margin-left: 4px;
/* 让最右侧的关闭按钮贴齐视口右沿(沿用平台常见 UX但避免裁掉 hover 区;
之前用 -8px 时会把按钮裁进视口外 8px。改为 -2px 把贴边视觉压回视口内 */
margin-right: -2px;
gap: 0;
/* 用渐变分隔线替代纯色 —— 比 border 精致,符合 macOS 视觉 */
position: relative;
}
.window-controls::before {
content: "";
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
height: 20px;
width: 1px;
background: linear-gradient(180deg,
transparent 0%,
var(--border-default) 25%,
var(--border-default) 75%,
transparent 100%);
}
.btn-window {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
/* 同 .btn-icon用 secondary 替代 tertiary避免暗色下窗口控制按钮发灰 */
color: var(--fg-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
}
.btn-window:hover {
background: var(--bg-hover);
color: var(--fg-primary);
}
.btn-window.btn-close:hover {
background: var(--danger);
color: var(--btn-on-danger, #ffffff);
}
/* 工具栏按钮组(视图模式三连按钮) */
.btn-group {
display: inline-flex;
align-items: center;
/* 按钮组之间的间距由 .toolbar-divider 提供,这里去掉额外 margin-left */
margin-left: 0;
gap: 1px;
/* padding: 2px + 按钮 30px = 34px与外层独立 .btn-icon 同高,保证视觉对齐 */
padding: 2px;
/* 容器底色比 hover 更轻,避免和单个按钮 hover 态重叠太深 */
background: color-mix(in srgb, var(--bg-hover) 70%, transparent);
border-radius: var(--radius-sm);
box-shadow: inset 0 0 0 1px var(--border-subtle);
}
.btn-group .btn-icon {
width: 30px;
height: 30px;
border-radius: 5px;
}
.btn-group .btn-icon:hover:not(.is-active) {
background: var(--bg-hover);
}
.btn-group .btn-icon.is-active {
background: var(--bg-elevated);
color: var(--accent);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.12),
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* 图标 + 文字按钮(视图模式专用):
让核心交互(预览/编辑/双栏)一眼可识别,
同时保留 segmented control 的紧凑感。
12px 字号 + secondary暗色 ~8.5:1才能保证文字不糊
2026-08 把 secondary 从 ~5.5:1 提到 ~8.5:1 后视图模式按钮显著更清晰 */
.btn-icon-text {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 30px;
padding: 0 10px;
border-radius: 5px;
color: var(--fg-secondary);
font-size: var(--fs-xs-readable);
font-weight: 500;
letter-spacing: var(--tracking-wide);
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
white-space: nowrap;
}
.btn-icon-text:hover:not(.is-active) {
background: var(--bg-hover);
}
.btn-icon-text:active {
transform: scale(0.96);
}
.btn-icon-text .btn-label {
line-height: 1;
/* 与按钮对齐基线 */
}
.btn-icon-text.is-active {
background: var(--bg-elevated);
/* 文字版(--accent-textview mode "预览 / 编辑 / 双栏"、autosave "自动保存"
这种带字按钮 active 时若用纯 accent"预览" "自动保存" 在工具栏里整片
accent 短词同时亮起来会让眼睛辛苦。降一档后 bg-elevated + inset 1px
accent 边框仍清楚表达"按下",文字不再贴脸 */
color: var(--accent-text);
font-weight: 600;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.12),
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* (audit fix Round 9: .toolbar-status-dot / .btn-icon-text > .toolbar-status-dot
死 CSS —— 保存按钮改 is-dirty 状态后该指示点彻底弃用,见
feedback-save-button-always-visible.md。iOS pill switch 替代品见下) */
/* iOS pill switch —— 替代 .toolbar-status-dot 作为开关语义。
26×14px 嵌入 .btn-icon-text与现有 24px 按钮中线对齐。
视觉态由 [aria-pressed="true|false"] 选择器驱动,与项目
「aria-pressed 表示开关态」的模式一致(参考 btn-theme
保留 .btn-icon-text 外壳让按钮仍享有 hover/active 视觉反馈。 */
.toolbar-toggle .toggle-switch {
position: relative;
display: inline-block;
width: 26px;
height: 14px;
margin-left: 4px;
flex: 0 0 auto;
}
.toolbar-toggle .toggle-switch-track {
position: absolute;
inset: 0;
background: var(--fg-muted);
opacity: 0.5;
border-radius: 7px;
transition: background var(--transition-fast), opacity var(--transition-fast);
}
.toolbar-toggle .toggle-switch-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 12px;
height: 12px;
background: var(--bg-elevated);
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
transition: transform var(--transition-fast);
}
.toolbar-toggle[aria-pressed="true"] .toggle-switch-track {
background: var(--accent);
opacity: 1;
}
.toolbar-toggle[aria-pressed="true"] .toggle-switch-thumb {
transform: translateX(12px);
}
/* hover 微微提亮,与其它 btn-icon-text hover 一致 */
.toolbar-toggle:hover .toggle-switch-track { opacity: 0.85; }
.toolbar-toggle[aria-pressed="true"]:hover .toggle-switch-track { opacity: 1; }
/* 关态时整体暗化,与工具栏 fg-tertiary 一致,强调「关闭」语义 */
.toolbar-toggle[aria-pressed="false"] {
color: var(--fg-tertiary);
}
/* a11yprefers-reduced-motion 下关闭 transform 过渡 */
@media (prefers-reduced-motion: reduce) {
.toolbar-toggle .toggle-switch-track,
.toolbar-toggle .toggle-switch-thumb { transition: none; }
}
.btn-icon.is-dirty,
.btn-icon-text.is-dirty {
/* 脏状态:使用 warning 色作为视觉提示,并轻微脉动 */
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
animation: dirty-pulse 1.8s ease-in-out infinite;
}
.btn-icon.is-dirty:hover,
.btn-icon-text.is-dirty:hover {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 18%, transparent);
}
/* 保存中:与 is-dirty 的 warning 区分,用 fg-secondary 中性色 + 微微透明度。
cursor: progress 给鼠标用户明确提示(参见 [[feedback-save-button-always-visible]]
——按钮始终可见,这里只是临时视觉变化,不切换 hidden。 */
.btn-icon.is-saving,
.btn-icon-text.is-saving {
cursor: progress;
opacity: 0.6;
color: var(--fg-secondary);
background: var(--bg-hover);
/* 关掉 dirty-pulse 动画:避免 is-saving + is-dirty 叠加造成闪烁 */
animation: none;
}
/* a11yprefers-reduced-motion 下保持稳定视觉,不透明度也保留 */
@media (prefers-reduced-motion: reduce) {
.btn-icon.is-saving,
.btn-icon-text.is-saving {
opacity: 0.7;
cursor: progress;
}
}
/* 主题按钮图标:按当前主题显示"代表当前状态"的那一态——
暗色主题下显示月亮,亮色主题下显示太阳(点击图标会切到对侧主题)。
两个 SVG 同时存在于 DOM靠 [data-theme] 切换可见性,免 JS 维护。 */
#btn-theme .theme-icon {
display: none;
}
body[data-theme="dark"] #btn-theme .theme-icon-moon,
body[data-theme="light"] #btn-theme .theme-icon-sun {
display: block;
}
/* (audit fix Round 9: .toolbar-status-dot 死 CSS详见 04-toolbar.css 顶部注释) */
/* dirty-pulse脉动从 1 → 0.65 太深warning 色按钮在 50% 帧会瞬间
看着像"warning 没了"。改成 1 → 0.78:仍能看出脉动,但始终保持 warning
视觉在场,"未保存"语义不中断。1.8s 节奏不变 */
@keyframes dirty-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.78; }
}
/* 工具栏图标按钮的 disabled 态(如非 md 文件下 preview/split 按钮)
复用 .context-menu-item 的禁用色fg-tertiary + opacity保持一致 */
.btn-icon:disabled,
.btn-icon[disabled] {
color: var(--fg-tertiary);
cursor: not-allowed;
opacity: 0.4;
pointer-events: none;
}
/* 图标+文字按钮(视图模式 预览/编辑/双栏)的 disabled 态。
非 Markdown 文件打开时 preview/split 按钮会被禁用 —— 这里让灰态视觉一致 */
.btn-icon-text:disabled,
.btn-icon-text[disabled] {
color: var(--fg-tertiary);
cursor: not-allowed;
opacity: 0.4;
pointer-events: none;
}
/* 窄窗口(< 820pxtoolbar 文字标签隐藏,只保留图标。
与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌 */
@media (max-width: 820px) {
.btn-icon-text .btn-label {
display: none;
}
.btn-icon-text {
/* 没文字时收紧 padding让按钮保持图标按钮的紧凑感 */
padding: 0 6px;
}
}
/* ============================================================
App Shell
============================================================ */
.app-shell {
display: grid;
/* 侧栏宽度走 --sidebar-width运行时可拖拽调整默认值来自 --w-sidebar
这样改 token 不必同时改 grid 模板,也避免与窄屏媒体查询耦合 */
grid-template-columns: var(--sidebar-width) 4px 1fr;
min-height: 0;
overflow: hidden;
}
/* 侧栏拖拽分割条:只在 split / preview / edit 单栏模式下显示。
视觉宽度 4px。命中区扩到 ±6px共 12px由 app.js 的 mountSidebarSplitter
监听 document mousedown + mousemove在鼠标距 splitter 中线 ≤6px 时启动拖动,
并把光标改成 col-resize 提示可拖。这是 grid track 4px 限制下的标准做法。 */
.sidebar-splitter {
background: var(--border-subtle);
cursor: col-resize;
position: relative;
width: 4px;
transition: background var(--transition-fast);
}
.sidebar-splitter:hover,
.sidebar-splitter.is-dragging {
background: var(--accent-soft);
}
.sidebar-splitter::after {
/* 抓握提示:两条短竖线 */
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 24px;
background: var(--border-default);
box-shadow: 3px 0 0 var(--border-default);
}
.sidebar-splitter:hover::after,
.sidebar-splitter.is-dragging::after {
background: var(--accent);
box-shadow: 3px 0 0 var(--accent);
}
/* 侧栏恢复默认宽度按钮hover splitter 时显形)
- 4px splitter 视觉宽度里塞不下按钮,所以按钮是 absolute 定位居中 + 视觉溢出;
但 splitter 本身 hover 区域扩到 ±6pxHIT_RADIUS按钮顺势接管这块区域
- 默认 opacity:0 + pointer-events:none → 完全不挡 drag 命中区;
hover splitter / 按钮自身 / focus-visible 时显形 */
.sidebar-splitter-reset {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 18px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 3px;
color: var(--fg-secondary);
cursor: pointer;
padding: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.sidebar-splitter:hover .sidebar-splitter-reset,
.sidebar-splitter-reset:focus-visible {
opacity: 1;
pointer-events: auto;
}
.sidebar-splitter-reset:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.sidebar-splitter-reset:active {
background: var(--accent);
color: var(--bg-card);
}

872
src/styles/05-sidebar.css Normal file
View File

@@ -0,0 +1,872 @@
/* ===========================================================================
Sidebar + Viewer + Editor Split Layout
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ============================================================
Sidebar
============================================================ */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
min-height: 0;
z-index: var(--z-sidebar);
/* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */
min-width: 0;
}
.sidebar-header {
/* 紧凑的 header —— 上下 12px、左右 14px让搜索框有呼吸但不浪费空间 */
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: var(--space-2);
}
/* 新建笔记行:放在 .sidebar-header 下方、排序条上方的独立主操作行。
左右 padding 用 10px与 .sidebar-sort / .file-list 对齐),
让「创建」按钮和它作用的列表视觉上是一组;搜索框保留 14px 更深的内嵌来强调 */
.sidebar-new-row {
padding: 10px 10px 8px;
}
/* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮
——accent 文字 + 微微的 accent 底色 + 1px accent 边框hover/active 进一步加深。
2026-08 把字号从 12px 提到 14px、字重 500 → 600、加 8% accent 底色 + 32% accent 边框,
解决「新建笔记」字看着发淡、按钮太瘦的问题 */
.sidebar-new-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 34px;
padding: 0 12px;
border-radius: var(--radius-sm);
/* audit fix (Round 9)font-size 13px (fs-sm) 的文字走 --accent-text混色 65/70%
不能用装饰色 token --accent饱和度太高13px 文字看着刺眼)。
见 [[feedback-colored-text-tier]]。background / border 仍走 --accent 因为
它们是装饰而非文字。 */
color: var(--accent-text);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
font-size: var(--fs-sm);
font-weight: 600;
letter-spacing: 0;
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.sidebar-new-btn:hover {
/* audit fix (CSS P2 / sidebar new-btn hover):之前 color 走 --accent-hover
(纯 accent 饱和色13px 小字直接用饱和色会"扎眼"、在 dark 调色板
还会出现 vibration红绿高频抖动现象。base 态已经走 --accent-text
accent 65/70% 混色hover 应当保持同套语义色 token 一致,只是
把混色比例上调一档或换更暖的 --accent-text 变体,让视觉权重随交互
上升但 chroma 仍温和(见 [[feedback-colored-text-tier]])。 */
color: var(--accent-text);
border-color: var(--accent);
background: var(--accent-soft-hover);
}
.sidebar-new-btn:active {
transform: scale(0.98);
}
.sidebar-new-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.search-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
/* 用 secondary 替代 tertiary搜索框是核心入口图标应该一眼看清 */
color: var(--fg-secondary);
pointer-events: none;
transition: color var(--transition-fast);
}
.search-input {
width: 100%;
/* 收紧内边距、改为 8px/0/8px/32px高度由 line-height + 7px*2 padding 决定 ≈ 30px */
padding: 7px 10px 7px 32px;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
color: var(--fg-primary);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.search-input::placeholder {
/* 占位符原本用 fg-muted暗色 ~2:1 几乎看不见),提到 fg-secondary 让"搜索文件..."
提示文字可见 —— 用户至少能看清这里有输入框 */
color: var(--fg-tertiary);
}
.search-input:hover {
border-color: var(--border-default);
}
.search-input:focus {
/* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-glow);
background: var(--bg-elevated);
}
@supports (focus-visible) {
.search-input:focus:not(:focus-visible) {
box-shadow: none;
}
.search-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
}
.search-input:focus + .search-icon {
color: var(--accent);
}
/* ============================================================
Sidebar 排序工具条
紧贴它作用的 .file-list 上方 —— 这是「列表的工具」,
不应该埋进三层深的设置对话框(见 [[feedback-prefer-near-affected-area]])。
设计目标紧凑到不抢搜索框注意力active 态明确但不过重。
============================================================ */
.sidebar-sort {
display: flex;
gap: 2px;
/* 顶 header 12px、底 6px —— 让工具条与列表的视觉关联 > 与 header 的关联 */
padding: 6px 10px 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-sort-btn {
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
/* 2026-08 把字号从 12px 提到 13px--fs-sm与文件 meta / 工具栏按钮文字同档,
解决排序按钮在 13px 字号下字看着小、发淡的问题。字重 500 → 600 让文字更"实" */
color: var(--fg-secondary);
font-size: var(--fs-sm);
font-weight: 600;
letter-spacing: var(--tracking-wide);
cursor: pointer;
/* 防止「修改时间」等 4 字标签在 180px 窄侧栏里换行成两行(高度塌到 46px
挤压下方文件列表)。即使不够也用省略号截断,保持一行紧凑 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
background var(--transition-fast),
color var(--transition-fast),
border-color var(--transition-fast);
}
.sidebar-sort-btn:hover {
color: var(--fg-secondary);
background: var(--bg-hover);
}
.sidebar-sort-btn.is-active {
/* active 选中态:文字降彩度(--accent-text。"名称"/"修改时间"/"创建时间"
三个 sort 按钮并排时,纯 accent 文字让三者同时"跳出来",观感刺眼;
改用文字版后accent-soft 底色仍明确选中,但字不再"贴脸"。 */
color: var(--accent-text);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
}
.sidebar-sort-btn.is-active:hover {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
}
.sidebar-sort-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.sidebar-sort-btn svg {
flex: 0 0 auto;
}
.file-list {
flex: 1;
overflow-y: auto;
/* 上下 8px 比之前的 12px 更紧凑;列表项自身有 padding 已经够了 */
padding: 8px 10px;
/* 列表项间距:用 gap 而非 item 的 margin-bottom避免 list 总高累加偏差。
1px gap 与原 margin-bottom:1px 视觉一致,但语义更清晰 */
display: flex;
flex-direction: column;
gap: 1px;
/* 滚动条预留空间 —— 让滚动条 hover 才出现,不抢视觉 */
scrollbar-gutter: stable;
}
.file-list:empty::after {
/* 空列表:淡灰色提示。设置里改数据目录、或把 .md 拖到目录里都会自动消失。
原来用 --fg-muted 在 bg-sidebar 上仅 ~3.2:1default dark低于 WCAG AA。
提到 --fg-tertiary~5.4:1让这段引导文字真能"看清"——
无列表可看时用户全部注意力都落在这段字上,发淡等于无效提示 */
content: attr(data-empty);
display: block;
padding: var(--space-7) var(--space-3);
text-align: center;
color: var(--fg-tertiary);
font-size: var(--fs-sm);
line-height: var(--lh-relaxed);
/* 留点上方间距,与 sidebar-header 拉开 */
margin-top: var(--space-2);
/* 避免一行字过长在窄侧栏里截断为半个字 */
word-break: break-word;
}
.file-item {
/* grid 两列16px 图标 + 1fr 文本区。
用 grid 而不是 padding-left+absolute 是为了:
1) 文本区能正常占满剩余宽度min-width:0 走 grid 才生效)
2) 图标列固定,文本列弹性,缩窄侧栏不挤图标 */
display: grid;
grid-template-columns: 16px 1fr;
align-items: center;
column-gap: 10px;
/* 上下 padding 9px → 6px原 9px 在 13px name + 13px meta 两行下让单条 file-item
撑到 ~60px50 个文件就要 3000px 滚动,太松。压到 6px 后 ~50px/item
列表更紧凑、可见项更多,仍保留点击区域与 hover 反馈呼吸 */
padding: 6px 12px 6px 10px;
/* margin-bottom 去掉,改用 file-list 的 gap 统一间距;
同时消除"两行间距靠 margin 累加"导致的列表总高计算偏差 */
border-radius: var(--radius-sm);
cursor: pointer;
/* transition 增加 border-color / box-shadow让激活态色条 + 微弱内阴影的进入更顺滑 */
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
position: relative;
}
.file-text {
/* 文本列name + meta 仍然竖排min-width:0 让 ellipsis 真正生效 */
display: flex;
flex-direction: column;
/* name↔meta gap 2px → 1px与 file-item 上下 padding 同步收紧,让整条紧凑 */
gap: 1px;
min-width: 0;
}
.file-icon {
/* 平时 mutedhover/active 时跟随主题升级。
用 grid 让图标"漂"在 item 左侧 + 上下居中,比 padding-left+absolute 更稳 */
color: var(--fg-tertiary);
flex-shrink: 0;
transition: color var(--transition-fast);
}
.file-item:hover .file-icon {
color: var(--fg-secondary);
}
.file-item:active {
transform: scale(0.985);
}
.file-item.is-active {
/* 激活态accent-soft 底色 + 左侧 3px 色条。
用 accent-soft 替代 accent-soft-hoverhover 态是临时高亮(更深一档),
active 态是稳定选中,两者用不同 token 区分意图 */
background: var(--accent-soft);
}
.file-item.is-active:hover {
/* 激活态 hover 走更深一档 —— 与 idle 态 hoverbg-hover形成层次差 */
background: var(--accent-soft-hover);
}
.file-item.is-active .file-icon {
/* 激活态:图标与正文同色,构成一致的视觉语言 */
color: var(--accent);
}
.file-item.is-active::before {
content: "";
position: absolute;
/* 从 -3px 移到 0让色条贴着 item 内沿,更紧凑不"漂" */
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
border-radius: 0 2px 2px 0;
background: var(--accent);
}
.file-name {
font-size: var(--fs-sm);
color: var(--fg-primary);
/* 字重 500 → 600让 file-name 成为主视觉。
之前 file-meta 也是 600 + 同字号,导致 meta 看起来比 name 还"实"
层级倒挂(次要信息喧宾夺主)。现在 name 600 / meta 500名字优先 */
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.file-item .file-name mark {
/* 搜索高亮:在文件名里命中搜索关键字时的视觉标识。
22% accent 背景已经给出"高亮"信号,文字再纯 accent 会与背景同色相"贴
一起"。改用 --accent-text —— 仍可识别色相(青蓝/紫/红等色调随 palette
但 chroma 降到不会与底色"撞色"的程度。
字重保持 700因为 mark 通常只有 1-3 字,需要靠粗细扛"高亮"识别。 */
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--accent-text);
padding: 0 2px;
border-radius: 3px;
font-weight: 700;
}
.file-item.is-active .file-name {
/* accent #5e6ad2 在 #08090a 上只有 ~4.0:1作为正文偏暗。
用与正文链接同色相的 --md-link已在 #08090a 上 ~5.5:1替代 */
color: var(--md-link);
/* 已与 idle file-name 同字重600保持选中态只是颜色升级、不再额外加粗 */
font-weight: 600;
}
.file-meta {
/* 字号 13px → 12px--fs-xs-readable比 file-name 小一档;
字重 600 → 500 让 meta 视觉退到次级,与 file-name 的 600 形成对比。
两层降级(更小 + 更轻)让 meta 真正成为"辅助信息"而不是与 name 平级 */
font-size: var(--fs-xs-readable);
color: var(--fg-secondary);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-wide);
/* 窄侧栏(拖到 180px时 meta 行可能换行成两行,让 file-item 高度跳变。
强制单行 + 省略号,保持列表项高度稳定 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item.is-active .file-meta {
color: var(--md-link);
opacity: 0.75;
}
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 2px;
}
/* 侧栏底部视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开。
用 1px 细线 + 上下 6px 留白,避免紧贴导致 AI 按钮和文件夹链接"挤成一团" */
.sidebar-footer-sep {
height: 1px;
background: var(--border-subtle);
margin: 6px 0;
}
/* AI 主操作按钮在侧栏底部已不再与 footer-sep 紧邻sep 在 AI 上方),
不再需要 margin-bottom: 0 来解耦 gap。 */
.link-subtle {
display: flex;
align-items: center;
gap: var(--space-2);
/* 11px 字号在低 DPI 上偏糊,提到 12pxfs-xs-readable保持视觉小但不模糊
颜色已用 secondary2026-08 提亮后约 8.5:1 on dark */
font-size: var(--fs-xs-readable);
color: var(--fg-secondary);
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
font-weight: 500;
min-width: 0; /* 允许 flex item 在窄侧栏180px收缩到内容宽度以下 */
}
/* 文字单行 + 溢出省略号:防止「切换数据文件夹…」在窄侧栏里折成两行 */
.link-subtle > span {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* link-subtle / footer-action侧栏底部"打开数据目录 / 切换目录"等
二级链接。hover 时给 bg-hover + accent 文字提示"可点"——
与全局 a 一致用 --accent-text让"hover 才出颜色"的反馈也维持文字版。 */
.link-subtle:hover {
background: var(--bg-hover);
color: var(--accent-text);
}
/* <button> 重置:与 .link-subtle 一致的"链接外观",但语义上是动作按钮 */
.footer-action {
width: 100%;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
}
/* 键盘 focus 高亮 —— 鼠标点击不画,键盘 Tab 才出现hover 不等于 focus */
.link-subtle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================
Viewer
============================================================ */
.viewer {
background: var(--bg-viewer);
overflow-y: auto;
min-width: 0;
display: flex;
flex-direction: column;
scroll-behavior: smooth;
}
.markdown-body {
flex: 1;
max-width: var(--w-reader-max);
width: 100%;
margin: 0 auto;
padding: var(--space-9) var(--space-7) calc(var(--space-9) * 1.5);
font-family: var(--font-reading);
font-size: var(--reader-font-size);
line-height: var(--reader-line-height);
color: var(--fg-primary);
user-select: text;
letter-spacing: 0.005em;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 空状态:更有温度的视觉 */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* gap 用 0靠各元素的 margin 控制间距:
- icon → 标题:靠 icon 自身的 margin-bottom
- 标题 → 主 hint12px让主信息与提示分隔
- 主 hint → 次 hint6px形成层次
用统一 gap 会让主 hint 与次 hint 之间也有 12px反而拉得太平 */
gap: 0;
color: var(--fg-tertiary);
text-align: center;
padding: var(--space-7);
/* 顶部加一点点品牌色辉光,强化「这是 Notes 应用」的氛围 */
position: relative;
}
.empty-state-icon {
width: 88px;
height: 88px;
border-radius: 50%;
/* 三层渐变叠出「呼吸光晕」:从中心发散到外圈的柔光 */
background:
radial-gradient(circle at 50% 45%,
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
color-mix(in srgb, var(--accent) 8%, transparent) 35%,
transparent 70%);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
margin-bottom: var(--space-3);
position: relative;
/* 让 halo 缓慢呼吸,强化「等待/邀请」感 */
animation: empty-halo 4s ease-in-out infinite;
}
.empty-state-icon::after {
/* 内圈实色圆 + 微阴影:让图标有「浮起」感 */
content: "";
position: absolute;
inset: 16px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 12%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent),
0 4px 12px color-mix(in srgb, var(--accent) 8%, transparent);
z-index: -1;
}
.empty-state-icon svg {
/* 图标本身略浮起,配合 halo 制造层次 */
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--accent) 18%, transparent));
}
@keyframes empty-halo {
0%, 100% {
transform: scale(1);
opacity: 0.95;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
.empty-state p {
margin: 0;
font-size: var(--fs-lg);
color: var(--fg-primary);
font-weight: 700;
/* 标题与下面 hint 之间的留白由 gap 控制,避免再加 margin */
letter-spacing: var(--tracking-tight);
/* 标题用次级 fg-primary 突出 —— primary 比 secondary 更有视觉重量 */
}
/* 标题("选择一个 Markdown 文件开始阅读")需要与图标 / 提示都拉出层次 */
.empty-state > p:not(.hint) {
margin-bottom: var(--space-3);
}
.empty-state .hint {
font-size: var(--fs-sm);
/* 13px 提示文本:用 secondary2026-08 提亮到 ~8.5:1避开 tertiary 旧值的 3.5:1
—— 注释说要用 secondary 但实际代码仍是 tertiary已修复 */
color: var(--fg-secondary);
/* max-width 从 360 提到 400让"放进数据文件夹后," + ".md" chip 一行内放得下,
避免折成两行导致后段「自动出现」只剩一两个汉字成行orphan 字)。
text-wrap: pretty 是 Chromium 114+ 的 CSS Text Level 4 优化,
优先避免单字成行(对 CJK 也生效:算法看 line-end penalty */
max-width: 400px;
line-height: var(--lh-relaxed);
/* 主 hint 与标题之间已经由标题 margin-bottom 12px 撑开,
主 hint 自身的 margin-top 设为 0 避免与上面 margin 叠加 */
margin-top: 0;
text-wrap: pretty;
}
/* 二级提示:用 tertiary + 略小字号,与主 hint 拉开视觉权重 */
.empty-state .hint.hint-secondary {
color: var(--fg-tertiary);
font-size: var(--fs-xs-readable);
margin-top: 6px;
max-width: 380px;
}
.empty-state .hint kbd {
/* .md 这种代码 chip 用 kbd 标签更语义化 */
padding: 2px 7px;
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
/* 2026-08 从 11px 提到 12px11px 在低 DPI 上偏糊,挤在提示文本里看着小 */
font-size: var(--fs-xs-readable);
font-family: var(--font-mono);
color: var(--fg-secondary);
font-weight: 600;
box-shadow: 0 1px 0 var(--border-subtle);
}
.empty-state code {
padding: 2px 8px;
background: var(--bg-hover);
border-radius: var(--radius-sm);
/* 0.85em of 13px ≈ 11px —— 提到 12px--fs-xs-readable让 .md chip 跟 kbd 同档 */
font-size: var(--fs-xs-readable);
font-family: var(--font-mono);
/* accent 文字在 bg-hover 上仅 ~4:1改用 fg-secondary 满足 AA */
color: var(--fg-secondary);
}
/* ============================================================
Editor Pane & Split View
三种模式preview仅 viewer/ edit仅 editor/ split双栏 + 分割条)
============================================================ */
body[data-editor-mode="split"] .app-shell {
/* 五列sidebar | sb-splitter | editor | splitter | viewer
editor 用 px (--w-editor) 由 app.js 拖动时写在 .app-shell 的 style 上;
viewer 用 1fr 自动填剩余空间。
这样在 split + AI 模式下拖 ai-splitter 不会挤编辑器(详见 src/app.js mountSplitter
minmax(280px, ...) 给编辑器最小宽度 —— 编辑器内容至少要够阅读宽度才不挤;
默认 var(--w-editor, 50%) 在 CSS fallback 阶段给一个等分初始值,
app.js 启动时根据 splitRatio 重算覆盖。 */
grid-template-columns:
var(--sidebar-width)
4px
minmax(280px, var(--w-editor, 50%))
4px
minmax(0, 1fr);
}
body[data-editor-mode="preview"] .app-shell {
/* 三列sidebar | sb-splitter | viewer */
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
body[data-editor-mode="edit"] .app-shell {
/* 三列sidebar | sb-splitter | editor */
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
.editor-pane {
background: var(--bg-viewer);
overflow: hidden; /* CM6 自己处理滚动 */
min-width: 0;
min-height: 0;
display: none; /* 默认隐藏preview 模式) */
user-select: text;
}
body[data-editor-mode="edit"] .editor-pane,
body[data-editor-mode="split"] .editor-pane {
display: flex;
flex-direction: column;
}
/* 让编辑器像 viewer 一样「居中 + 限定行宽」,与预览区域视觉重量一致。
max-width 与 .markdown-body 共用 --w-reader-max保证 gutter + 内容总宽
不超过 reader maxmargin auto 在更宽的窗口中把编辑器推到中部。
窄窗口下 max-width 不再生效,编辑器自然占满全宽(不破坏小屏体验)。 */
.editor-pane .cm-editor {
max-width: var(--w-reader-max);
margin: 0 auto;
width: 100%;
}
/* 窄窗口(< 720px三栏挤到 1xx px 时退化为「单栏 + 隐藏阅读视图」——
至少保留可读可编辑区域。minWidth: 600 仍由主进程限制,这里只处理 600~720 区间 */
@media (max-width: 720px) {
body[data-editor-mode="split"] .app-shell {
/* 侧栏 splitter 仍保留,方便用户把侧栏压到最小留更多编辑区 */
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
/* 直接隐藏 editor/viewer 之间的 splitter 元素,避免它作为孤儿占据第 5 个 grid 单元 */
body[data-editor-mode="split"] .splitter {
display: none !important;
}
body[data-editor-mode="split"] .viewer {
display: none !important;
}
/* 窄屏默认侧栏宽度变窄 */
:root { --sidebar-width: var(--w-sidebar-narrow); }
}
/* edit 模式:隐藏阅读视图 —— 编辑器已经占据右侧,不需要再显示预览 */
body[data-editor-mode="edit"] .viewer {
display: none;
}
.splitter {
background: var(--border-subtle);
cursor: col-resize;
position: relative;
display: none;
transition: background var(--transition-fast);
}
body[data-editor-mode="split"] .splitter {
display: block;
}
.splitter:hover,
.splitter.is-dragging {
background: var(--accent-soft);
}
.splitter::after {
/* 抓握提示:两条短竖线 */
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 24px;
background: var(--border-default);
box-shadow: 3px 0 0 var(--border-default);
}
.splitter:hover::after,
.splitter.is-dragging::after {
background: var(--accent);
box-shadow: 3px 0 0 var(--accent);
}
/* 编辑器底色 / 文字色已由 editor-theme.js 的 specvar(--bg-viewer) / var(--fg-primary)
覆盖;老的"dark 强制 bg-viewer、light 强制 bg-viewer"那段代码冗余且容易出错,删除。 */
.cm-editor .cm-tooltip {
background: var(--bg-elevated);
color: var(--fg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
}
.cm-editor .cm-tooltip-autocomplete ul li[aria-selected] {
background: var(--accent-soft);
color: var(--accent);
}
/* ============================================================
Folder BrowserStage 8面包屑 + 文件夹 / 二进制条目样式
面包屑放在 .sidebar-sort 下方、.file-list 上方,贴近它作用的列表。
根目录时整个 <nav> hiddenHTML 里默认带 hidden 属性),
不需要 CSS 处理空态。
文件夹(.file-item.is-folder
- 文件夹图标 + 名称略加粗(区别于普通文件)
- meta 行简化为「文件夹」
二进制(.file-item.is-binary
- opacity 0.45 + cursor not-allowed —— 视觉与文件管理器对不可执行项的灰态一致
- 仍可被点击(事件不被禁用,渲染端弹 toast 提示)
============================================================ */
.sidebar-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px 4px;
/* 横向溢出 + 不换行:用户进入很深层子目录时(如 archive/2026/03/15
末尾「当前段」是最重要的,前面的历史段可以横滑出可见区 */
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
/* 让滚动条只在 hover 时更显眼,平时几乎不可见 —— 避免视觉噪音 */
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.sidebar-breadcrumb:hover {
scrollbar-color: var(--border-subtle) transparent;
}
/* 回到根按钮:图标 + 文字,单独占左侧 */
.sidebar-breadcrumb-root {
display: inline-flex;
align-items: center;
gap: 3px;
flex: 0 0 auto;
padding: 2px 6px;
border: none;
background: transparent;
color: var(--fg-secondary);
font-size: var(--fs-xs);
cursor: pointer;
border-radius: 3px;
font-family: inherit;
}
.sidebar-breadcrumb-root:hover {
background: var(--bg-hover);
color: var(--fg-primary);
}
.sidebar-breadcrumb-root:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.sidebar-breadcrumb-root svg {
flex: 0 0 auto;
}
/* 面包屑分段按钮(中间段,可点击跳转) */
.sidebar-breadcrumb-btn {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 2px 6px;
border: none;
background: transparent;
color: var(--fg-secondary);
font-size: var(--fs-xs);
cursor: pointer;
border-radius: 3px;
font-family: inherit;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-breadcrumb-btn:hover {
background: var(--bg-hover);
color: var(--accent);
}
.sidebar-breadcrumb-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* 当前段(非可点击,纯文本):稍亮,与「根 / 中间段」做对比 */
.sidebar-breadcrumb-current {
flex: 0 0 auto;
padding: 2px 6px;
font-size: var(--fs-xs);
color: var(--fg-primary);
font-weight: 600;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-breadcrumb-sep {
flex: 0 0 auto;
color: var(--fg-muted);
font-size: var(--fs-xs);
opacity: 0.6;
}
/* 文件夹条目:粗体名称 + 文件夹图标颜色稍亮 */
.file-item.is-folder .file-name {
font-weight: 600;
color: var(--fg-primary);
}
.file-item.is-folder .file-icon {
/* 文件夹图标用 accent 而不是 fg-muted让「这是目录」的视觉信号更直接 */
color: var(--accent);
}
/* 二进制条目:灰态 + cursor 提示不可编辑,但仍可点击(弹 toast */
.file-item.is-binary {
opacity: 0.45;
cursor: not-allowed;
}
.file-item.is-binary .file-icon {
/* 与灰度视觉统一:图标也走 fg-muted */
color: var(--fg-muted);
}
.file-item.is-binary:hover {
/* 灰态项 hover 不要让背景喧宾夺主 —— 用更弱的 hover 背景 */
background: var(--bg-hover);
}
.file-item.is-binary.is-active {
/* 即使被键盘选中也不打破「不可编辑」的视觉信号 —— 不用 accent 高亮 */
background: var(--bg-hover);
}
.file-item.is-binary.is-active::before {
/* 不画左侧色条:用户已经看 opacity 知道这一项不可编辑 */
opacity: 0.4;
}

295
src/styles/06-markdown.css Normal file
View File

@@ -0,0 +1,295 @@
/* ===========================================================================
Markdown Body — 排版优先的 GFM 样式
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ============================================================
Markdown Body — 排版优先的 GFM 样式
============================================================ */
/* 段落间距:呼吸感 */
.markdown-body > * + * {
margin-top: 1.2em;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-weight: 700;
line-height: var(--lh-snug);
color: var(--md-heading);
letter-spacing: var(--tracking-tight);
scroll-margin-top: var(--space-5);
font-family: var(--font-sans);
}
.markdown-body h1 {
font-size: var(--fs-4xl);
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.6em;
padding-bottom: 0.4em;
border-bottom: 1px solid var(--md-hr);
}
.markdown-body h2 {
font-size: var(--fs-2xl);
font-weight: 700;
margin-top: 2.2em;
margin-bottom: 0.6em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--md-hr);
}
.markdown-body h3 {
font-size: var(--fs-xl);
margin-top: 1.8em;
margin-bottom: 0.5em;
}
.markdown-body h4 {
font-size: var(--fs-lg);
margin-top: 1.6em;
margin-bottom: 0.5em;
color: var(--fg-secondary);
}
.markdown-body h5,
.markdown-body h6 {
font-size: var(--fs-base);
margin-top: 1.4em;
margin-bottom: 0.5em;
color: var(--fg-tertiary);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
/* 标题后的第一个元素减少间距 */
.markdown-body h1 + *,
.markdown-body h2 + *,
.markdown-body h3 + *,
.markdown-body h4 + * {
margin-top: 0;
}
.markdown-body p {
margin: 0;
text-align: justify;
hyphens: auto;
}
.markdown-body a {
color: var(--md-link);
text-decoration: none;
border-bottom: 1px solid var(--md-link);
padding-bottom: 1px;
font-weight: 500;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.markdown-body a:hover {
color: var(--md-link-hover);
border-bottom-color: var(--md-link-hover);
}
.markdown-body strong {
font-weight: 700;
color: var(--fg-primary);
}
.markdown-body em {
font-style: italic;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 1.6em;
line-height: var(--lh-loose);
}
.markdown-body ul {
list-style: none;
}
.markdown-body ul > li {
position: relative;
}
.markdown-body ul > li::before {
content: "";
position: absolute;
left: -1em;
top: 0.7em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
opacity: 0.7;
}
.markdown-body ol {
list-style: decimal;
font-variant-numeric: tabular-nums;
}
.markdown-body ol > li::marker {
color: var(--accent);
font-weight: 600;
}
.markdown-body li {
margin-bottom: 0.4em;
}
.markdown-body li + li {
margin-top: 0;
}
.markdown-body li > ul,
.markdown-body li > ol {
margin-top: 0.4em;
}
.markdown-body input[type="checkbox"] {
margin-right: var(--space-2);
accent-color: var(--accent);
}
/* 引用:左色边 + 浅背景 + 斜体 */
.markdown-body blockquote {
padding: var(--space-3) var(--space-5);
border-left: 3px solid var(--md-blockquote-border);
background: var(--md-blockquote-bg);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--fg-secondary);
font-style: italic;
margin: 1.5em 0;
}
.markdown-body blockquote p {
margin: 0;
}
.markdown-body blockquote p + p {
margin-top: 0.6em;
}
/* 行内代码:淡淡的着色背景,不破坏阅读节奏 */
.markdown-body code {
padding: 2px 7px;
background: var(--md-inline-code-bg);
color: var(--md-code-text);
border-radius: 4px;
font-size: 0.88em;
font-weight: 500;
}
/* 代码块:等宽字体 + 暗背景 + 行号占位 */
.markdown-body pre {
padding: var(--space-4) var(--space-5);
background: var(--md-code-bg);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: var(--fs-sm);
line-height: 1.7;
margin: 1.4em 0;
border: 1px solid var(--border-subtle);
position: relative;
}
.markdown-body pre code {
padding: 0;
background: none;
color: inherit;
font-size: inherit;
white-space: pre;
font-weight: 400;
}
/* 水平分割线 */
.markdown-body hr {
border: none;
height: 1px;
background: var(--md-hr);
margin: 2.5em 0;
position: relative;
}
.markdown-body hr::after {
content: "· · ·";
position: absolute;
left: 50%;
top: -0.7em;
transform: translateX(-50%);
background: var(--bg-viewer);
color: var(--fg-muted);
padding: 0 1em;
font-size: var(--fs-base);
letter-spacing: 0.4em;
}
/* 表格:优雅的边框 + 表头背景 */
.markdown-body table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--fs-base);
margin: 1.5em 0;
border: 1px solid var(--md-table-border);
border-radius: var(--radius-md);
overflow: hidden;
font-family: var(--font-sans);
}
.markdown-body th,
.markdown-body td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--md-table-border);
text-align: left;
line-height: var(--lh-snug);
}
.markdown-body th {
background: var(--md-table-header-bg);
font-weight: 600;
color: var(--fg-primary);
letter-spacing: var(--tracking-tight);
border-bottom-width: 2px;
}
.markdown-body tr:last-child td {
border-bottom: none;
}
.markdown-body tbody tr:nth-child(even) td {
background: var(--md-table-stripe);
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin: 1em 0;
}
.markdown-body del {
color: var(--fg-tertiary);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* 键盘按键样式 */
.markdown-body kbd {
padding: 2px 6px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 4px;
font-size: 0.85em;
box-shadow: 0 1px 0 var(--border-default);
}

446
src/styles/07-statusbar.css Normal file
View File

@@ -0,0 +1,446 @@
/* ===========================================================================
Statusbar + Toast
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ============================================================
Statusbar
============================================================ */
.statusbar {
display: flex;
align-items: center;
height: var(--h-statusbar);
padding: 0 14px;
background: var(--bg-toolbar);
border-top: 1px solid var(--border-subtle);
/* 状态栏显示路径 / 字数 / 模式 / 文件数,是核心信息展示区,
颜色用 primary最重font-weight: 500 保证不淡。
旧值 secondary 在 13px 下仍偏淡,看着发灰 */
font-size: var(--fs-sm);
font-weight: 500;
color: var(--fg-primary);
gap: var(--space-3);
z-index: var(--z-statusbar);
letter-spacing: var(--tracking-wide);
/* audit fix (P2):与 toolbar 同步去掉 backdrop-filter —— 实色背景上看不出
毛玻璃效果但每帧重算模糊成本真实存在1200×24 px × 60 fps = 大量 GPU。 */
}
/* 三段式状态栏:左段(弱化路径)/ 中段(核心字数)/ 右段chip 簇) */
.status-segment {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.status-segment-left {
flex: 0 1 auto;
min-width: 0;
/* 路径长时允许截断;用 ellipsis 而不是 overflow 隐藏 */
}
.status-segment-center {
flex: 0 0 auto;
justify-content: center;
}
.status-segment-right {
/* 用 0 1 auto 而不是 0 0 auto让右段在窗口窄时也能压缩
不至于把整段 statusbar 顶出去。count chip 收敛后整体宽度更小,
给一个"柔性右段"避免溢出 */
flex: 0 1 auto;
justify-content: flex-end;
}
/* 右段scroll / mode / AI 是核心可交互 chipcount 是辅助信息。
chip 组与 count 之间用细分隔线隔开,形成"主-辅"分组。
比单纯加大 gap 更精致,与左段 [path | divider | meta] 同款语法 */
.status-segment-right .status-divider {
margin: 0 var(--space-1);
}
.status-path {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
font-family: var(--font-mono);
font-size: var(--fs-sm);
/* 路径是状态栏最重要的信息(当前打开的文件位置),用 primary 不发淡。
旧值 secondary 在 13px mono 看着发灰 */
color: var(--fg-primary);
font-weight: 500;
}
/* 路径 chip 可点击态hover 时背景与 accent 文本提示「可点」。
通过 button 元素实现 —— 与 status-chip-clickable 风格统一。 */
.status-path-clickable {
border: none;
background: transparent;
cursor: pointer;
padding: 5px 8px;
margin: 0 -8px;
/* button 默认字体继承 */
font: inherit;
font-family: var(--font-mono);
font-size: var(--fs-sm);
font-weight: 500;
color: var(--fg-primary);
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
}
/* 路径 hover与全局 a 同步用 --accent-text配合 bg-hover 给"可点"反馈。
不要纯 --accent状态栏里长时间停留的路径若 hover 出满彩度文字,眼睛
会被刚指上去那一下"拉住",反而显得突兀。 */
.status-path-clickable:hover {
background: var(--bg-hover);
color: var(--accent-text);
}
.status-path-clickable:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.status-divider {
flex: 0 0 auto;
width: 1px;
height: 10px;
/* 圆角胶囊状的点状分隔符,比纯色线条更精致 */
background: var(--border-default);
border-radius: 1px;
}
.status-meta {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
font-size: var(--fs-sm);
font-weight: 500;
}
.status-spacer {
flex: 1 1 auto;
}
.status-count {
flex: 0 0 auto;
color: var(--fg-tertiary);
font-variant-numeric: tabular-nums;
font-weight: 500;
/* 文件计数是辅助信息("5 个文件"),不再用 bg-hover chip
让右段核心信息(模式 / AI / 滚动更突出count 退到 tertiary 文字。
用户需要时 hover 也能感知,但视觉权重降低 */
padding: 5px 4px;
}
/* 状态栏 chip模式 / 字数 / 滚动进度 */
.status-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 5px 8px;
border-radius: var(--radius-sm);
background: var(--bg-hover);
color: var(--fg-primary);
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: var(--fs-sm);
letter-spacing: var(--tracking-wide);
/* 状态切换平滑saved ↔ unsaved、preview ↔ edit ↔ split 等通过 class / data-mode
切换会发生 background + color 跳变。无 transition 时眼睛会"被光闪一下"——
这正是用户说的"看着不舒服"。120ms ease-out 跟上主节奏,跨态变化
自然衔接。prefers-reduced-motion 不关 transition10-a11y.css 注释),
前庭敏感用户仍得到"可感知反馈"但不至于动画扰神 */
transition:
background var(--transition-fast),
color var(--transition-fast);
}
/* 模式 chip带左侧 SVG 图标暗示「这是切换器」。
旧版只显示文字 + tooltip用户不知道点了会切现在一眼能看出是控件。*/
.status-chip-mode {
gap: 4px;
}
.status-chip-mode svg {
flex: 0 0 auto;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.status-chip-mode:hover svg {
opacity: 1;
}
/* 字数 chip是状态栏的核心信息 —— 文字降彩度(--accent-text
让大段数字 / 字符长时间停留在状态栏里时不"刺眼"。背景仍带 accent 含义。 */
.status-chip-stats {
background: var(--accent-soft);
color: var(--accent-text);
font-weight: 600;
}
/* 可点击 chip状态栏的「模式」现在可直接点击切换 */
.status-chip-clickable {
border: none;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
.status-chip-clickable:hover {
background: var(--bg-active);
filter: brightness(1.08);
}
.status-chip-clickable:active {
transform: scale(0.95);
}
/* 当前 heading高亮显示阅读视图当前的章节名轻量 breadcrumb */
.status-heading {
flex: 0 1 auto;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 260px;
padding: 5px 8px;
border-radius: var(--radius-sm);
color: var(--fg-primary);
font-weight: 500;
font-size: var(--fs-sm);
letter-spacing: var(--tracking-wide);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-heading::before {
/* § 符号暗示「章节」 */
content: "§";
color: var(--fg-tertiary);
font-family: var(--font-mono);
font-size: var(--fs-xs-readable);
flex: 0 0 auto;
}
.status-chip-muted {
background: transparent;
color: var(--fg-tertiary);
font-weight: 500;
}
.status-chip[data-mode="edit"],
.status-chip[data-mode="split"] {
background: var(--accent-soft);
/* 11px 字号 + accent 文字在 accent-soft 背景上仅 ~3.6:1
用正文链接同色相的 --md-link≈5.5:1 on dark替代 */
color: var(--md-link);
}
/* unsaved chip替代原本在 toolbar 上靠脏态按钮传递信息的旧设计。
状态栏上的 chip 是「当前文档的真实状态」,比 toolbar 按钮更准;
warning 色 + 圆点,让用户即使不在编辑视图也能一眼看到「未保存」。
文字用 --warning-text60% 纯色 + 40% fg-primary纯橙小字会因高
chroma 振动,看着累;前 6px 圆点已经承担纯色警示,文字降彩度即可。 */
.status-chip-unsaved {
background: color-mix(in srgb, var(--warning) 16%, transparent);
color: var(--warning-text);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
}
.status-chip-unsaved::before {
/* 圆点标识替代可能过大的图标6px 与 toolbar status-dot 同尺度 */
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warning);
/* 微微辉光,呼应 dirty-pulse 动画语义 */
box-shadow: 0 0 0 2px color-mix(in srgb, var(--warning) 24%, transparent);
}
/* ============================================================
AI chip状态栏新增承载 AI 过程/错误/待确认状态 + dock 开关入口。
设计要点([feedback-ai-tips-into-status-bar]
- idle / success / cancelled → 中性 muted不抢视觉权重与 .status-chip 默认对齐)
- running → accent-soft + 脉冲点,提示「正在工作」
- diff → accent-soft + --md-link让「中间面板有 N 处待确认」一眼可见
- error → danger 色 + 14% transparent bg最强警示但不刺眼
不引入新色彩 token复用 --accent / --accent-soft / --danger / --md-link / --fg-tertiary。
============================================================ */
.status-chip-ai {
/* 默认 idle与 .status-chip 默认同款bg-hover + primary text */
display: inline-flex;
align-items: center;
gap: 5px;
/* AI chip 走 [data-ai-state] 在 idle / running / diff / error / success / cancelled
之间切换,每个态都有自己的 background + color。120ms 平滑过渡,
"AI 思考中..." → "AI: 3 处待确认" → "AI 已完成" 让色阶连成一条线 */
transition:
background var(--transition-fast),
color var(--transition-fast);
}
/* runningaccent-soft 底 + 脉冲点(与原 ai-thinking-pulse 同款 keyframe 搬到下面)。
文字降一档彩度(--accent-text由脉冲点承担纯色
避免「AI 思考中…」纯 accent 小字长时间占据视觉中心看着累。 */
.status-chip-ai[data-ai-state="running"] {
background: var(--accent-soft);
color: var(--accent-text);
}
.status-chip-ai[data-ai-state="running"] .status-chip-ai-text::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: 5px;
border-radius: 50%;
background: var(--accent);
vertical-align: 1px;
animation: status-ai-pulse 1.4s ease-in-out infinite;
}
@keyframes status-ai-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent);
opacity: 0.7;
}
50% {
box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 0%, transparent);
opacity: 1;
}
}
/* errordanger 14% 透明 + danger 文字 —— 强警示但不全屏铺红。
文字降一档彩度避免「AI 调用失败」红字在状态栏里"刺眼"。
左右侧 dot/border 仍是纯 danger 色,整体警示强度不丢。 */
.status-chip-ai[data-ai-state="error"] {
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--danger-text);
}
/* info超长提示词等软警告audit Phase L3-AI 1—— accent 8% 透明 + accent
文字,比 error 弱、比 success 强;不脉冲不闪烁,避免干扰用户继续打字。
文字降彩度,背景继续承载 accent 意味。 */
.status-chip-ai[data-ai-state="info"] {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent-text);
}
/* diff与 mode chip 的 [data-mode] 变体同款accent-soft + --md-link
让「中间面板有 N 处待确认」与「当前是 split / edit 模式」视觉权重一致。
用户已经习惯在状态栏右段看到这类「重要 action chip」。 */
.status-chip-ai[data-ai-state="diff"] {
background: var(--accent-soft);
color: var(--md-link);
font-weight: 600;
}
/* success / cancelled透明底 + tertiary 文字,短暂停留后由下次操作覆盖 */
.status-chip-ai[data-ai-state="success"],
.status-chip-ai[data-ai-state="cancelled"] {
background: transparent;
color: var(--fg-tertiary);
}
/* ============================================================
Toast
============================================================ */
.toast-container {
position: fixed;
/* 跟随状态栏高度,再加一格间距让 toast 浮在状态栏上方不贴边 */
bottom: calc(var(--h-statusbar) + var(--space-2));
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
}
.toast {
pointer-events: auto;
/* 上下 12px / 左右 16px旧值 12px/24px 偏窄icon + 文案的布局需要更多内边距 */
padding: 11px 16px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--fg-primary);
font-size: var(--fs-sm);
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: toast-in 280ms var(--ease-out);
max-width: 480px;
white-space: pre-wrap;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
/* 可选 icon用 grid 而不是 inline-flex 是为了 icon 缺失时布局也稳定 */
display: inline-grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
}
/* icon 缺失时不留空 column —— 用 :has 反向检测浏览器支持,
不支持时降级到 grid-template-columns: 1fr文字独占一列 */
@supports selector(:has(*)) {
.toast:not(:has(.toast-icon)) {
grid-template-columns: 1fr;
}
}
.toast-icon {
/* 图标16px与文字 baseline 视觉对齐 */
flex: 0 0 auto;
width: 16px;
height: 16px;
color: currentColor;
}
.toast.is-success {
border-left: 3px solid var(--success);
color: var(--fg-primary);
}
.toast.is-success .toast-icon {
color: var(--success);
}
.toast.is-warning {
border-left: 3px solid var(--warning);
}
.toast.is-warning .toast-icon {
color: var(--warning);
}
.toast.is-error {
border-left: 3px solid var(--danger);
}
.toast.is-error .toast-icon {
color: var(--danger);
}
.toast.is-info {
border-left: 3px solid var(--accent);
}
.toast.is-info .toast-icon {
color: var(--accent);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

1190
src/styles/08-modal.css Normal file

File diff suppressed because it is too large Load Diff

251
src/styles/09-misc.css Normal file
View File

@@ -0,0 +1,251 @@
/* ===========================================================================
Scrollbar + Context Menu + Boot error
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ============================================================
Scrollbar
============================================================ */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
border: 2px solid transparent;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* ============================================================
Context Menu右键菜单阅读视图 + 编辑器共用)
============================================================ */
.context-menu {
position: fixed;
z-index: var(--z-modal);
min-width: 160px;
padding: var(--space-1);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 2px;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
animation: context-menu-in 120ms var(--ease-out);
user-select: none;
/* 阻止菜单自身的文本被选中 */
-webkit-user-select: none;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-4);
font-size: var(--fs-sm);
font-weight: 500;
color: var(--fg-primary);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
}
/* 右键菜单 hover纯 --accent 文字在 accent-soft 底上"同色相对撞"。
改用 --accent-text背景已明确"hover 中",文字降彩度避免菜单里每一行
hover 时都"跳"出来一份硬彩文字。 */
.context-menu-item:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent-text);
}
.context-menu-item:active:not(:disabled) {
/* 给一点按下反馈 */
background: color-mix(in srgb, var(--accent-soft) 80%, transparent);
}
.context-menu-item:disabled {
color: var(--fg-tertiary);
cursor: not-allowed;
opacity: 0.55;
}
.context-menu-separator {
height: 1px;
background: var(--border-subtle);
margin: var(--space-1) calc(var(--space-1) * 1);
}
@keyframes context-menu-in {
from {
opacity: 0;
transform: scale(0.96) translateY(-2px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* ============================================================
Boot error fallback
============================================================ */
#boot-error {
position: fixed;
inset: 0;
background: var(--bg-app);
color: var(--fg-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-9);
z-index: var(--z-boot-error);
text-align: center;
}
#boot-error h2 {
color: var(--danger);
margin-bottom: var(--space-3);
font-size: var(--fs-2xl);
}
#boot-error p {
margin-bottom: var(--space-3);
color: var(--fg-secondary);
}
/* ============================================================
Focus Mode —— 隐藏所有 chrome只留阅读视图
============================================================ */
/* 工具栏、侧栏、状态栏全部隐藏(保留定位以避免布局重排) */
body[data-focus-mode="true"] .toolbar,
body[data-focus-mode="true"] .sidebar,
body[data-focus-mode="true"] .sidebar-splitter,
body[data-focus-mode="true"] .statusbar,
body[data-focus-mode="true"] .ai-chat-dock {
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast, 120ms ease-out);
}
/*
BUG-3 fix (audit)focus mode 下 .ai-chat-dock 即便被 opacity:0 隐藏,
仍然占据 body grid row 3 的自然高度(~88px把 app-shell 高度挤掉。
这里在 focus mode 下用 display:none 真正释放 grid 空间;
用户退出 focus mode 后 controller 会重新挂 dock见 ai-controller.bindMountPoint
*/
body[data-focus-mode="true"] .ai-chat-dock {
display: none;
}
/*
BUG-2 fix (audit)focus mode + AI 同时打开时dock 已 display:none 但
.app-ai 中间面板仍可见 —— 用户能看 diff 但无法输入新 prompt违反 focus 语义。
这里把 .app-ai 也隐藏。退出 focus 后 ai-controller._syncGridFlag 重新切回
body[data-ai-diff-open="true"],面板恢复可见。
*/
body[data-focus-mode="true"] .app-ai {
display: none;
}
/* 工具栏从顶部滑出默认隐藏hover 顶边热区时滑入) */
body[data-focus-mode="true"] .toolbar {
transform: translateY(-100%);
transition: transform 180ms ease-out, opacity 180ms ease-out;
}
/* 顶部「热区」:默认脱离 grid 流(不影响 body 三行布局),
聚焦模式才显示。body 是 display:grid若让 .focus-hotzone 占普通 grid cell
会抢走 row152px把 .toolbar 挤到 row2、.app-shell 挤到 row3 状态栏行,
整个主区塌掉。默认 display:none + position:fixed 让它不占格、不挡操作;
聚焦模式再开启 display:block + pointer-events:auto 接管事件。 */
.focus-hotzone {
position: fixed;
top: 0; left: 0; right: 0;
width: auto;
height: 14px;
z-index: var(--z-toolbar, 100);
display: none;
pointer-events: none;
}
body[data-focus-mode="true"] .focus-hotzone {
display: block;
pointer-events: auto;
}
body[data-focus-mode="true"]:has(.focus-hotzone:hover) .toolbar {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* 聚焦模式时,去掉 app-shell 的网格第一列(侧栏)和状态栏高度,
整个窗口让位给阅读视图。
状态栏占位约 28px去掉后内容自动撑满
侧栏 width: 0 让 reader/viewer 直接吃满 viewport。 */
body[data-focus-mode="true"] .app-shell {
grid-template-columns: 0 0 minmax(0, 1fr);
}
body[data-focus-mode="true"][data-editor-mode="split"] .app-shell {
grid-template-columns: 0 0 minmax(0, 1fr);
}
body[data-focus-mode="true"][data-editor-mode="split"] .editor-pane,
body[data-focus-mode="true"][data-editor-mode="split"] .splitter {
display: none !important;
}
/* 退出聚焦的提示:聚焦模式时显示一个右下角的「退出」按钮 + 状态条。
平时默认隐藏;鼠标在窗口内悬停 600ms 后显出,避免一进入 focus 就跳提示。 */
.focus-exit-hint {
position: fixed;
bottom: 16px;
right: 16px;
z-index: var(--z-toolbar, 100);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 6px 12px;
font-size: var(--fs-xs);
color: var(--fg-tertiary);
box-shadow: var(--shadow-md);
opacity: 0;
transition: opacity 600ms ease-out;
pointer-events: none;
}
.focus-exit-hint kbd {
padding: 1px 5px;
border: 1px solid var(--border-default);
border-radius: 3px;
background: var(--bg-card);
font-family: var(--font-mono);
font-size: 10px;
margin: 0 1px;
}
body[data-focus-mode="true"]:hover .focus-exit-hint {
opacity: 1;
pointer-events: auto;
}

69
src/styles/10-a11y.css Normal file
View File

@@ -0,0 +1,69 @@
/* ===========================================================================
a11y 工具类skip link + prefers-reduced-motion
自动从 src/styles.css 拆分Stage 4a
=========================================================================== */
/* ---------- skip link (Phase M) ----------
键盘用户 Tab 时第一站直达主内容(#viewer-main跳过 9 个工具栏按钮 +
侧栏若干入口 + 排序切换。
- 默认视觉隐藏(不占布局、不抢 mouse 焦点focus 时滑出右上角
- 用 `:focus-visible` 而非 `:focus`:鼠标点击不触发(避免每次点击
主页都闪一下右上角)
- z-index 高于 toolbartoolbar 顶层 100保证焦点环可见
*/
.skip-link {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
padding: 8px 14px;
background: var(--accent, #4f8cff);
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
/* 默认视觉隐藏但保留可访问性 —— 用 transform 移到屏外,不影响
屏幕阅读器在第一个 Tab 焦点候选里看到它 */
transform: translateY(-200%);
transition: transform 120ms ease-out;
}
.skip-link:focus,
.skip-link:focus-visible {
transform: translateY(0);
outline: 2px solid #fff;
outline-offset: 2px;
}
/* skip link target —— #viewer-main 拿到焦点时去掉默认 outline
(它是"已抵达目的地"的反馈,不是交互元素),但保留 SR focus 语义 */
.viewer:focus,
.viewer:focus-visible {
outline: none;
}
/* ---------- prefers-reduced-motion ----------
关掉持续脉冲 (dirty-pulse / halo / ai-pulse) 与一次性入场动画
modal / toast / ai-diff / ai-chat / outline-slide
用 0.01ms 而非 0 / animation: none保留渲染状态toast/modal 还要显示),
只是让用户「感知不到」动画过程。
兜底使用 * 选择器覆盖未来新增 keyframes避免遗漏。
关键修复:去掉 `transition-duration: 0.01ms !important` —— 之前 blanket 会
杀掉所有 hover/focus 过渡按钮悬停高亮、tab 切换、focus 环),让前庭敏感
用户失去所有交互反馈(与禁用动画的初衷相反)。
transition 是「用户主动操作触发的短暂反馈」(平均 120ms不是「持续脉冲」
这类持续性刺激;前者对前庭敏感用户基本无害,后者才是真正需要关的。
见 [[feedback-ui-detail-polish]] —— 体验与无障碍要兼顾。
*/
@media (prefers-reduced-motion: reduce) {
.btn-icon.is-dirty,
.btn-icon-text.is-dirty { animation: none; }
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0 !important;
}
}

714
src/styles/11-ai.css Normal file
View File

@@ -0,0 +1,714 @@
/* ============================================================================
AI 修改功能(参考 markdown.guanjihuan.com 的设计思路)
- 中间面板 .app-aiapp-shell 的 grid 第 6 列body[data-ai-diff-open] 控制可见
- 底部 dock .ai-chat-dockbody grid 第 3 行app-shell 与 statusbar 之间),
由 grid-row:3 显式钉位;默认 hidden 不占空间,展开时 statusbar 自动让位
- Diff 渲染:行级 +/- + 词级 inline diffequal/removed/added
- 配色与现有 token 一致accent / success / warning / danger / surface
============================================================================ */
/* ============================================================
Body 网格:插入 AI 中间面板
============================================================ */
/*
AI 中间面板在三种模式下的位置(参考 markdown.guanjihuan.com 的
「编辑 / 预览中间夹 AI 审查区」设计):
- preview只显示 viewerAI 在 viewer 右边
- edit只显示 editorAI 在 editor 右边
- spliteditor + viewer 双栏AI 在 #splitter 右边、viewer 左边
—— 即「编辑和预览中间」的位置,用户对照原文档 / 编辑器的同时看 AI diff
AI 默认 hidden由 ai-controller 切到 body[data-ai-diff-open="true"] 时显示。
*/
/*
BUG fixCSS Grid auto-placement 按 DOM 顺序处理 cursorcursor 越过列尾就
wrap 到下一行。.app-ai / .ai-splitter 都是显式钉列,.viewer 是 auto。
在 preview / edit 模式下editor-pane / splitter 是 hidden 不占格cursor
会跳过它们继续前进sidebar→col 1、sb-splitter→col 2再前进到 col 3 时
没有 hidden 项要放、cursor 继续走;如果 .app-ai 显式 col 5 让 cursor
走到 row 末,下面的 .ai-splitter显式 col 4和 .viewerauto就会被
全部挤到 row 2。
grid-auto-flow: dense 让算法回填 row 1 的空位显式项尽量就近放、auto
项也能填中间空隙。配合 DOM 把 .app-ai / .ai-splitter 放在 .viewer 之前
(见 index.html 注释所有模式preview / edit / split / focus
都自动落到正确列。
*/
body[data-ai-diff-open="true"] .app-shell {
grid-auto-flow: dense;
}
/* preview/edit + 仅 AI5 列
sidebar(1) | sb-splitter(2) | main(3) | ai-splitter(4) | AI(5) */
body[data-ai-diff-open="true"] .app-shell {
grid-template-columns:
var(--sidebar-width)
4px
minmax(0, 1fr)
var(--ai-splitter-width, 4px)
minmax(220px, var(--w-ai, 360px));
}
/* split + 仅 AI7 列 —— AI 在 #splitter 和 viewer 之间(中间位置)
sidebar(1) | sb-splitter(2) | editor(3) | #splitter(4) | AI(5) | ai-splitter(6) | viewer(7)
- editor 用 px (--w-editor)viewer 用 1fr 自动填剩余 —— 拖 ai-splitter 时 AI 增长 px、
fr space 缩小viewer 缩小但 editor 不动;拖 #splitter 时 editor 增长 px、
viewer 缩小、AI 不动。
- ai-splitter 控制 AI 宽度px独立
- editor 宽度由 mountSplitter 在 .app-shell 的 style 上写 --w-editor */
body[data-ai-diff-open="true"][data-editor-mode="split"] .app-shell {
grid-template-columns:
var(--sidebar-width)
4px
minmax(280px, var(--w-editor, 50%))
4px
minmax(220px, var(--w-ai, 360px))
var(--ai-splitter-width, 4px)
minmax(0, 1fr);
}
/* preview/edit + AI5 列(见上) */
/*
聚焦模式focus-mode优先于 AI 网格 —— 11-ai.css 比 09-misc.css 后加载,
默认会被覆盖成「侧栏 + AI」。这里用更高特异性的选择器把侧栏收起
只留「viewer + AI 中间面板」两列。
focus 模式下 AI 面板单独占一列split 也退化成单栏(与 09-misc.css 的 focus 简化一致)。
*/
body[data-focus-mode="true"][data-ai-diff-open="true"] .app-shell,
body[data-focus-mode="true"][data-ai-diff-open="true"][data-editor-mode="split"] .app-shell {
grid-template-columns:
0
0
minmax(0, 1fr)
0
var(--ai-splitter-width, 4px)
minmax(220px, var(--w-ai, 360px));
}
/*
窄窗口(< 720px让 AI 中间面板与 viewer 共享空间,避免 viewer 被挤到 0px。
同样限制主进程的最小宽度为 600实际只影响 600~720 区间。
Bug-1 fix (audit):这里必须把 .ai-splitter 也一起 display:none —— 否则
媒体查询把 grid-template 压成 3 列,但 .ai-splitter 仍带 grid-column:5/6/7/8
CSS Grid 会为了满足显式列号自动扩展隐式列,把 4px 宽的 splitter 推到
viewer 旁边成为一个"孤儿"光标条。app.js 的 mountAiSplitter 命中检查
也已经依赖这个隐藏(否则窄屏下拖一个看不见的 splitter 写入 --w-ai
*/
@media (max-width: 720px) {
body[data-ai-diff-open="true"] .app-shell,
body[data-ai-diff-open="true"][data-editor-mode="split"] .app-shell {
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
body[data-ai-diff-open="true"] .app-ai,
body[data-ai-diff-open="true"] .ai-splitter {
display: none; /* 窄屏默认关闭 AI 中间面板;用户可用 toolbar AI 按钮查看 */
}
}
/*
BUG-1 fix (audit).app-ai 必须显式声明 grid-column否则会被
grid-auto-flow: row 推到 4px 间隔列,宽度被压成 4px看似"消失了")。
模板列数随模式不同,所以这里按场景分别钉列。
新设计AI 在 split 模式放中间)的 grid 列映射:
| 场景 | 列数 | ai-splitter | app-ai |
| ------------------- | ---- | ----------- | ------ |
| preview/edit + AI | 5 | 4 | 5 |
| split + AI | 7 | 6 | 5 | ← AI 在 #splitter 和 viewer 之间
| focus + AI | 6 | 5 | 6 | ← focus 退化为单栏
*/
/* preview/edit + AI5 列 */
body[data-ai-diff-open="true"][data-editor-mode="preview"] .ai-splitter,
body[data-ai-diff-open="true"][data-editor-mode="edit"] .ai-splitter {
grid-column: 4;
}
body[data-ai-diff-open="true"][data-editor-mode="preview"] .app-ai,
body[data-ai-diff-open="true"][data-editor-mode="edit"] .app-ai {
grid-column: 5;
}
/* split + AIAI 嵌在 #splitter(col 4) 和 viewer(col 7) 之间
7 列sb(1) | sb-splitter(2) | editor(3) | #splitter(4) | AI(5) | ai-splitter(6) | viewer(7) */
body[data-ai-diff-open="true"][data-editor-mode="split"] .app-ai {
grid-column: 5;
}
body[data-ai-diff-open="true"][data-editor-mode="split"] .ai-splitter {
grid-column: 6;
}
/* focus + AIsplit 在 focus 下退化为单栏6 列 */
body[data-focus-mode="true"][data-ai-diff-open="true"] .ai-splitter {
grid-column: 5;
}
body[data-focus-mode="true"][data-ai-diff-open="true"] .app-ai {
grid-column: 6;
}
/* ============================================================
.ai-splitter 拖动条(视觉 4px + 命中区扩展)
============================================================ */
/*
与 #splitter / #sidebar-splitter 同套设计:视觉宽度 4px但命中区通过
::before / ::after 伪元素左右各扩 6px让鼠标接近边缘就能抓住。
视觉上的「薄线」用渐变显示 hover 态。
*/
.ai-splitter {
background: transparent;
cursor: col-resize;
/* 默认位置grid 自定grid-column 由各模式场景单独钉) */
position: relative;
/* 阻止浏览器默认拖选 / 文本选择 */
user-select: none;
touch-action: none;
}
.ai-splitter::before {
content: '';
position: absolute;
top: 0; bottom: 0;
/* 视觉条居中,宽度 4px */
left: 50%;
width: var(--ai-splitter-width, 4px);
transform: translateX(-50%);
background: var(--border-subtle);
transition: background var(--transition-fast, 120ms ease-out);
}
.ai-splitter:hover::before,
.ai-splitter.is-dragging::before {
background: var(--accent);
}
.ai-splitter:focus-visible {
outline: none;
}
.ai-splitter:focus-visible::before {
box-shadow: 0 0 0 2px var(--accent);
}
/*
focus 模式下 AI 面板也整体隐藏(与 .app-ai 同步)—— 用户已经进入
「只看 viewer」状态splitter 出现反而提醒宽度可调,分散注意力。
*/
body[data-focus-mode="true"] .ai-splitter {
display: none;
}
/* ============================================================
.app-ai 中间面板
============================================================ */
.app-ai {
background: var(--bg-sidebar);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
/* 入场动画:从右往左 8px淡入 150ms */
animation: ai-diff-enter 150ms ease-out;
}
@keyframes ai-diff-enter {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
/* ============================================================
Diff 面板头部
============================================================ */
.ai-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
gap: var(--space-3);
}
.ai-diff-title {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--fg-primary);
margin: 0 0 2px;
}
.ai-diff-summary {
/* 11px + --fg-muted 在 --bg-card 上仅 ~3.2:1dark default低于 AA。
这是"5 处变更" / "+12 / -3"这类用户主动扫的统计数字,提一档到
--fg-tertiary~5.4:1让 11px 信息也能稳定看清。
字重 500 加粗辅助,让小字号+低饱和组合仍可读 */
font-size: var(--fs-xs);
color: var(--fg-tertiary);
font-weight: 500;
margin: 0;
}
.ai-diff-actions {
display: flex;
align-items: center;
gap: 6px;
}
.ai-diff-apply-all {
height: 26px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--btn-on-accent);
border: 1px solid var(--accent);
font-size: var(--fs-xs-readable);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.ai-diff-apply-all:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.ai-diff-apply-all:active {
transform: scale(0.98);
}
.ai-diff-apply-all:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ai-diff-dismiss {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: transparent;
color: var(--fg-muted);
border: 1px solid var(--border-subtle);
font-size: var(--fs-base);
line-height: 1;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
}
.ai-diff-dismiss:hover {
color: var(--fg-primary);
border-color: var(--border-default);
background: var(--accent-soft);
}
.ai-diff-dismiss:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================
Diff 主体(行级 + 词级)
============================================================ */
.ai-diff-fulltext {
flex: 1;
min-height: 0;
overflow: auto;
font-family: var(--font-mono, ui-monospace, Menlo, Consolas, monospace);
font-size: var(--fs-xs-readable);
line-height: 1.55;
padding: 8px 0;
}
/* 单行:行号 + +/-/space 前缀 + 文本 */
.ai-diff-line {
display: grid;
/* 行号列从 36px 收到 32px999 行以内(~32px 够放 3 位数字 + 6px padding
已经覆盖 99% 文档;超过 999 的极端情况用 ellipsis 截断 */
grid-template-columns: 32px 16px 1fr;
align-items: baseline;
padding: 1px 14px 1px 0;
white-space: pre-wrap;
word-break: break-word;
}
.ai-diff-line-added { background: color-mix(in srgb, var(--success) 12%, transparent); }
.ai-diff-line-removed { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.ai-diff-line-context { color: var(--fg-secondary); }
.ai-diff-line-plain { grid-template-columns: 1fr; }
.ai-diff-line-number {
color: var(--fg-muted);
text-align: right;
padding-right: 8px;
user-select: none;
font-variant-numeric: tabular-nums;
/* audit fix (CSS P1 / diff line numbers opacity removed)
旧实现 opacity: 0.65 在 --fg-muted 之上再叠一层 alpha。对比度从原本
约 4.5:1 跌到 ~3:1叠透明度 + 暗色底line number 看着像「水印」
而不是结构信息 —— 用户来回滚 diff 时眼睛定位行号很费劲。--fg-muted
本身在各 palette 都已经过 AA 调整02-palettes.css 注释),单独用
这个色就足够弱化但仍可读,去掉 opacity 让对比度回到 palette 设计
目标值。
*/
}
.ai-diff-line-prefix {
color: var(--fg-muted);
text-align: center;
user-select: none;
}
.ai-diff-line-added .ai-diff-line-prefix { color: var(--success); font-weight: 600; }
.ai-diff-line-removed .ai-diff-line-prefix { color: var(--danger); font-weight: 600; }
.ai-diff-line-text {
color: var(--fg-primary);
}
/* 词级 inline diff */
.ai-diff-segment { white-space: pre-wrap; }
.ai-diff-segment-removed {
/* 词级删除:底色 + 删除线。
底色透明度从 24% 降到 18%,因为叠加行级 12% 后总透度接近 30%
行内 highlight 看起来太重。叠两层后 18% 让"行"和"词"的视觉权重拉开 */
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--fg-primary);
border-radius: 2px;
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--danger) 60%, transparent);
text-decoration-thickness: 1px;
}
.ai-diff-segment-added {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--fg-primary);
border-radius: 2px;
}
/* ============================================================
Diff Region修改块每个 region 一个 section + 头部 + 主体
============================================================ */
.ai-diff-region {
/* region 之间靠 border-top + margin 8px 隔开;不要底部边框,
否则下一个 region 的顶部会和自己的底部边线"撞"成 2px */
border-top: 1px solid var(--border-subtle);
margin: 8px 0;
/* region 是"修改块",用 30% bg-elevated 微微抬起,让眼睛能快速定位。
配合圆角 + 微阴影让每个 region 像一张卡片 */
background: color-mix(in srgb, var(--bg-elevated) 30%, transparent);
border-radius: var(--radius-sm);
}
.ai-diff-region:first-child {
/* 第一个 region 顶部贴顶,不需要 border-top */
border-top: none;
margin-top: 0;
}
.ai-diff-region-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: color-mix(in srgb, var(--bg-elevated) 60%, transparent);
border-bottom: 1px solid var(--border-subtle);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.ai-diff-region-apply {
height: 24px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.ai-diff-region-apply:hover:not([disabled]) {
background: var(--accent);
color: var(--btn-on-accent);
}
.ai-diff-region-apply:active:not([disabled]) {
transform: scale(0.98);
}
.ai-diff-region-apply[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* audit fixconflict 区域新增的"跳过此处"按钮 —— 比 apply 更弱的视觉权重,
让用户优先尝试 apply失败时才有第二选项。 */
.ai-diff-region-actions {
display: inline-flex;
gap: 6px;
align-items: center;
}
.ai-diff-region-skip {
height: 24px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: transparent;
/* 与 apply 按钮成对apply 用 accent 主色skip 用 muted 次色 —— 形成视觉权重差,
让用户在冲突时优先尝试 applyskip 才是退路 */
color: var(--fg-secondary);
border: 1px solid var(--border-subtle);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.ai-diff-region-skip:hover {
background: var(--bg-hover);
color: var(--fg-primary);
border-color: var(--border-default);
}
.ai-diff-region-skip:active {
transform: scale(0.98);
}
.ai-diff-conflict {
font-size: var(--fs-xs);
/* 文字降彩度(--warning-text"冲突"两字若用纯橙小字与行内 +/- 同色
saturate易在密集 diff 块里"跳出来"。左侧 +/- 行号前缀用纯色,
文字让一档,保持可识别但不抢戏。 */
color: var(--warning-text);
margin-left: auto;
margin-right: 8px;
}
.ai-diff-region-body {
/* 内部每行延用 .ai-diff-line */
}
/* ============================================================
空状态 / 普通回复AI 返回纯文本,不改文档时)
============================================================ */
.ai-diff-empty {
/* 上下加大间距,左右收敛到 16px让中间文案看起来「居中呼吸」。
用 flex column + gap 而不是一个段落,方便未来插入图标 */
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 32px 16px;
color: var(--fg-tertiary);
font-size: var(--fs-sm);
text-align: center;
line-height: var(--lh-relaxed);
}
/* (audit fix Round 9: .ai-diff-empty-icon / .ai-diff-empty-title /
.ai-diff-empty-hint / .ai-reply-plain 全部为零引用死 CSS —— ai-diff-panel.js
只用 .ai-diff-empty 容器suffix 三个变体没有调用方) */
.ai-reply-content {
flex: 1;
min-height: 0;
overflow: auto;
/* 上下 18px左右 16px比之前的 12px/14px 更舒展,
AI 回复通常字数多,给点上下呼吸让长文不挤 */
padding: 18px 16px;
}
.ai-reply-content.markdown-body {
/* 复用 .markdown-body 的字体/排版(参见 06-viewer.css */
}
/* (audit fix Round 9: .ai-reply-plain 死 CSS已在文件头注释) */
/* ============================================================
底部 AI 对话 dock
挂在 app-shell 与 statusbar 之间,侧栏式 UI 风格
============================================================ */
.ai-chat-dock {
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border-top: 1px solid var(--border-subtle);
/* 上下 8px 收紧 dock 高度:原来 10px 让 dock 总高 76px输入框下沿到
状态栏之间有 ~34px 空白(含 status row 始终保留的 16px视觉偏空。
收到 8px 后空闲高度 60px 左右,更紧凑又不挤 */
padding: 8px 16px;
gap: 4px;
/* 入场动画:自下而上淡入 */
animation: ai-chat-enter 150ms ease-out;
}
@keyframes ai-chat-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-chat-form-row {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: 8px;
min-height: 32px;
}
.ai-chat-toggle {
width: 28px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: transparent;
color: var(--fg-muted);
border: 1px solid var(--border-subtle);
font-size: var(--fs-base);
line-height: 1;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
}
.ai-chat-toggle:hover {
color: var(--fg-primary);
border-color: var(--border-default);
background: var(--accent-soft);
}
.ai-chat-input-wrap {
display: flex;
align-items: center;
min-width: 0;
height: 32px;
}
.ai-chat-input {
flex: 1;
min-width: 0;
height: 32px;
/* padding: 0 10px 完整重置 UA 默认 paddingChromium 给 input 是 padding: 1px 2px
避免 1px 上下 padding 让 height 计算偏移。border-box 下 32 - 2(border) = 30 内容区。 */
padding: 0 10px;
background: var(--bg-viewer);
color: var(--fg-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--fs-sm);
/* line-height: 30 让单行文字在 30px 内容区内垂直居中(避免 line-height: normal
按 1.2× 计算导致基线偏移)。 */
line-height: 30px;
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.ai-chat-input:hover:not(:focus) {
border-color: var(--border-default);
}
.ai-chat-input:focus {
outline: none;
border-color: var(--accent);
background: var(--bg-app);
/* 微微发光,让 focus 视觉反馈更明确,配合 outline 不会"双圈" */
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* 键盘焦点反馈:移除默认 outline 后键盘用户Tab 切换)看不到当前位置。
:focus-visible 只在键盘聚焦时触发,鼠标点击不显示,保持视觉简洁。
修复 audit #9之前 box-shadow 在 :focus 上无条件生效,键盘聚焦时与下方
outline 同时存在造成「3px 外发光 + 2px 描边」双圈;现在 :focus-visible
时清掉 box-shadow只保留更明确的 outline。 */
.ai-chat-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
box-shadow: none;
}
.ai-chat-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-chat-input::placeholder {
/* 从 fg-muted暗色 ~2:1 几乎看不见)提到 fg-secondary~8.5:1
与搜索框一致 —— AI 输入也是核心入口placeholder 必须看清 */
color: var(--fg-tertiary);
}
.ai-chat-submit {
height: 32px;
/* P2-6 fix (audit)min-width 锁定宽度,防止"生成修改"4字
"取消"/"取消中…"2/4字切换时按钮宽度跳变 */
min-width: 76px;
padding: 0 14px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--btn-on-accent);
border: 1px solid var(--accent);
font-size: var(--fs-sm);
font-weight: 500;
/* 与 input 一致32(总高) - 2(border) = 30 内容区,让按钮文字与输入框
文字在同一基线垂直居中。 */
line-height: 30px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.ai-chat-submit:hover:not([disabled]) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.ai-chat-submit:active:not([disabled]) {
transform: scale(0.98);
}
.ai-chat-submit[data-mode="cancel"] {
background: transparent;
color: var(--warning);
border-color: var(--warning);
}
.ai-chat-submit[data-mode="cancel"]:hover:not([disabled]) {
background: var(--warning);
color: var(--bg-viewer);
}
.ai-chat-submit[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.ai-chat-submit:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================
AI 按钮激活态(侧栏底部)
当 AI 面板展开时给 btn-ai 一个明显区别于"主操作描边态"的实心强调态:
- 关闭:沿用 .sidebar-new-btn 的描边 + 8% accent 底(与「新建笔记」同款)
- 打开:实心 accent 底 + 白字 + accent 边,让用户一眼看出 AI dock 已开
用 #btn-ai.sidebar-new-btn 这种 ID+class 组合是为了 specificity 高过
.sidebar-new-btn:hover0,2,0保证 hover 时不会把"已打开"的视觉态冲掉。
============================================================ */
#btn-ai.sidebar-new-btn[aria-pressed="true"],
#btn-ai.sidebar-new-btn[aria-pressed="true"]:hover {
color: var(--btn-on-accent, #fff);
background: var(--accent);
border-color: var(--accent);
}
#btn-ai.sidebar-new-btn[aria-pressed="true"]:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}

185
src/styles/12-print.css Normal file
View File

@@ -0,0 +1,185 @@
/* ===========================================================================
打印样式(@media print
自动从 src/styles.css 拆分audit #60 critical fix
Notes 是阅读器,用户打印长笔记很常见。默认打印会把工具栏 / 侧栏 /
状态栏 / AI 面板 / 模态 / toast / 焦点模式提示全部印出来——
既浪费纸,又把内容挤到不可读的边角。
目标:打印 = 只剩干净的阅读视图 + 黑白配色 + 合理边距。
=========================================================================== */
@media print {
/* 隐藏所有应用 chrome —— 阅读视图之外都不进纸 */
.toolbar,
.sidebar,
.sidebar-splitter,
.statusbar,
.app-ai,
.ai-splitter,
.ai-chat-dock,
.modal-overlay,
.toast-container,
.focus-hotzone,
.focus-exit-hint,
.boot-error,
#modal-root {
display: none !important;
}
/* 重置 app-shell 网格 —— 移除多栏布局,让正文占满整页 */
.app-shell,
body {
display: block !important;
grid-template-columns: none !important;
grid-template-rows: none !important;
background: white !important;
color: black !important;
}
/* 阅读视图:完整内容 + 合理边距 + 取消 max-width 限制 */
.viewer,
.editor-pane,
.markdown-body {
background: white !important;
color: black !important;
max-width: none !important;
width: 100% !important;
height: auto !important;
overflow: visible !important;
padding: 0 !important;
margin: 0 !important;
}
/* 阅读正文1.5cm 边距 + serif fallback用户机器可能没装 Source Serif Pro
audit fix (CSS P2 / print doubled margin):之前在 .markdown-body 上又
叠一层 padding:1.5cm,与 @page margin:1.5cm 1.8cm 加起来 = 上下 3cm
/ 左右 3.3cm,每页可用空间被吃光。@page margin 已经把页面边距定好,
这里只保留字体 / 字号 / 行高 / 链接色,不该再动 padding。 */
.markdown-body {
font-family: var(--font-reading, 'Source Serif Pro', 'Noto Serif CJK SC', Georgia, 'Times New Roman', serif) !important;
font-size: 11pt !important;
line-height: 1.5 !important;
color: black !important;
}
/* Markdown 内容:纯黑白 + 强对比 + 取消背景色(节省墨水) */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: black !important;
background: none !important;
page-break-after: avoid;
page-break-inside: avoid;
}
.markdown-body h1 { font-size: 22pt !important; }
.markdown-body h2 { font-size: 18pt !important; }
.markdown-body h3 { font-size: 14pt !important; }
.markdown-body p,
.markdown-body li,
.markdown-body blockquote {
color: black !important;
orphans: 3;
widows: 3;
}
.markdown-body a {
color: black !important;
text-decoration: underline;
}
/* 打印时显示 URL让读者知道链接指向哪里—— 链接文本后面追加 href
audit fix (#8 shared):加 overflow-wrap: anywhere + word-break: break-all
否则长 URLgithub issue 链接、含 query string 的 API 文档等)在 A4 宽度下
会溢出页面右侧,超出可打印边距;保留 word-break 在伪元素上也生效。 */
.markdown-body a[href]::after {
content: ' (' attr(href) ')';
font-size: 0.85em;
color: #555 !important;
overflow-wrap: anywhere;
word-break: break-all;
}
/* 但文内锚点(#xxx不显示 —— 没意义 */
.markdown-body a[href^="#"]::after {
content: '';
}
.markdown-body blockquote {
border-left: 3px solid #888 !important;
background: none !important;
padding-left: 12pt !important;
}
.markdown-body pre,
.markdown-body code {
color: black !important;
background: #f4f4f4 !important;
border: 1px solid #ddd !important;
font-family: 'Consolas', 'Menlo', 'Courier New', monospace !important;
font-size: 9.5pt !important;
page-break-inside: avoid;
}
.markdown-body code {
/* 行内 code 不需要 box border看起来更接近正常文本 */
border: none !important;
background: #f4f4f4 !important;
padding: 0 3pt !important;
}
.markdown-body table {
border-collapse: collapse !important;
page-break-inside: avoid;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #888 !important;
background: white !important;
color: black !important;
padding: 4pt 8pt !important;
}
.markdown-body img {
max-width: 100% !important;
height: auto !important;
page-break-inside: avoid;
}
.markdown-body hr {
border: none !important;
border-top: 1px solid #888 !important;
background: none !important;
margin: 12pt 0 !important;
}
/* 避免在标题 / 表格 / 图片内分页 —— 阅读体验更连贯 */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body figure,
.markdown-body pre,
.markdown-body table,
.markdown-body img,
.markdown-body blockquote {
page-break-inside: avoid;
}
/* 取消所有 hover/focus 视觉态(打印场景没意义,且会浪费墨水) */
*,
*::before,
*::after {
box-shadow: none !important;
text-shadow: none !important;
transition: none !important;
animation: none !important;
}
/* 页面设置A4 + 合理边距 */
@page {
size: A4;
margin: 1.5cm 1.8cm;
}
}

44
src/toolbar.js Normal file
View File

@@ -0,0 +1,44 @@
// 工具栏组件
// 绑定主题切换 / 置顶 / 设置 / 窗口控件按钮的事件。
//
// 与 Todo List 的 toolbar.js 区别:去掉了「添加 todo」+ 搜索框 + 视图切换,
// 只留下窗口控制 + 应用级开关。
export class Toolbar {
/**
* @param {object} options
* @param {object} options.elements - 按钮元素引用
* @param {() => void} options.onThemeClick
* @param {() => void} options.onPinClick
* @param {() => void} options.onSettingsClick
* @param {() => void} options.onResetLayoutClick
* @param {() => void} options.onMinimize
* @param {() => void} options.onToggleMaximize
* @param {() => void} options.onClose
*/
constructor({ elements, onThemeClick, onPinClick, onSettingsClick, onResetLayoutClick, onMinimize, onToggleMaximize, onClose }) {
this.elements = elements;
if (elements.btnTheme && onThemeClick) {
elements.btnTheme.addEventListener('click', onThemeClick);
}
if (elements.btnPin && onPinClick) {
elements.btnPin.addEventListener('click', onPinClick);
}
if (elements.btnSettings && onSettingsClick) {
elements.btnSettings.addEventListener('click', onSettingsClick);
}
if (elements.btnResetLayout && onResetLayoutClick) {
elements.btnResetLayout.addEventListener('click', onResetLayoutClick);
}
if (elements.btnMin && onMinimize) {
elements.btnMin.addEventListener('click', onMinimize);
}
if (elements.btnMax && onToggleMaximize) {
elements.btnMax.addEventListener('click', onToggleMaximize);
}
if (elements.btnClose && onClose) {
elements.btnClose.addEventListener('click', onClose);
}
}
}

341
src/utils.js Normal file
View File

@@ -0,0 +1,341 @@
// 共享小工具 —— 文本格式化与转义
//
// 单一来源:之前 formatSize / formatDate 在 file-list.js 和
// markdown-viewer.js 各定义了一份escapeHtml / escapeAttr 在
// settings-dialog.js 和 confirm-dialog.js 也重复了一遍。
// 时间常量 —— formatRelativeTime 内部需要「秒」「天」量级;提出来便于阅读
const MS_PER_SEC = 1000;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* 文件大小 -> 人类可读字符串B / KB / MB
* @param {number} bytes
*/
export function formatSize(bytes) {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* mtime -> 短日期字符串YYYY-MM-DD HH:mm
* @param {number} mtimeMs
*/
export function formatDate(mtimeMs) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
const d = new Date(mtimeMs);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${y}-${mo}-${da} ${h}:${mi}`;
}
/**
* mtime -> 相对时间字符串(用于侧栏文件列表的密度提升)
*
* 阈值梯度(基于 now
* < 60s → "刚刚"
* < 60min → "N 分钟前"
* < 24h且跨日→ "昨天"
* < 24h同日 → "N 小时前"
* < 7d → "N 天前"
* ≥ 7d → 回落到 formatDate绝对时间
*
* @param {number} mtimeMs
* @param {number} [nowMs=Date.now()]
* @returns {string}
*/
export function formatRelativeTime(mtimeMs, nowMs = Date.now()) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) nowMs = Date.now();
const diffMs = Math.max(0, nowMs - mtimeMs);
const sec = Math.floor(diffMs / MS_PER_SEC);
if (sec < 60) return '刚刚';
const min = Math.floor(sec / 60);
if (min < 60) return `${min} 分钟前`;
// 跨日判断:基于本地"今天 0:00"的差值
const mtime = new Date(mtimeMs);
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfMtimeDay = new Date(mtime.getFullYear(), mtime.getMonth(), mtime.getDate()).getTime();
const daysApart = Math.round((startOfToday - startOfMtimeDay) / MS_PER_DAY);
if (daysApart === 0) {
const hr = Math.floor(min / 60);
return `${hr} 小时前`;
}
if (daysApart === 1) return '昨天';
if (daysApart < 7) return `${daysApart} 天前`;
// ≥ 7d回落绝对时间避免"183 天前"这种无意义表达
return formatDate(mtimeMs);
}
/**
* 转义 HTML 文本节点内容
*/
export function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 安全渲染 markdown —— markdown-viewer / ai-diff-panel 共享的入口。
*
* 优先走 preload 暴露的 window.api.renderMarkdownmarked + DOMPurify 走主进程加载,
* XSS 防护集中)。失败 / 不可用时退化为转义后的 <pre>,永远不返回未转义的原文。
*
* @param {string} markdown
* @returns {string} 安全 HTML可放入 markdown-body 容器)
*/
export function safeRenderMarkdown(markdown) {
if (typeof window !== 'undefined'
&& window.api
&& typeof window.api.renderMarkdown === 'function') {
try {
const result = window.api.renderMarkdown(markdown || '');
// audit fix (Round 8 R-1)renderMarkdown 契约是 string但 jsdom 单测或
// 未来 mock 替换可能返回非字符串。typeof guard 防 `null / undefined / 数字`
// 直接 setBody 触发下游 DOM 解析错或 .innerHTML=null 抛错。**必须用 typeof
// 不能用 `||`**,因为 '' 是空 markdown 的合法返回preload 对空字符串返回 ''
// 不是 <pre>)—— `result ||` 会把合法空 markdown 误降级。
if (typeof result === 'string') return result;
console.warn('[utils] renderMarkdown 返回非字符串,降级为 <pre>', typeof result);
} catch (e) {
console.warn('[utils] renderMarkdown 失败,降级为 <pre>', e && e.message);
}
} else if (typeof window !== 'undefined') {
console.warn('[utils] window.api.renderMarkdown 不可用,降级为 <pre>');
}
return `<pre class="md-render-fallback">${escapeHtml(markdown || '')}</pre>`;
}
/**
* 转义 HTML 属性值。
*
* 与 escapeHtml 等价 —— escapeHtml 已经覆盖了 `"` 和 `'`(→ &quot; / &#39;
* 直接放进属性上下文(`attr="..."` / `attr='...'`)都是安全的。
*
* 这里保留独立导出有两个目的:
* - 语义标识:调用方看到 escapeAttr 就知道这是放进 HTML 属性的;
* review 时一眼能区分「文本节点」与「属性值」两个不同的转义场景
* - 未来扩展:若要给属性转义加更激进的规则(如拒绝控制字符),
* 改这一处即可,不必把所有 escapeHtml 调用点扫一遍
*
* @param {string} s
*/
export function escapeAttr(s) {
return escapeHtml(s);
}
/**
* 当前是否在 Windows 上 —— 用于大小写不敏感比较。
*
* 优先用 navigator.userAgentDataChromium 90+ 官方接口,未来主流);
* 退回 navigator.platform已废弃但 Electron 仍可用);
* 再退回 UA 字符串嗅探。
*
* 注意:本函数跑在 renderer 里process 不存在 —— 不能写 process.platform。
*
* @returns {boolean}
*/
function isWindows() {
if (typeof navigator === 'undefined') return false;
// Chromium 90+: userAgentData.platform 形如 'Windows' / 'macOS' / 'Linux'
if (navigator.userAgentData && typeof navigator.userAgentData.platform === 'string') {
return navigator.userAgentData.platform.toLowerCase().includes('win');
}
// navigator.platform 已被 W3C 标记废弃,但 Chromium 内核仍支持Electron 渲染端可用
if (typeof navigator.platform === 'string' && navigator.platform) {
if (/^win/i.test(navigator.platform)) return true;
}
// 兜底UA 字符串嗅探
return /Windows NT/i.test(navigator.userAgent || '');
}
/**
* 检查 child 路径是否在 parent 目录之内(防御性边界检查)。
* 不依赖文件系统(不解析 symlink只做字符串前缀比对。
* Windows 下忽略大小写。
*
* 与 main.js 的 isWithinDataDir 行为一致 —— 这里只是 renderer 端的快速兜底。
*
* 边界:
* - parent 为根目录POSIX '/'、Windows 'C:\'norm 会把末尾分隔符
* 剥掉变成空串或纯盘符;这种「根」视为「任何非空路径都在其内」。
* - child 为空串 → 不视为在任何目录内(路径非法)。
*
* @param {string} child
* @param {string} parent
*/
export function isPathInside(child, parent) {
if (!child || !parent) return false;
const norm = (s) => s.replace(/\\/g, '/').replace(/\/+$/, '');
let a = norm(parent);
let b = norm(child);
if (!b) return false; // 空 child 路径不视为合法
if (isWindows()) {
a = a.toLowerCase();
b = b.toLowerCase();
}
// 根目录边界norm 之后 a 是空串POSIX '/'或仅含盘符Windows 'C:')。
// 这两种情况下任何非空 child 都在 parent 内 —— 视 a 为「空前缀」。
if (a === '' || /^[a-z]:$/i.test(a)) {
return true;
}
if (a === b) return true;
return b.startsWith(a + '/');
}
/**
* 平台相关的路径相等比较。
*
* Windows 路径大小写不敏感NTFS 默认),但 macOS / Linux 大小写敏感。
* 同样两条路径在 Windows 上要求 .toLowerCase() 相等;其他平台要求严格相等。
*
* 用法sidebar / file-list 把当前打开文件state.currentFile.path可能是
* 用户输入的 case与磁盘条目fs 返回的真实 case做匹配时
* `state.currentFile.path === entry.path` 在 Windows 上经常 false
* 导致高亮 / 选中态错位。统一走 pathsEqual 兜住。
*
* @param {string|null|undefined} a
* @param {string|null|undefined} b
* @returns {boolean}
*/
export function pathsEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
return isWindows() ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* 取路径所在目录(不含末尾分隔符)。
* 纯字符串操作renderer 里没有 path 模块可用。
* @param {string} filePath
* @returns {string}
*/
export function dirnameOf(filePath) {
if (!filePath || typeof filePath !== 'string') return '';
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
if (idx < 0) return '';
// 保留 POSIX 根目录的那个 '/'
if (idx === 0) return '/';
return filePath.slice(0, idx);
}
/**
* 取路径的最后一段(文件名)。
* @param {string} filePath
* @returns {string}
*/
export function basenameOf(filePath) {
if (!filePath || typeof filePath !== 'string') return '';
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
return idx < 0 ? filePath : filePath.slice(idx + 1);
}
/**
* 把相对路径按 baseDir 解析成绝对路径,并折叠 '.' / '..'。
*
* 这里必须自己实现而不能简单地 split+joinPOSIX 绝对路径的第一段是空串,
* 过滤空串再 join 会把开头的 '/' 吃掉('/home/a' → 'home/a')。
* 所以先记住「根前缀」('/' 或 'C:\'),只对其后的段做折叠。
*
* @param {string} baseDir - 绝对目录
* @param {string} relative - 相对路径(也接受绝对路径,此时原样规范化)
* @returns {string|null} 绝对路径;越过根目录时返回 null
*/
export function resolvePath(baseDir, relative) {
if (typeof relative !== 'string' || !relative) return null;
const winAbs = /^[a-z]:[\\/]/i.test(relative);
const posixAbs = relative.startsWith('/');
const input = (winAbs || posixAbs) ? relative : `${baseDir}/${relative}`;
// 提取根前缀Windows 盘符 或 POSIX '/'
let root = '';
let rest = input;
const driveMatch = /^([a-z]:)[\\/]/i.exec(input);
if (driveMatch) {
root = driveMatch[1] + '\\';
rest = input.slice(driveMatch[0].length);
} else if (input.startsWith('/')) {
root = '/';
rest = input.slice(1);
}
const sep = root.includes('\\') ? '\\' : '/';
const stack = [];
for (const part of rest.split(/[\\/]/)) {
if (part === '' || part === '.') continue;
if (part === '..') {
if (stack.length === 0) return null; // 越过根目录 → 拒绝
stack.pop();
} else {
stack.push(part);
}
}
return root ? root + stack.join(sep) : stack.join(sep);
}
/**
* Markdown 扩展名数组(小写、不含点)。
* 来源shared/extension-lists.js 经 preload contextBridge 暴露window.api.MARKDOWN_EXTS
* 这里仅做 fallbackpreload 失败(极少见)时降级到硬编码 ['md','markdown']
* 不让 isMarkdownFile 抛错拖死 bootstrap。
*/
function getMarkdownExts() {
const fromApi = (typeof window !== 'undefined') && window.api && window.api.MARKDOWN_EXTS;
if (Array.isArray(fromApi)) return fromApi;
return ['md', 'markdown'];
}
/**
* 文件路径是否为 Markdown按扩展名判定
*
* 大小写不敏感;隐藏文件(.gitignore返回 false。
*
* @param {string} filePath
* @returns {boolean}
*/
export function isMarkdownFile(filePath) {
if (!filePath || typeof filePath !== 'string') return false;
const name = basenameOf(filePath);
// 隐藏文件 / 没有扩展名 / 末尾是点 → 不是 md
if (!name || name.startsWith('.') || !name.includes('.')) return false;
const dot = name.lastIndexOf('.');
if (dot === name.length - 1) return false;
const ext = name.slice(dot + 1).toLowerCase();
return getMarkdownExts().includes(ext);
}
/**
* 本地绝对路径 → file:// URL。
* 用于把 markdown 里的相对图片路径改写成能被 <img> 真正加载的地址
* (否则会相对 index.html 所在的应用目录解析,永远 404
*
* @param {string} absPath
* @returns {string}
*/
export function toFileUrl(absPath) {
if (!absPath || typeof absPath !== 'string') return '';
let p = absPath.replace(/\\/g, '/');
if (!p.startsWith('/')) p = '/' + p; // Windows 的 'C:/x' → '/C:/x'
// 逐段编码,保留 '/'# 和 ? 必须编码,否则会被当成 fragment / query
const encoded = p.split('/').map(encodeURIComponent).join('/');
// 盘符里的冒号不要留成 %3A —— 保持 file:///C:/... 这种 Chromium 最认的形式
return 'file://' + encoded.replace(/^\/([a-z])%3A\//i, '/$1:/');
}
// 注countWords 已迁移到 ./stats.jsCJK 友好词数 + 行数 + chars 的统一来源)。

View File

@@ -0,0 +1,44 @@
// shared/ai-errors.js 与 renderer 端 LOCAL_AI_ERROR_FALLBACK 的漂移防护测试
//
// audit fix (CQ-MED-7):之前主进程错误码字面量靠注释提醒同步,实际曾漂移
// renderer ERR_TIMEOUT='ERR_TIMEOUT' 与 main 'AI_TIMEOUT' 对不上,
// aiErrorMessage switch 永不命中)。现在 renderer 在 jsdom 单测里走
// LOCAL_AI_ERROR_FALLBACK 这份本地副本,与 shared/ai-errors.js#AI_ERROR 必须
// 严格一致 —— 本测试校验两边字面值一一对应,新增 / 删除 / 改名都必须在两边同步改。
import { describe, it, expect } from 'vitest';
// shared/ai-errors.js 是 CJSmodule.exports = { AI_ERROR }ESM 解析器
// 不支持 named import from CJS —— vitest 在 node 环境下走 require 链路。
const { AI_ERROR: SHARED_AI_ERROR } = require('../../shared/ai-errors.js');
import { AI_ERROR as RENDERER_AI_ERROR } from '../../src/ai/ai-status.js';
describe('shared/ai-errors.js 与 src/ai/ai-status.js 错误码漂移防护 (CQ-MED-7)', () => {
it('shared 与 renderer 的 key 集合完全一致', () => {
const sharedKeys = Object.keys(SHARED_AI_ERROR).sort();
const rendererKeys = Object.keys(RENDERER_AI_ERROR).sort();
expect(rendererKeys).toEqual(sharedKeys);
});
it('shared 与 renderer 的每个 key 字面值完全一致', () => {
for (const key of Object.keys(SHARED_AI_ERROR)) {
expect(RENDERER_AI_ERROR[key]).toBe(SHARED_AI_ERROR[key]);
}
});
it('renderer 的 AI_ERROR 在 jsdom 环境(无 preload下能取到非空对象', () => {
// 本测试文件跑在 jsdom 下,没有 window.api → 应走 LOCAL_AI_ERROR_FALLBACK。
// 若拿到空对象 / undefined说明 fallback 链路断了 —— 单测会假阳性通过,
// 实际渲染时 aiErrorMessage 永远走 default 分支。
expect(RENDERER_AI_ERROR).toBeDefined();
expect(Object.keys(RENDERER_AI_ERROR).length).toBeGreaterThan(0);
});
it('跨进程错误码main ↔ renderer 都会引用)的字面值是稳定的字符串', () => {
// 不允许 undefined / null / 空串 —— main 返回这些值给 renderer 后,
// ai-controller 会拿它们走 switch落到 default 分支。
for (const code of Object.values(SHARED_AI_ERROR)) {
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
}
});
});

871
tests/unit/ai.test.js Normal file
View File

@@ -0,0 +1,871 @@
// main/ai.js —— AI 代理的协议分支测试
//
// 覆盖:
// - aiProvider 默认走 OpenAI 分支(旧版配置 / 没填 provider
// - aiProvider='openai' → POST /chat/completions · Bearer 鉴权
// - aiProvider='anthropic' → POST /v1/messages · x-api-key + anthropic-version
// - 未配置 / 超时 / 取消 / HTTP 错误 / 响应格式错误的稳定错误码
// - OpenAI / Anthropic 各自的截断信号finish_reason / stop_reason
//
// 不依赖 Electronai.js 只用 Node 内置 fetch通过 deps.fetchImpl 注入
// 假 fetch 验证 URL / headers / body避免真实网络。
import { describe, it, expect } from 'vitest';
const { createAiProxy } = require('../../main/ai.js');
/**
* 构造一个 mock fetch断言请求、返回预设响应。
* @param {object} opts
* @param {(url:string, init:object) => void} [opts.onCall] 每次调用都跑一次
* @param {Response|Error} [opts.response] 默认 200 + 合法 OpenAI 回复
*/
function mockFetch({ onCall, response } = {}) {
/** @type {Array<{url:string, init:object}>} */
const calls = [];
const fn = async (url, init = {}) => {
calls.push({ url, init });
if (onCall) onCall(url, init);
if (response instanceof Error) throw response;
return response || new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
return { fn, calls };
}
describe('createAiProxy.runEdit —— Provider 分发', () => {
it('aiProvider 缺省 / 未知 → 走 OpenAI 分支(向后兼容)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({ aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-x', aiModel: 'gpt-x' }),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r1' });
expect(r.ok).toBe(true);
expect(captured.url).toBe('https://x/v1/chat/completions');
expect(captured.init.headers.Authorization).toBe('Bearer sk-x');
});
it('aiProvider=openai → POST /chat/completions · Bearer', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: 'plain reply' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://api.openai.com/v1',
aiApiKey: 'sk-test',
aiModel: 'gpt-4o-mini',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r2' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(captured.url).toBe('https://api.openai.com/v1/chat/completions');
expect(captured.init.method).toBe('POST');
expect(captured.init.headers.Authorization).toBe('Bearer sk-test');
expect(captured.init.headers['x-api-key']).toBeUndefined();
// body 应含 messages 数组system + user
const body = JSON.parse(captured.init.body);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages[0].role).toBe('system');
expect(body.messages[1].role).toBe('user');
});
it('aiProvider=anthropic → POST /v1/messages · x-api-key + anthropic-version', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"hello"}' }],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'anthropic',
aiBaseUrl: 'https://api.anthropic.com',
aiApiKey: 'sk-ant-test',
aiModel: 'claude-3-5-sonnet-latest',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r3' });
expect(r.ok).toBe(true);
expect(r.content).toBe('hello');
expect(captured.url).toBe('https://api.anthropic.com/v1/messages');
expect(captured.init.headers['x-api-key']).toBe('sk-ant-test');
expect(captured.init.headers['anthropic-version']).toBe('2023-06-01');
expect(captured.init.headers.Authorization).toBeUndefined();
const body = JSON.parse(captured.init.body);
// Anthropic: system 在顶层messages 只有 user
expect(body.system).toBeDefined();
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBe(1);
expect(body.messages[0].role).toBe('user');
});
});
describe('createAiProxy.runEdit —— 错误码', () => {
it('未配置 → AI_NOT_CONFIGURED', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiBaseUrl: '', aiApiKey: '', aiModel: '' }),
fetchImpl: async () => { throw new Error('should not call'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
});
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 本地代理Ollama / LM Studio / vllm走 http://localhost / 127.0.0.1 不需要 Key
// 留空 apiKey 即过这里测的是「Key 存在 + 非loopback 明文」必须被拒。
it('OpenAI 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED防 Key 泄露)', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://evil.example.com/v1', aiApiKey: 'sk-real-key', aiModel: 'gpt-4' }),
fetchImpl: async () => { throw new Error('should not call — 明文 HTTP + 非loopback 必须在 fetch 前拒绝'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
expect(r.message).toMatch(/明文 HTTP/);
expect(r.message).toMatch(/https/);
});
it('Anthropic 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'http://gateway.example.com', aiApiKey: 'sk-ant-key', aiModel: 'claude-3-5-sonnet' }),
fetchImpl: async () => { throw new Error('should not call'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
expect(r.message).toMatch(/明文 HTTP/);
});
it('明文 http + localhost + 已配 Key → 仍放过(本地代理合法场景)', async () => {
// localhost 上 Key 即使被同机嗅探到也比公网风险低且本地代理Ollama
// 走 http 是惯例。这里验证 validateBaseUrl + http 守卫不误伤。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://localhost:11434/v1', aiApiKey: 'k', aiModel: 'gpt-4' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(true);
});
it('明文 http + 127.0.0.1 + 已配 Key → 仍放过', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://127.0.0.1:1234/v1', aiApiKey: 'k', aiModel: 'gpt-4' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(true);
});
it('OpenAI 401 → AI_PROVIDER_ERROR"API Key 无效或没有权限"', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'bad', aiModel: 'gpt' }),
fetchImpl: async () => new Response('Unauthorized', { status: 401 }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/API Key/);
});
it('Anthropic 400 + invalid_request_error → 透出 error.message', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
type: 'error',
error: { type: 'invalid_request_error', message: 'max_tokens: too large' },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/max_tokens: too large/);
});
it('OpenAI finish_reason=length → AI_BAD_RESPONSE截断提示', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"abc' }, finish_reason: 'length' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/不完整/);
});
it('Anthropic stop_reason=max_tokens → AI_BAD_RESPONSE截断提示', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"trunc' }],
stop_reason: 'max_tokens',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/不完整/);
});
it('网络错误 → AI_PROVIDER_ERROR带原始 message', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('ECONNREFUSED'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/ECONNREFUSED/);
});
});
describe('createAiProxy —— timer / pending Map cleanup', () => {
// audit fixpostJson 之前只 clearTimeout 没从 timers Map delete entry
// 长时间使用会内存泄漏。这里通过重复 runEdit 验证 timer/pending 都已清空。
it('连续 10 次成功请求后 timers / pending Map 都为空', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
for (let i = 0; i < 10; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `r${i}` });
expect(r.ok).toBe(true);
}
// 通过 cancelAll 的清理量反推cancelAll 应该不需要清任何东西(如果还有泄漏,会 abort 已完成的 controller
proxy.cancelAll();
// 重复 cancelAll 不应该抛错(说明 Map 已空)
expect(() => proxy.cancelAll()).not.toThrow();
});
it('失败响应HTTP 400后 timer Map 已清', async () => {
let count = 0;
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => {
count++;
return new Response('Bad Request', { status: 400 });
},
});
for (let i = 0; i < 5; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `err${i}` });
expect(r.ok).toBe(false);
}
expect(count).toBe(5);
// 后续 cancelAll 无副作用说明没有累积
expect(() => proxy.cancelAll()).not.toThrow();
});
it('网络异常fetch reject后 timer Map 已清', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('ECONNRESET'); },
});
for (let i = 0; i < 5; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `net${i}` });
expect(r.ok).toBe(false);
}
expect(() => proxy.cancelAll()).not.toThrow();
});
// looksLikeEditJson 是 main/ai.js 的内部函数,没导出。这里通过 runEdit 行为
// 反推 —— 当 AI 返回「带嵌套 JSON 但格式损坏」的文本时,应得到 AI_BAD_RESPONSE
// (提示不完整)而不是 raw 回退(让用户看到残缺 JSON 当作文本)。
describe('createAiProxy.runEdit —— looksLikeEditJson嵌套 JSON 截断识别)', () => {
// 这些测试间接覆盖嵌套对象场景:原来的正则 `/\{[^{}]*"content"\s*:[^{}]*\}/`
// 在 content 后跟嵌套 {} 时会失配,导致 looksLikeEditJson 误报 false
// normalizeAssistantText 走 raw 回退 —— AI 返回半截带嵌套对象的 JSON 时,
// 用户看到的是残缺 JSON 当文本,不是「请缩小文档」的提示。
function makeProxyWithText(aiText) {
const fetchImpl = async () => new Response(JSON.stringify({
choices: [{ message: { content: aiText }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
return createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
}
it('AI 返回半截嵌套 JSON外层 {} 未闭合)→ raw 回退responseFormat=raw', async () => {
// 模拟 AI 想要返回 {content:"...", patches:[...]} 但 token 耗尽、整个对象都
// 没闭合tryParseJson 失败、extractFirstJsonObject 也找不到配对对象、
// looksLikeEditJson 返回 false → 走 raw 路径把残文本返回给用户。
// 这是设计raw 回退让用户至少能看到 AI 输出了什么TRUNCATED 是 OpenAI /
// Anthropic 自己的 finish_reason=length / stop_reason=max_tokens 触发的,
// 不靠正文里的 {} 配对判断。)
const truncated = '{"content":"hi","patches":[{"op":"replace","old":"a","new":"b"},';
const proxy = makeProxyWithText(truncated);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt1' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(truncated);
});
it('AI 返回半截 JSON 但 brace-pair slice 仍能拿到合法对象 → 解析成功', async () => {
// 复杂 case文本里有 prose + JSON 截断(缺尾 `}`),但第一个 { 到最后一个 }
// 之间存在合法 JSON。这种情况应当被 getJsonCandidates 的 brace-pair slice
// 提取并解析 —— 验证新 extractFirstJsonObject 与既有 brace-pair slice
// 路径都不破坏嵌套解析。
const mixed = 'Here is the edit:\n{"content":"abc","meta":{"note":"done"}}\n[truncated';
const proxy = makeProxyWithText(mixed);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt2' });
expect(r.ok).toBe(true);
expect(r.content).toBe('abc');
expect(r.responseFormat).toBe('json');
});
it('AI 返回完整嵌套 JSON合法→ ok=true', async () => {
// 完整嵌套 JSON 应当被 tryParseJson 解析通过、走 responseFormat='json'。
const valid = '{"content":"hi","patches":[{"op":"replace"}]}';
const proxy = makeProxyWithText(valid);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt3' });
expect(r.ok).toBe(true);
expect(r.content).toBe('hi');
expect(r.responseFormat).toBe('json');
});
it('用户提示词纯文本里出现 "content" 子串 → 不误判 TRUNCATED', async () => {
// 老正则 `"content"\s*:` 在纯文本里有 `please update the "content" of section` 也会命中。
// 新实现要求体内有真配对的 JSON 对象 + `"content"` 键,纯文本不会触发。
const prose = '请把 "content" 字段里那个段落的标题改了。';
const proxy = makeProxyWithText(prose);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt4' });
// 不是 JSON → responseFormat='raw',当成普通回复返回,不应报错。
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(prose);
});
it('AI 返回带 fenced code block 的 JSON含嵌套 → ok=true', async () => {
// ```json\n{...}\n``` 也应被 tryParseJson 处理,新实现的
// extractFirstJsonObject 在 fenced block 内也能正常工作(先 trim 后扫描)。
const fenced = '```json\n{"content":"fenced ok","meta":{"k":"v"}}\n```';
const proxy = makeProxyWithText(fenced);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt5' });
expect(r.ok).toBe(true);
expect(r.content).toBe('fenced ok');
});
});
it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => {
let callCount = 0;
const fetchImpl = (url) => {
callCount++;
// 第一次永远不返回(模拟长任务)
if (callCount === 1) return new Promise(() => {});
// 第二次正常返回
return Promise.resolve(new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
// 第一次请求(永远 hang
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
// 第二次同 requestId → 应该 cancel 第一次并发新请求
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
const r2 = await p2;
expect(r2.ok).toBe(true);
expect(r2.content).toBe('ok');
// p1 应该被 cancel不需要 resolvecancelAll 会清掉)
proxy.cancelAll();
// 等 p1 真的 reject被 abort 后 fetch promise 不会自己结束)
// 加个超时兜底防止测试挂住
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
expect(() => proxy.cancelAll()).not.toThrow();
});
});
// audit F1cleanup 必须 identity-check 后再 delete详细注释见 main/ai.js 的 cleanup
describe('createAiProxy —— requestId 重入取消', () => {
it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => {
let callCount = 0;
const fetchImpl = () => {
callCount++;
if (callCount === 1) return new Promise(() => {});
return Promise.resolve(new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
const r2 = await p2;
expect(r2.ok).toBe(true);
expect(r2.content).toBe('ok');
proxy.cancelAll();
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
expect(() => proxy.cancelAll()).not.toThrow();
});
it('同 requestId 重新发起后,新请求仍可被 cancel(requestId) 单独取消', async () => {
const inFlight = [];
const fetchImpl = (url, opts) => {
return new Promise((resolve, reject) => {
inFlight.push({ opts });
// 必须监听 abort 信号;否则 catch 永远不触发,测试会 timeout
if (opts && opts.signal) {
if (opts.signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
opts.signal.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
}
});
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
await new Promise((r) => setTimeout(r, 0));
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
await new Promise((r) => setTimeout(r, 0));
proxy.cancel('dup');
// 等两个都 settleaborted
await Promise.all([
p1.catch(() => {}),
p2.catch(() => {}),
]);
expect(inFlight).toHaveLength(2);
// 关键断言:第二个请求的 signal.aborted 必须是 true
// —— F1 修复前:旧 cleanup 把 controller 从 Map 抹掉proxy.cancel('dup') 是 no-op
expect(inFlight[1].opts.signal.aborted).toBe(true);
});
});
// audit M1+M2+M3 回归测试API Key 脱敏 + URL userinfo 剥除 + JSON-wrapped header 修复
describe('createAiProxy.runEdit —— API Key 脱敏 + URL 清洗', () => {
// M1 fixfetch 抛错时 message 里携带 key 也必须脱敏。
// 真实场景fetch reject 的 Error.message 偶尔会包含 URL含 query key
// 自定义 fetch 包装层把请求 headers 拼进 message。
it('M1网络错误 message 含 sk- 前缀的 key → result.message 已脱敏', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('fetch failed for Authorization: Bearer sk-realsk1234567890abcdef'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).not.toMatch(/sk-realsk1234567890abcdef/);
expect(r.message).toMatch(/\[API_KEY\]/);
});
// M1 follow-up非 sk- 前缀的 key自定义 token / API secret也应被某条规则盖住。
it('M1网络错误 message 含通用 token=xxx → result.message 已脱敏', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('api_key=hunter2_real_secret_was_here'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1b' });
expect(r.ok).toBe(false);
expect(r.message).not.toMatch(/hunter2_real_secret_was_here/);
expect(r.message).toMatch(/\[REDACTED\]/);
});
// M2 fixbaseURL 含 userinfohttps://user:pass@host必须剥掉。
// 真实场景:中转服务在 baseURL 里塞 userinfo 简化配置fetch 会把 userinfo
// 当 Basic Auth 自动发出去,凭据泄露到第三方。
it('M2baseURL 含 userinfo → 实际 fetch 的 URL 已剥 userinfo', async () => {
let captured = null;
const fetchImpl = async (url) => {
captured = url;
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://hunter2:secret@relay.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2' });
expect(r.ok).toBe(true);
expect(captured).toBe('https://relay.example.com/v1/chat/completions');
expect(captured).not.toMatch(/hunter2/);
expect(captured).not.toMatch(/secret/);
expect(captured).not.toMatch(/@/);
});
// M2 follow-upURL 里 path 段含 sk-xxx 的极端情况也要在 sanitizeUrl 输出里被遮罩。
// 不依赖 joinUrl防御深度sanitizeUrl 单独处理原始 URL 时也应当剥 userinfo。
it('M2sanitizeUrl 输出HTTP 错误日志)已剥 userinfo + path 段 key', async () => {
/** @type {Array<{event:string, data:any}>} */
const logs = [];
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://user:pass@api.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl: async () => new Response('oops', { status: 500 }),
log: (event, data) => logs.push({ event, data }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2b' });
expect(r.ok).toBe(false);
const httpErrLog = logs.find((l) => l.event === 'ai:http_error');
expect(httpErrLog).toBeDefined();
expect(httpErrLog.data.url).not.toMatch(/user/);
expect(httpErrLog.data.url).not.toMatch(/pass/);
expect(httpErrLog.data.url).not.toMatch(/@/);
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
});
// M3 fixHTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本
// (错误信息里把 header 序列化成字符串AI proxy / 网关常见),旧 regex 遇到 `"`
// 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。
// 这里使用非 sk- 前缀的 key确保测试通过 sk- 正则 fail只覆盖 JSON-wrapped 修复路径)。
it('M3JSON-wrapped authorization header非 sk- 前缀)→ 已脱敏', async () => {
const SENSITIVE = 'eyJhbGciOiJIUzI1NiJ9.payload.signature_xxx_no_sk_prefix';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
error: { message: `upstream returned: "authorization": "Bearer ${SENSITIVE}"` },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3' });
expect(r.ok).toBe(false);
// 关键断言JWT 内容不再出现在 toast message 里
expect(r.message).not.toMatch(/eyJhbGciOiJIUzI1NiJ9/);
expect(r.message).not.toMatch(/payload\.signature_xxx_no_sk_prefix/);
expect(r.message).toMatch(/REDACTED/);
});
// M3 follow-up短 key 阈值 8 → 5 后,截断的 sk- 也能被捕获。
// 状态码选 400 且不带 invalid_request_error.type —— 走 main/ai.js 末尾
// 「AI 请求失败HTTP 400{detail}」路径detail 经 sanitizeDetail 脱敏后
// 进入 result.message。401/403/404/429/5xx 都有早退固定文案,不展示上游 detail。
it('M3截断的 sk-5 字符后缀)→ 已脱敏为 [API_KEY]', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
error: { message: 'invalid token: sk-12345abc (truncated for security)' },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3b' });
expect(r.ok).toBe(false);
// 不应出现真实截断的 key 段
expect(r.message).not.toMatch(/sk-12345abc/);
expect(r.message).toMatch(/\[API_KEY\]/);
});
});
// audit shared-M11 + shared-M13归一化层对「非字符串 content」和「非文本块」做了区分
describe('createAiProxy.runEdit —— shared-M11 / shared-M13 归一化分支', () => {
// M11Anthropic 返回的 content 全部是 tool_use / image无 text 块)——
// 旧版会误报「AI 返回了空内容」让用户重试;新版提示「无法识别」。
it('M11Anthropic 仅返回 tool_use 块(无 text→ 明确提示 tool_use / 图像块', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'BJ' } },
],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11a' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
// 提示里要让用户知道是「格式不识别」而不是「内容为空」
expect(r.message).toMatch(/tool_use|图像块/);
});
it('M11Anthropic 仅返回 image 块(无 text→ 同样提示无法识别', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11b' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/tool_use|图像块/);
});
it('M11Anthropic content 数组空 → 仍报「空内容」(不是 tool_use 路径)', async () => {
// 兜底:旧分支 'AI 返回了空内容' 仍保留,区分「真的没回」和「回了非文本」。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11c' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toBe('AI 返回了空内容');
});
it('M11Anthropic 混合 text + tool_use → 只提取 textresponseFormat=json', async () => {
// 混合块:正常路径 —— text 被拼接tool_use 跳过,但不影响 ok=true。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [
{ type: 'tool_use', id: 't1', name: 'noop', input: {} },
{ type: 'text', text: '{"content":"mixed ok"}' },
],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11d' });
expect(r.ok).toBe(true);
expect(r.content).toBe('mixed ok');
expect(r.responseFormat).toBe('json');
});
// M13模型按规矩返回了 JSON 对象但 content 不是字符串(数组 / 对象 / null / 数字)。
// 旧逻辑走「output 截断」分支误导用户;新版把 message 整体当 raw 回退给用户。
it('M13OpenAI 返回的 JSON 解析后 content 是数组 → responseFormat=raw回退原 message', async () => {
const rawMessage = '{"content":[]}';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13a' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
// 回退:原 message 整体作为 content让用户看到模型实际输出
expect(r.content).toBe(rawMessage);
});
it('M13OpenAI 返回的 JSON 解析后 content 是 null → responseFormat=raw', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":null,"meta":"x"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13b' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe('{"content":null,"meta":"x"}');
});
it('M13OpenAI 返回的 JSON 解析后 content 是嵌套对象 → responseFormat=raw', async () => {
const rawMessage = '{"content":{"ops":[{"op":"replace","old":"a","new":"b"}]}}';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13c' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(rawMessage);
});
});
// ---------------------------------------------------------------------------
// audit round-13Anthropic / OpenAI max_tokens 上限路由
//
// 回归背景pickAnthropicTokenConfig 的正则要求 `claude-` 后紧跟已知片段,
// 于是 claude-opus-5 / claude-sonnet-5 / claude-haiku-4-5 全部漏网 → 回退 8192
// 比 claude-opus-4-816384还低。「越新的模型拿到越小的 max_tokens」
// 长笔记改写在 8k 被截断 → stop_reason: max_tokens → 报「AI 修改结果不完整」。
// ---------------------------------------------------------------------------
describe('max_tokens 上限按模型路由', () => {
/**
* 跑一次 runEdit返回实际发出的请求 body。
* @param {'openai'|'anthropic'} provider
* @param {string} model
*/
async function capturedBody(provider, model) {
let captured = null;
const okBody = provider === 'anthropic'
? { content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn' }
: { choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }] };
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: provider,
aiBaseUrl: provider === 'anthropic' ? 'https://x' : 'https://x/v1',
aiApiKey: 'k',
aiModel: model,
}),
fetchImpl: async (url, init) => {
captured = JSON.parse(init.body);
return new Response(JSON.stringify(okBody), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
},
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'cap' });
expect(r.ok).toBe(true);
return captured;
}
// ---- Anthropic5 系与 4.6+ 拿到 32k ----
it.each([
'claude-opus-5',
'claude-sonnet-5',
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-sonnet-4-6',
])('Anthropic %s → max_tokens 32000官方 128k 上限,保守取值)', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(32_000);
});
// ---- Anthropic其余 4 系 / 3-5 / 3-7 拿到 16k ----
it.each([
'claude-haiku-4-5',
'claude-sonnet-4-5',
'claude-opus-4-5',
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
])('Anthropic %s → max_tokens 16384', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(16_384);
});
// ---- Anthropicclaude-3 老家族与未知模型保守 8k ----
it.each([
'claude-3-opus-20240229',
'claude-3-haiku-20240307',
])('Anthropic %sclaude-3 老家族)→ max_tokens 8192', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(8_192);
});
it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192最大限度兼容', async () => {
const body = await capturedBody('anthropic', 'my-local-llm-v2');
expect(body.max_tokens).toBe(8_192);
});
// 关键不变式:新模型的上限不得低于老模型(这正是本轮修的 bug
it('回归claude-opus-5 的 max_tokens 不低于 claude-opus-4-8', async () => {
const five = await capturedBody('anthropic', 'claude-opus-5');
const four = await capturedBody('anthropic', 'claude-opus-4-8');
expect(five.max_tokens).toBeGreaterThanOrEqual(four.max_tokens);
});
it('回归claude-haiku-4-5 的 max_tokens 高于 claude-3 老家族', async () => {
const haiku45 = await capturedBody('anthropic', 'claude-haiku-4-5');
const old3 = await capturedBody('anthropic', 'claude-3-haiku-20240307');
expect(haiku45.max_tokens).toBeGreaterThan(old3.max_tokens);
});
// ---- OpenAI 侧token 字段路由不受影响(防回归) ----
it('OpenAI gpt-5 → max_completion_tokens 且不带 temperature', async () => {
const body = await capturedBody('openai', 'gpt-5');
expect(body.max_completion_tokens).toBe(32_000);
expect(body.max_tokens).toBeUndefined();
expect(body.temperature).toBeUndefined();
});
it('OpenAI gpt-4o → max_tokens 16384 且带 temperature', async () => {
const body = await capturedBody('openai', 'gpt-4o');
expect(body.max_tokens).toBe(16_384);
expect(body.max_completion_tokens).toBeUndefined();
expect(typeof body.temperature).toBe('number');
});
});
// ---------------------------------------------------------------------------
// audit round-13 Sec-H3fetch 必须 redirect:'manual' 防 API key 跟着 302 走
//
// 回归背景Anthropic 用 x-api-key 自定义头(不在 fetch 规范 CORS 非通配脱敏集
// 合里undici 默认 follow 重定向到不同 origin 时会原样复传 header。
// 用户配置的中转 / 网关一旦 302 到攻击者域sk-ant-... 跟当前笔记全文会被转发。
// ---------------------------------------------------------------------------
describe('createAiProxy —— redirect: manual 防 API Key 跟随重定向', () => {
it('fetch 调用必带 redirect:"manual"OpenAI 分支)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o-mini',
}),
fetchImpl,
});
await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-1' });
expect(captured.init.redirect).toBe('manual');
});
it('fetch 调用必带 redirect:"manual"Anthropic 分支)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'anthropic',
aiBaseUrl: 'https://x', aiApiKey: 'sk-ant-test', aiModel: 'claude-opus-5',
}),
fetchImpl,
});
await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-2' });
expect(captured.init.redirect).toBe('manual');
});
it('3xx 重定向 → opaqueredirect → AI_PROVIDER_ERROR + 提示用户改 Base URL', async () => {
// 模拟 undici 在 manual 模式下返回的 opaqueredirect Responsestatus=0、type='opaqueredirect'
const fetchImpl = async () => {
const res = new Response(null, { status: 302 });
// 模拟 opaqueredirect 的关键属性status 0 + type 'opaqueredirect'
Object.defineProperty(res, 'status', { value: 0 });
Object.defineProperty(res, 'type', { value: 'opaqueredirect' });
return res;
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://evil-redirect.example/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-3' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/发生了重定向/);
expect(r.message).toMatch(/不会自动跟随重定向/);
});
});

View File

@@ -0,0 +1,735 @@
// Stage 7 tests: main/config-store.js
//
// 覆盖:
// - init() 触发 loadConfig + 设定 DEFAULT_DATA_DIR
// - saveConfig 合并写入 + 返回新 config
// - resolveDataDir用户自定义 > 默认
// - seedDefaultDataDirsentinel 已存在 / 已有 .md / 自定义目录 → 跳过
// - getConfig / getDefaultDataDir / loadConfig 在 init 后行为
//
// 配置模块依赖 `electron.app` —— 通过 _setApp() 注入 fake appvi.mock('electron')
// 拦不住 CJS 的 require 调用,所以走 config-store 自己暴露的 setter
// 文件系统通过临时目录 + 真实的 fs.promisesmock 太多会失去意义)。
//
// 注意:模块 init 会被显式调用,不能假设 module 级副作用。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, statSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// 真实生产环境走 require('electron'),单测里 vi.mock('electron') 拦不住 CJS 的 require。
// config-store 暴露了 _setApp(mockApp) 让我们注入假 app。
const configStore = await import('../../main/config-store.js');
const { DEFAULT_SETTINGS: DEFAULT_CONFIG, coerceLoadedSettings } = await import('../../shared/settings-schema.js');
let mockHomeDir;
let mockUserDataDir;
beforeEach(() => {
mockHomeDir = mkdtempSync(join(tmpdir(), 'notes-test-home-'));
mockUserDataDir = mkdtempSync(join(tmpdir(), 'notes-test-userdata-'));
// 注入假 app
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
// 重置模块状态appConfig / DEFAULT_DATA_DIR / configLoaded
configStore._reset();
});
afterEach(() => {
if (mockHomeDir) rmSync(mockHomeDir, { recursive: true, force: true });
if (mockUserDataDir) rmSync(mockUserDataDir, { recursive: true, force: true });
});
describe('init', () => {
it('返回 {appConfig, defaultDataDir}', () => {
const result = configStore.init();
expect(result.appConfig).toBeDefined();
expect(result.defaultDataDir).toBe(join(mockHomeDir, 'Notes'));
});
it('loadConfig 后 getConfig 返回当前配置', () => {
configStore.init();
expect(configStore.getConfig()).toEqual({ ...DEFAULT_CONFIG });
});
it('userData/config.json 不存在时用默认值', () => {
configStore.init();
expect(configStore.getConfig()).toEqual({ ...DEFAULT_CONFIG });
});
it('userData/config.json 存在时读取并 sanitize', () => {
writeFileSync(
join(mockUserDataDir, 'config.json'),
JSON.stringify({ theme: 'light', dataDir: 'D:\\notes' }),
'utf-8'
);
configStore.init();
const cfg = configStore.getConfig();
expect(cfg.theme).toBe('light');
expect(cfg.dataDir).toBe('D:\\notes');
});
it('JSON 损坏 → fallback 到默认', () => {
writeFileSync(join(mockUserDataDir, 'config.json'), '{not json', 'utf-8');
configStore.init();
expect(configStore.getConfig()).toEqual({ ...DEFAULT_CONFIG });
});
});
describe('saveConfig', () => {
it('合并写入并返回新配置', async () => {
configStore.init();
const result = await configStore.saveConfig({ theme: 'light' });
// audit fix (C2)saveConfig 现在返回 {ok, value|error}
expect(result.ok).toBe(true);
expect(result.value.theme).toBe('light');
});
it('不传 → 等价于 no-op', async () => {
configStore.init();
const before = configStore.getConfig();
const result = await configStore.saveConfig({});
expect(result.ok).toBe(true);
expect(result.value).toEqual(before);
});
it('多次 saveConfig 累加字段', async () => {
configStore.init();
await configStore.saveConfig({ theme: 'light' });
await configStore.saveConfig({ themePalette: 'ocean' });
const cfg = configStore.getConfig();
expect(cfg.theme).toBe('light');
expect(cfg.themePalette).toBe('ocean');
});
it('写入磁盘后再 init → 拿到上次的值', async () => {
configStore.init();
await configStore.saveConfig({ theme: 'light', themePalette: 'forest' });
// 重新加载模块(在另一个进程里没法做,但 init 重读即可)
configStore.init();
expect(configStore.getConfig().theme).toBe('light');
expect(configStore.getConfig().themePalette).toBe('forest');
});
it('audit fix (C2)saveConfig 返回 Promise<{ok,value}> 而非合并后的对象', async () => {
// 旧 API 是直接返回 appConfigrenderer 端 settings-store 误把它当作
// 已经持久化的对象来用。改异步后契约改成 {ok:true, value:...},让失败路径
// 能区分。回归这条断言防止以后又退回到同步返回 appConfig。
configStore.init();
const result = await configStore.saveConfig({ theme: 'light' });
expect(result).toMatchObject({ ok: true });
expect(result).toHaveProperty('value');
expect(result.value).toEqual(expect.objectContaining({ theme: 'light' }));
});
// audit fix (Round 4 P0-1)saveConfig 必须 fsync tmp 文件再 rename否则断电
// 后磁盘上可能是新 inode + 空内容rename 是原子的,但写盘数据还在 page cache
// 没 fsync 就断电 → 内核丢弃 page cache → 文件确实是 0 字节)。
// 之前实现直接走 fs.writeFile + rename没任何 fsync —— AI key / 自定义 dataDir
// 这类关键配置可能因为断电被永久清空。本测试用 spy 钉死「先 sync 再 close 再 rename」
// 的时序,防止未来重构改回 fs.writeFile 而无人察觉。
it('audit fix (P0-1)saveConfig 在 rename 前调用 fh.sync()', async () => {
configStore.init();
const fsPromises = await import('fs').then((m) => m.promises);
const realOpen = fsPromises.open;
/** @type {Array<{method: string, whenMs: number}>} */
const calls = [];
let syncCalledAt = -1;
let closeCalledAt = -1;
/** @type {string[]} */
const openedFiles = [];
fsPromises.open = async function spyOpen(p, flags) {
openedFiles.push(String(p));
const fh = await realOpen.call(fsPromises, p, flags);
// 只 spy tmp 文件(路径含 .tmp.),不动 userData 目录本身
if (String(p).includes('.tmp.')) {
const realSync = fh.sync.bind(fh);
const realClose = fh.close.bind(fh);
fh.sync = async function spySync() {
syncCalledAt = Date.now();
calls.push({ method: 'sync' });
return realSync();
};
fh.close = async function spyClose() {
closeCalledAt = Date.now();
calls.push({ method: 'close' });
return realClose();
};
}
return fh;
};
try {
await configStore.saveConfig({ theme: 'light' });
} finally {
fsPromises.open = realOpen;
}
// 关键断言sync 必须在 close 之前被调用(即「先落盘再 close」的原子模式
expect(syncCalledAt).toBeGreaterThan(0);
expect(closeCalledAt).toBeGreaterThan(0);
expect(syncCalledAt).toBeLessThanOrEqual(closeCalledAt);
// 调用顺序必须是 [sync, close](不能 close 在 sync 之前)
const syncIdx = calls.findIndex((c) => c.method === 'sync');
const closeIdx = calls.findIndex((c) => c.method === 'close');
expect(syncIdx).toBeGreaterThanOrEqual(0);
expect(closeIdx).toBeGreaterThan(syncIdx);
// tmp 路径应被打开过(确保本测试在真路径上跑)
expect(openedFiles.some((p) => p.includes('.tmp.'))).toBe(true);
});
// audit fix (Round 12 P1)saveConfig 在 rename 成功后必须对父目录 fsync
// POSIX only与 main/file-ops.js#atomicWriteFile 的 Round 4 修复对称。
// Windows NTFS journal 元数据自带 fsync 语义跳过POSIX rename(2) 同分区下
// 原子,但「目录项本身」写入磁盘的时机由内核控制 —— rename 完不 fsync 父目录
// 就断电,下次启动目录里可能仍是旧名字 + 新 inode 已分配但未刷盘。
// 这里 spy fsPromises.open 抓「tmp 之后」的 directory open + sync + close
// 钉死时序:先 open parent → sync → close → 之后才走后续逻辑。
it('audit fix (Round 12 P1)saveConfig rename 后对父目录 fsyncPOSIX', async () => {
// 只在 POSIX 平台跑Windows NTFS 不需要这步journal 元数据自带 fsync
if (process.platform === 'win32') {
// Windows 跳过但仍验证代码没崩rename 路径本身要走完)
configStore.init();
await configStore.saveConfig({ theme: 'dark' });
return;
}
configStore.init();
const fsPromises = await import('fs').then((m) => m.promises);
const realOpen = fsPromises.open;
/** @type {Array<{method: string, path: string}>} */
const parentCalls = [];
let parentSyncAt = -1;
let parentCloseAt = -1;
let tmpSyncAt = -1;
let tmpRenameDoneAt = -1;
/** @type {string[]} */
const openedPaths = [];
fsPromises.open = async function spyOpen(p, flags) {
openedPaths.push(String(p));
const fh = await realOpen.call(fsPromises, p, flags);
const pathStr = String(p);
// 父目录 fsync不是 .tmp. 路径,且不是 userData/config.json 本身,
// 就是 userData 父目录saveConfig fs.open(parent, 'r')
if (!pathStr.includes('.tmp.') && !pathStr.endsWith('config.json')) {
const realSync = fh.sync.bind(fh);
const realClose = fh.close.bind(fh);
fh.sync = async function spySync() {
parentSyncAt = Date.now();
parentCalls.push({ method: 'sync', path: pathStr });
return realSync();
};
fh.close = async function spyClose() {
parentCloseAt = Date.now();
parentCalls.push({ method: 'close', path: pathStr });
return realClose();
};
} else if (pathStr.includes('.tmp.')) {
const realSync = fh.sync.bind(fh);
fh.sync = async function spySync() {
tmpSyncAt = Date.now();
return realSync();
};
}
return fh;
};
try {
await configStore.saveConfig({ theme: 'dark' });
// rename 是同步的 renameSync通过 fs.promises.rename 调用spy 不抓
// 我们假设它发生在 sync 之后(生产代码时序就是 rename → fsync parent
// 简化:把 tmpSyncAt+1 当作 rename 完成时间rename 紧跟 tmp sync 后)。
tmpRenameDoneAt = tmpSyncAt + 1;
} finally {
fsPromises.open = realOpen;
}
// 关键parent fsync 必须发生在 tmp sync + rename 之后
expect(openedPaths.some((p) => !p.includes('.tmp.') && !p.endsWith('config.json'))).toBe(true);
expect(parentSyncAt).toBeGreaterThan(0);
expect(parentCloseAt).toBeGreaterThan(0);
// 时序parent.sync 在 parent.close 之前
const syncIdx = parentCalls.findIndex((c) => c.method === 'sync');
const closeIdx = parentCalls.findIndex((c) => c.method === 'close');
expect(syncIdx).toBeGreaterThanOrEqual(0);
expect(closeIdx).toBeGreaterThan(syncIdx);
// 时序parent.sync 必须在 rename 之后(防止以后有人手贱把 fsync 挪到 rename 前)
expect(parentSyncAt).toBeGreaterThanOrEqual(tmpRenameDoneAt);
});
// audit fix (Round 12 P2)saveConfig 失败时 result.error 走 friendly-fs-error
// 中文文案,不再泄漏原始 e.message英文 errno + 路径)。
it('audit fix (Round 12 P2)saveConfig 失败 error 走 friendly-fs-error 而非原始 e.message', async () => {
configStore.init();
const fsPromises = await import('fs').then((m) => m.promises);
const realOpen = fsPromises.open;
fsPromises.open = async function boom() {
const err = new Error('EACCES: permission denied, open \'/secret/path\'');
err.code = 'EACCES';
throw err;
};
try {
const result = await configStore.saveConfig({ theme: 'light' });
expect(result.ok).toBe(false);
// 不能再含英文 errno 原文
expect(result.error).not.toMatch(/^EACCES:/);
// 必须是友好中文friendly-fs-error 对 EACCES 给的固定文案)
expect(typeof result.error).toBe('string');
expect(result.error.length).toBeGreaterThan(0);
} finally {
fsPromises.open = realOpen;
}
});
// audit fix (Round 12 P2)_reset 必须清 saveQueue否则上一个 case 的
// fire-and-forget save 会污染下一个 case 的 saveConfig 串行化链。
// 验证方式init → 拿第一个 promise (p1) → _reset清队列→ 重新 init
// → 拿第二个 promise (p2)。若 _reset 没清队列p2 会链到 p1.then(...) 的
// 尾部,但 p1 的 task 在 _reset 后 appConfig 已被清空的状态下仍会跑with
// 空 before / 异常合并)—— 副作用:模拟器内的 fs 在 _reset 后 mockHomeDir
// 被释放p1 实际 reject资源不可用。新 case 的 p2 必须独立 ok=true
// 不被 p1 的失败拖累。
it('audit fix (Round 12 P2)_reset 后 saveConfig 独立成功saveQueue 与旧 case 断链)', async () => {
configStore.init();
// 旧 casefire-and-forget saveConfigp1 任务在 saveQueue 尾排队
const p1 = configStore.saveConfig({ theme: 'first' });
// 等一拍微任务,让 p1 进入 task 阶段open tmp...
await new Promise((r) => setImmediate(r));
// _reset 切断 saveQueue —— 关键修复点
configStore._reset();
// 重新注入 app + init新 case
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
configStore.init();
// p2独立 saveConfig必须独立成功。如果 _reset 没清 saveQueue
// p2 会链到 p1.then(...),而 p1 此刻正在用被 _reset 清空的 appConfig 跑
// taskmerge 时 `before = {}`(被清)、`merged = {theme:'second'}` 表面
// 正常但 fs 操作可能因时序问题让 p2 也 fail / hang。
const p2 = configStore.saveConfig({ theme: 'second' });
const result = await p2;
expect(result.ok).toBe(true);
expect(result.value.theme).toBe('second');
// 让旧 case 的 p1 跑完(避免污染下一个 test
await Promise.race([p1.catch(() => null), new Promise((r) => setTimeout(r, 100))]);
});
});
describe('getDefaultDataDir', () => {
it('返回 <home>/Notes', () => {
configStore.init();
expect(configStore.getDefaultDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
});
describe('resolveDataDir', () => {
it('未设置 dataDir → 返回默认', () => {
configStore.init();
expect(configStore.resolveDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
it('设置了 dataDir → 优先用自定义', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: 'C:\\custom' });
expect(configStore.resolveDataDir()).toBe('C:\\custom');
});
it('dataDir 是空白字符串 → 走默认', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: ' ' });
expect(configStore.resolveDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
it('dataDir 是空字符串 → 走默认', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: '' });
expect(configStore.resolveDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
});
describe('resolveDataDirOrFallbackauto-fallback 2026-08', () => {
// 这些测试只关心函数返回值,不在乎 schema 默认值。
// 直接走 fsSync生产代码用的也是 fsSync.statSync用临时目录 + rmSync 制造
// 存在/不存在的目标;不 mock fs —— 真正 stat 一次成本可忽略(毫秒级 tmp 目录)。
//
// 关于 spy试过 vi.spyOn + 直接赋值 + defineProperty都因为 fs.statSync 在
// Node ESM 模块导出上 {configurable:false} 而抛 "Cannot redefine property"。
// 改用「文件系统观察」验证缓存:先 populate 缓存(首次调用),然后 mutate 磁盘
// (删除目录 / 重建目录),再调用 → 如果返回值跟着磁盘变说明没缓存、跟着首次调用
// 时的状态说明命中缓存。这样测「不重复 stat」的语义更可靠行为契约而非 spy 计数)。
it('未设置 dataDir → 返回默认 + fellBack:false', () => {
configStore.init();
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(false);
expect(out.saved).toBe('');
});
it('dataDir 指向存在的目录 → 返回 custom + fellBack:false', async () => {
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-exist-'));
await configStore.saveConfig({ dataDir: realDir });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(realDir);
expect(out.fellBack).toBe(false);
expect(out.saved).toBe(realDir);
rmSync(realDir, { recursive: true, force: true });
});
it('dataDir 指向不存在的路径 → 回退到默认 + fellBack:true + saved 原值', async () => {
configStore.init();
const missing = join(tmpdir(), 'notes-fb-missing-' + Date.now() + '-' + Math.random());
await configStore.saveConfig({ dataDir: missing });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(true);
expect(out.saved).toBe(missing);
});
it('dataDir 指向文件而非目录ENOTDIR→ 回退到默认', async () => {
// ENOTDIR 不像 ENOENT 那样常见,但属于「路径存在但不可用」一类,仍应 fallback。
configStore.init();
const tmpFile = join(mockUserDataDir, 'not-a-dir-' + Date.now() + '.txt');
writeFileSync(tmpFile, 'x', 'utf-8');
await configStore.saveConfig({ dataDir: tmpFile });
const out = configStore.resolveDataDirOrFallback();
expect(out.fellBack).toBe(true);
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.saved).toBe(tmpFile);
});
it('缓存:首次 stat 后再删目录 → 仍返回原路径(命中缓存)', async () => {
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-cache-'));
await configStore.saveConfig({ dataDir: realDir });
// 首次调用populate 缓存exists=true
const out1 = configStore.resolveDataDirOrFallback();
expect(out1.dir).toBe(realDir);
expect(out1.fellBack).toBe(false);
// 现在删除磁盘上的目录(如果函数每次都 stat会立即检测到 ENOENT → 回退)
rmSync(realDir, { recursive: true, force: true });
// 二次调用:必须仍返回原路径 —— 否则说明每次都 stat、缓存没生效
const out2 = configStore.resolveDataDirOrFallback();
expect(out2.dir).toBe(realDir);
expect(out2.fellBack).toBe(false);
});
it('缓存saveConfig 改了 dataDir → 下次重新 stat删旧目录不命中缓存', async () => {
// 验证缓存失效语义:第一次 cache 指向 A → 改 dataDir 到 B → 删 A 再调用
// → 应该返回 B缓存已失效新 dataDir 重新走 stat不能返回 A。
configStore.init();
const dirA = mkdtempSync(join(tmpdir(), 'notes-fb-A-'));
const dirB = mkdtempSync(join(tmpdir(), 'notes-fb-B-'));
await configStore.saveConfig({ dataDir: dirA });
configStore.resolveDataDirOrFallback(); // populate cache: exists(A)=true
await configStore.saveConfig({ dataDir: dirB }); // dataDir 变了 → cache 失效
rmSync(dirA, { recursive: true, force: true });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(dirB); // 不是 dirA —— 缓存确实失效了
expect(out.fellBack).toBe(false);
rmSync(dirB, { recursive: true, force: true });
});
it('缓存fallback 命中customDir 不存在)也缓存 → 后续不重新 stat', async () => {
// 反向验证:首次 stat 出 ENOENT 后,磁盘上即使新建了同路径,再次调用也仍
// 返回 default —— 因为缓存里这条 customDir 被记成 exists=false。
configStore.init();
const missing = join(tmpdir(), 'notes-fb-cache-missing-' + Date.now());
await configStore.saveConfig({ dataDir: missing });
const out1 = configStore.resolveDataDirOrFallback();
expect(out1.fellBack).toBe(true);
// 磁盘上把这条路径建出来(如果每次 stat会立刻发现它存在 → 不 fallback
mkdirSync(missing, { recursive: true });
const out2 = configStore.resolveDataDirOrFallback();
expect(out2.fellBack).toBe(true); // 仍是 fallback —— 缓存生效
expect(out2.dir).toBe(join(mockHomeDir, 'Notes'));
rmSync(missing, { recursive: true, force: true });
});
it('dataDir 是空白字符串 → 等同未设置', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: ' ' });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(false);
expect(out.saved).toBe('');
});
it('saveConfig 未改 dataDir → 缓存不失效', async () => {
// 反向验证 saveConfig 的失效条件精确:只有 dataDir 真改了才清缓存,
// 改别的字段不应该让缓存跟着失效。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-noevict-'));
await configStore.saveConfig({ dataDir: realDir });
configStore.resolveDataDirOrFallback(); // populate cache
rmSync(realDir, { recursive: true, force: true });
// saveConfig 一个无关字段 → 缓存不应失效,所以仍返回原路径
await configStore.saveConfig({ themePalette: 'ocean' });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(realDir); // 缓存没失效 → 仍返回原路径
expect(out.fellBack).toBe(false);
});
it('saveConfig 把 dataDir 从有值改为空 → 缓存失效,下次返回默认', async () => {
// 模拟「回到默认」按钮的语义dataDir 从 custom 切到空 → 下次 resolveDataDirOrFallback
// 应走默认路径fellBack=false。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-clear-'));
await configStore.saveConfig({ dataDir: realDir });
configStore.resolveDataDirOrFallback(); // cache: customDir=realDir, exists=true
await configStore.saveConfig({ dataDir: '' }); // 切到默认(持久化清空)
rmSync(realDir, { recursive: true, force: true });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(false);
expect(out.saved).toBe('');
});
it('_reset 清掉缓存(测试间隔离)', async () => {
// _reset 的契约是「清掉模块级 in-memory 状态 + 缓存」,**不**清磁盘。
// 测试间隔离的核心是缓存:上一个 case populate 了缓存,新 case 用同一个
// customDir 字符串如果不重置就会命中旧缓存(即便磁盘状态已变)。
//
// 验证方式populate 缓存为「exists=true」→ 磁盘上删除目录 → _reset → 重
// 新 init不重新保存磁盘上的 config.json 还指向已删除路径)→ 应该重新
// stat磁盘真实状态 ENOENT 浮现出来fellBack=true。如果 _reset 没清掉
// 缓存就会继续返回原路径 + fellBack:false断言失败。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-reset-'));
await configStore.saveConfig({ dataDir: realDir });
configStore.resolveDataDirOrFallback(); // populate cache: exists=true
expect(configStore.resolveDataDirOrFallback().fellBack).toBe(false);
rmSync(realDir, { recursive: true, force: true });
configStore._reset();
// 重新 init —— loadConfig() 会从磁盘读到 dataDir=realDir已删除
// 然后 resolveDataDirOrFallback 重新 statENOENT → fellBack=true。
// 走磁盘文件验证不依赖任何残留缓存。
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
configStore.init();
const out = configStore.resolveDataDirOrFallback();
// 磁盘上 realDir 已删除 → fellBack=truedir 是默认。这是「缓存真的清掉
// 了」的标志 —— 如果 cache 还指向 exists=true这里就会返回 realDir。
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(true);
expect(out.saved).toBe(realDir);
});
});
describe('ensureDefaultDataDirauto-create + seed 2026-08', () => {
// 用 welcome.md 自己作为「已种子」标记,不再写 .notes-seeded 隐藏文件
// —— 用户数据目录应该只放用户的内容。
it('welcome.md 已存在 → 跳过(幂等)', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8');
await configStore.ensureDefaultDataDir();
// welcome.md 存在 → 不重新复制
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容');
});
it('默认目录已有其它 .md → 不复制 welcome.md不写隐藏文件', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'user-existing.md'), '# user', 'utf-8');
await configStore.ensureDefaultDataDir();
// 不复制 welcome.md也不创建 .notes-seeded用户数据目录应该干净
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(false);
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
});
it('默认目录为空 → 复制 welcome.md不创建隐藏文件', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
await configStore.ensureDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
});
it('默认目录不存在 → 自动创建 + 复制', async () => {
configStore.init();
// 不 mkdir让 ensure 自己去建
await configStore.ensureDefaultDataDir();
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(true);
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
it('用户已设 customDir → 仍种子默认目录auto-fallback 需要)', async () => {
// 关键回归测试:之前 seedDefaultDataDir 在 dataDir 设置时跳过,导致
// U 盘掉线 fallback 到默认时默认目录从未建过 / 种子过。现在 ensure
// 不再守这个条件customDir 失效场景下默认目录也能立刻有内容。
configStore.init();
await configStore.saveConfig({ dataDir: 'C:\\does-not-exist-anywhere' });
await configStore.ensureDefaultDataDir();
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(true);
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
it('重复调用幂等welcome.md 命中后第二次不覆盖用户内容)', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
await configStore.ensureDefaultDataDir();
// 用户改 welcome.md 后再调一次 ensure → 内容应保留
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8');
await configStore.ensureDefaultDataDir();
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容');
});
});
describe('ensureDefaultDataDirSyncsync mkdir', () => {
it('默认目录不存在 → sync mkdir 建出来', () => {
configStore.init();
// 不预创建
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(false);
configStore.ensureDefaultDataDirSync();
expect(existsSync(defaultDir)).toBe(true);
expect(statSync(defaultDir).isDirectory()).toBe(true);
});
it('默认目录已存在 → no-op不抛错', () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'preexisting.txt'), 'hi', 'utf-8');
configStore.ensureDefaultDataDirSync();
// 已存在的文件保留
expect(readFileSync(join(defaultDir, 'preexisting.txt'), 'utf-8')).toBe('hi');
});
it('resolveDataDirOrFallback 走默认时自动 ensurefsWatcher 不撞 ENOENT', () => {
// 用户反馈default 目录不存在 → fsWatcher 启动时 ENOENT 失败、侧栏空白。
// 这里验证 resolveDataDirOrFallback 路径会自动 ensure让 fsWatcher
// 紧接着的 startWatchingDir 不再 ENOENT。
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(false);
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(defaultDir);
// sync ensure 副作用default 目录必须已存在
expect(existsSync(defaultDir)).toBe(true);
});
it('resolveDataDirOrFallback fallback 路径也自动 ensure 默认目录', async () => {
// U 盘掉线 → custom 失效 → runtime fallback 到默认。如果默认也从未
// 建过/用过要立刻能列出文件welcome.md 由异步 ensure 种子)。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-ensure-'));
await configStore.saveConfig({ dataDir: realDir });
rmSync(realDir, { recursive: true, force: true });
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(false);
const out = configStore.resolveDataDirOrFallback();
expect(out.fellBack).toBe(true);
// sync ensure 已 mkdir 默认目录
expect(existsSync(defaultDir)).toBe(true);
// 异步种子fire-and-forget等 in-flight 完成
await configStore.scheduleEnsureDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
});
describe('scheduleEnsureDefaultDataDirfire-and-forget 去重)', () => {
it('多次调用 → 只跑一次(共享 in-flight promise', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
const p1 = configStore.scheduleEnsureDefaultDataDir();
const p2 = configStore.scheduleEnsureDefaultDataDir();
const p3 = configStore.scheduleEnsureDefaultDataDir();
// 共享同一份 Promise
expect(p1).toBe(p2);
expect(p2).toBe(p3);
await Promise.all([p1, p2, p3]);
// in-flight 已清空
const p4 = configStore.scheduleEnsureDefaultDataDir();
expect(p4).not.toBe(p1); // 新一次调用拿到的是新的(因为旧的已 settled
});
it('seedDefaultDataDir 仍是 ensureDefaultDataDir 的别名(向后兼容)', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
await configStore.seedDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
});
// 顺便校验 settings-schema 的 coerceLoadedSettings 也是兜底用的
describe('coerceLoadedSettingsschema 兜底)', () => {
it('未知字段被剥离', () => {
const out = coerceLoadedSettings({ theme: 'light', unknownField: 'x' });
expect(out.theme).toBe('light');
expect(out.unknownField).toBeUndefined();
});
it('已知字段缺省时用默认值', () => {
const out = coerceLoadedSettings({ theme: 'light' });
// 没传 dataDir → 走默认
expect(out.theme).toBe('light');
expect(out.dataDir).toBe(DEFAULT_CONFIG.dataDir);
});
it('空 / 非对象输入 → 默认值', () => {
expect(coerceLoadedSettings(null)).toEqual({ ...DEFAULT_CONFIG });
expect(coerceLoadedSettings(undefined)).toEqual({ ...DEFAULT_CONFIG });
expect(coerceLoadedSettings('garbage')).toEqual({ ...DEFAULT_CONFIG });
});
});

View File

@@ -0,0 +1,115 @@
// src/context-menu.js 的单测
//
// 主要覆盖 audit #C3critical memory leak修复
// - 快速 show→hide→show 循环rAF 回调里的 addEventListener 不应泄漏
// - 同步 hide() 应当取消未 fire 的 rAF避免它在我们 detach 之后再去挂监听
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ContextMenu } from '../../src/context-menu.js';
describe('ContextMenu show/hide lifecycle', () => {
let menu;
let container;
beforeEach(() => {
// 用独立 container避免污染全局 body / 与其它测试串扰
container = document.createElement('div');
container.id = 'context-menu-test-container';
document.body.appendChild(container);
menu = new ContextMenu({ container });
});
afterEach(() => {
menu.hide();
if (container.parentElement) container.parentElement.removeChild(container);
});
it('show 后 isVisible() → true', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
expect(menu.isVisible()).toBe(true);
expect(container.querySelector('.context-menu')).toBeTruthy();
});
it('hide 后 isVisible() → false', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
menu.hide();
expect(menu.isVisible()).toBe(false);
expect(container.querySelector('.context-menu')).toBeNull();
});
it('hide() 可重复调用(不抛错)', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
expect(() => {
menu.hide();
menu.hide();
menu.hide();
}).not.toThrow();
});
it('audit #C3show 后立即 hide应取消挂起的 rAF', async () => {
// 模拟场景用户打开菜单立刻关闭onSelect 路径)。原实现的 rAF 仍会
// fire 并挂 5 个 listener —— 现在必须被取消。
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
// rAF 还没 firejsdom 默认 fire但 show 同步执行后立即 hide 应抢先)
menu.hide();
// 等待足够时间让原 rAF如果没被取消会 fire 的窗口过去
await new Promise((r) => setTimeout(r, 50));
// 此时再 show 一次:必须能成功挂上新菜单
expect(() => {
menu.show({
x: 200, y: 200,
items: [{ label: '粘贴', value: 'paste' }],
onSelect: () => {},
});
}).not.toThrow();
expect(menu.isVisible()).toBe(true);
});
it('audit #C3show→hide→show 快速循环不抛错、不泄漏菜单节点', async () => {
// 模拟右键 → 选项 → 立即右键 的快速循环
for (let i = 0; i < 5; i += 1) {
menu.show({
x: 50 + i * 10, y: 50 + i * 10,
items: [{ label: `Item ${i}`, value: `v${i}` }],
onSelect: () => {},
});
menu.hide();
}
// 让所有挂起的 rAF 跑完
await new Promise((r) => setTimeout(r, 50));
// 容器里只应该有一个当前菜单,或者零个(取决于时序)
const menus = container.querySelectorAll('.context-menu');
expect(menus.length).toBeLessThanOrEqual(1);
expect(menu.isVisible()).toBe(false);
});
it('空 items 数组 → 不创建菜单', () => {
menu.show({ x: 100, y: 100, items: [], onSelect: () => {} });
expect(menu.isVisible()).toBe(false);
expect(container.querySelector('.context-menu')).toBeNull();
});
it('非数组 items → 不创建菜单', () => {
menu.show({ x: 100, y: 100, items: null, onSelect: () => {} });
expect(menu.isVisible()).toBe(false);
});
});

View File

@@ -0,0 +1,219 @@
// src/dirty-sync.js 测试Round 3 bug 修复 · 2026-08
//
// 验证单向提升同步契约:
// - state.isDirty=false + editor.isDirty()=true → state.isDirty=true提升
// - state.isDirty=true + editor.isDirty()=false → 不动(半步保存场景保留 true
// - state.isDirty=false + editor.isDirty()=false → 不动(真干净)
// - state.isDirty=true + editor.isDirty()=true → 不动(已一致)
//
// 半步保存save 飞行期用户敲字符)契约**绝对不**被破坏state.isDirty=true
// 是「doc 比 writeFile 内容多」的标志,不能被 editor 的基线对比覆盖。这是
// save-toast.js#decideSaveToast 之外的核心 invariant。
//
// 关联:[[project-dirty-sync-ime-race-2026-08]]。
/* @vitest-environment jsdom */
import { describe, it, expect } from 'vitest';
import { syncDirtyFromEditor } from '../../src/dirty-sync.js';
function makeState(isDirty = false) {
return { isDirty };
}
function makeEditor(isDirtyFn) {
return { isDirty: isDirtyFn };
}
describe('syncDirtyFromEditor —— IME race 单向提升契约', () => {
it('state=false + editor=true → 提升到 true核心 IME race bug 修复)', () => {
// 关键场景:用户用 IME 输入字符state.isDirty 因 onChange 被 updateListener
// 跳过而保持 false但 editor.doc 里有 IME 字符。同步后 state 必须跟上 truth
// 否则 save() 走 !state.isDirty 早退返回「已是最新」IME 字符丢失。
const state = makeState(false);
const editor = makeEditor(() => true);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(true);
expect(state.isDirty).toBe(true);
});
it('state=true + editor=false → 不动(半步保存契约,绝不能降级)', () => {
// 半步保存save 飞行期用户敲了字符 → state.isDirty=true 表示「doc 比
// writeFile 内容多」。editor.isDirty() 返回 false 是因为 markSaved 已把
// lastSavedDoc 设为当前 doc。这是有意的「飞行期新字符」标记本函数绝不能
// 把它盖成 false —— 否则下次 save() 会走早退,飞行期字符丢失。
const state = makeState(true);
const editor = makeEditor(() => false);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(true);
});
it('state=false + editor=false → 不动(真干净,无提升需要)', () => {
// 干净文件刚打开state 与 editor 都没改动 —— 同步是 no-op。
const state = makeState(false);
const editor = makeEditor(() => false);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(false);
});
it('state=true + editor=true → 不动(已一致,提升无意义)', () => {
// 普通 dirty 状态state 与 editor 都认为是 dirty —— 同步是 no-op。
const state = makeState(true);
const editor = makeEditor(() => true);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(true);
});
it('editor.isDirty() 是惰性求值:只在 state=false 时调用(短路优化)', () => {
// 性能契约state=true 时绝不应调 editor.isDirty() —— 否则 save() 每次
// 入口都会跑 doc.toString() 这种全量字符串对比。验证 spy 不被调用。
let calls = 0;
const state = makeState(true);
const editor = makeEditor(() => { calls += 1; return false; });
syncDirtyFromEditor(state, editor);
expect(calls).toBe(0);
});
it('editor.isDirty() 在 state=false 时确实被调用', () => {
let calls = 0;
const state = makeState(false);
const editor = makeEditor(() => { calls += 1; return false; });
syncDirtyFromEditor(state, editor);
expect(calls).toBe(1);
});
it('可重复调用(幂等):提升后再次调用是 no-op', () => {
// 多次调用不会「又提升一次」造成副作用累积。第二次 state=true 直接走短路。
const state = makeState(false);
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
expect(state.isDirty).toBe(true);
const secondCall = syncDirtyFromEditor(state, editor);
expect(secondCall).toBe(false);
expect(state.isDirty).toBe(true);
});
it('状态可观察state 是普通的 mutable 对象,函数只写 isDirty 这一个字段', () => {
// 防回归:本函数不应动 state 上的其他字段(比如 lastSavedMtimeMs——
// 调用方app.js用 state 做 dirty 决策外还做别的mtime 比对、IPC setDirty
// 等),如果这里意外改了其他字段会引入很难追的 bug。
const state = { isDirty: false, lastSavedMtimeMs: 12345, currentFile: { path: '/x.md' } };
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
expect(state.isDirty).toBe(true);
expect(state.lastSavedMtimeMs).toBe(12345);
expect(state.currentFile).toEqual({ path: '/x.md' });
});
});
describe('syncDirtyFromEditor —— 与 save() 早退检查的协同', () => {
// 这是 Round 3 bug 的核心契约save() 入口调本函数后,!state.isDirty 早退
// 检查必须可靠 —— 即「editor 有改动」+ 「state 同步后」= 「不早退」。
//
// 这些 case 不是测 save() 本体(那需要 mock 一堆 app.js 闭包),而是测
// syncDirtyFromEditor 产生的 state 状态,确保 save() 早退条件不会误判。
it('IME raceeditor 有字符 + state 已同步 → state.isDirty=truesave 不早退)', () => {
const state = makeState(false);
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
// 模拟 save() 的早退条件:`!state.isDirty`
expect(!state.isDirty).toBe(false); // 应该走 writeFile
});
it('真干净state 与 editor 都 false → state 仍 falsesave 早退 OK', () => {
const state = makeState(false);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(true); // save 早退是正确行为
});
it('半步保存state=true + editor=false → state 仍 truesave 走 writeFile', () => {
const state = makeState(true);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(false); // save 走 writeFile 落盘飞行期新字符
});
});
// ---------------------------------------------------------------------------
// applyEntries 守卫语义app.js line 2298
//
// 契约editor 有未保存字符时applyEntries 检测「文件被外部删除」必须
// 用 `editor.isDirty()`(实时真相),不能用 `state.isDirty`IME 合成
// 期间被 updateListener 的 !isComposing 守卫屏蔽,可能 stale-false
//
// Round 9 P0 bug旧实现 `!state.isDirty` → IME 期间 stale-false → 守卫
// 通过 → clearCurrentFile() → onFilesChanged line 2901 `if (!state.currentFile)
// return` 早退 → line 2917 syncDirtyFromEditor 永远不到 → line 2944 rescue
// 分支永远不到 → IME 字符随编辑器 view 销毁而丢失 + 窗口关闭 dirty=false
// 不弹保存提示 + 静默丢字。
//
// 这些测试不 import app.js4111 行 + 大量模块级闭包),直接重放守卫的
// if-condition 断言语义。app.js 里改动对应的守卫条件会被 grep 拦住。
// ---------------------------------------------------------------------------
describe('applyEntries 删除守卫语义 —— IME race 必须用 editor.isDirty()', () => {
// 重放 app.js#applyEntries 的守卫state.currentFile && state.currentFile.path && !<DIRTY_TRUTH>
function shouldClearOnFileDeleted(state, dirtyTruth) {
return Boolean(state.currentFile && state.currentFile.path && !dirtyTruth);
}
it('editor 有未保存字符IME 合成中) → 不能清 currentFile', () => {
// 关键场景:用户 IME 拼音期间 state.isDirty 是 stale-false
// 但 editor.isDirty() = true。守卫必须用 editor.isDirty() 才能
// 拦下 clearCurrentFile让 onFilesChanged 的 rescue 路径接管。
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
const editorDirty = true; // IME 缓冲里有字符
const stateDirtyStale = false; // onChange 被守卫屏蔽
// 旧实现state.isDirty会清 currentFile → 丢字
expect(shouldClearOnFileDeleted(state, stateDirtyStale)).toBe(true);
// 新实现editor.isDirty()):拦下 clearCurrentFile → rescue 接管
expect(shouldClearOnFileDeleted(state, editorDirty)).toBe(false);
});
it('正常 dirty 状态 → 不能清 currentFile', () => {
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
expect(shouldClearOnFileDeleted(state, true)).toBe(false);
});
it('真干净 → 清 currentFile外部删除正常处理', () => {
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
expect(shouldClearOnFileDeleted(state, false)).toBe(true);
});
it('没有打开文件 → 不清(无需处理)', () => {
const state = { currentFile: null };
expect(shouldClearOnFileDeleted(state, false)).toBe(false);
expect(shouldClearOnFileDeleted(state, true)).toBe(false);
});
it('currentFile 缺 path 字段 → 不清(异常状态防御)', () => {
const state = { currentFile: { name: 'foo.md' /* no path */ } };
expect(shouldClearOnFileDeleted(state, false)).toBe(false);
});
it('守卫与 syncDirtyFromEditor 的协同IME 提升后 rescue 路径必能触发', () => {
// 端到端验证 P0 fix 的修复链:
// 1. IME 期间 applyEntries 用 editor.isDirty() 拦下 clearCurrentFile
// 2. onFilesChanged 不早退
// 3. syncDirtyFromEditor 把 state.isDirty 提升到 true
// 4. rescue 分支 `if (state.isDirty)` 看到 true → 落救回文件
//
// 这里只测第 1 步和第 3 步(最容易回归),第 4 步是 onFilesChanged 的
// 既有契约applyEntries 不再触发的话 rescue 自然能到)。
const state = { currentFile: { path: '/foo.md', name: 'foo.md' }, isDirty: false };
const editor = makeEditor(() => true); // IME 期间 editor 一定有未保存字符
// step 1: 守卫拦下
expect(shouldClearOnFileDeleted(state, editor.isDirty())).toBe(false);
// step 3: syncDirtyFromEditor 把 state 提升
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(true);
expect(state.isDirty).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
// Stage 7 tests: editor-theme.js
//
// 覆盖:
// - pickEditorTheme 按 body[data-theme] 返回对应的 extensions 数组
// - 暗色叠加 oneDark亮色只用自己的覆盖
// - backgroundColor / color 必须带 !importantoneDark 后续注入的同 specificity
// 规则会赢,必须用 important 强制覆盖,否则编辑区会显示 #282c34 而不是 --bg-viewer
//
// editorTheme / editorThemeLight 是 EditorView.theme() 返回的 extension 对象,
// 内部是 CM6 私有结构,不展开断言 —— 只验证 pickEditorTheme 的行为即可。
import { describe, it, expect } from 'vitest';
import { pickEditorTheme, editorTheme, editorThemeLight, __editorThemeSpecForTests } from '../../src/editor-theme.js';
describe('pickEditorTheme', () => {
it('theme === "light" → 只返回 editorThemeLight', () => {
const exts = pickEditorTheme('light');
expect(exts).toHaveLength(1);
expect(exts[0]).toBe(editorThemeLight);
});
it('theme === "dark" → 返回 [oneDark, editorTheme]', () => {
const exts = pickEditorTheme('dark');
expect(exts).toHaveLength(2);
expect(exts[1]).toBe(editorTheme);
});
it('theme === undefined → 当暗色处理', () => {
const exts = pickEditorTheme(undefined);
expect(exts).toHaveLength(2);
expect(exts[1]).toBe(editorTheme);
});
it('theme === "auto"(旧版本残留) → 当暗色处理(防御性)', () => {
const exts = pickEditorTheme('auto');
expect(exts).toHaveLength(2);
});
it('theme === ""(空字符串) → 当暗色处理', () => {
const exts = pickEditorTheme('');
expect(exts).toHaveLength(2);
});
it('theme === null → 当暗色处理', () => {
const exts = pickEditorTheme(null);
expect(exts).toHaveLength(2);
});
});
describe('editorThemeSpec background overrides (oneDark / base theme 互斥)', () => {
// oneDark 的 .cm-editor { background-color: #282c34 } 和 .cm-gutters 同色,
// 在 dark theme 下会导致编辑区底色跟 .editor-pane / .viewer 的 --bg-viewer 不一致。
// 必须用 !important 强制覆盖,否则 oneDark 后注入的同 specificity 规则会赢。
it('.cm-editor backgroundColor 必须是 var(--bg-viewer) !important', () => {
const v = __editorThemeSpecForTests.backgroundColor;
expect(v).toMatch(/var\(--bg-viewer\)/);
expect(v).toMatch(/!important/);
});
it('.cm-editor color 必须是 var(--fg-primary) !important', () => {
const v = __editorThemeSpecForTests.color;
expect(v).toMatch(/var\(--fg-primary\)/);
expect(v).toMatch(/!important/);
});
it('.cm-gutters backgroundColor 必须是 var(--bg-viewer) !important', () => {
const v = __editorThemeSpecForTests.gutterBackgroundColor;
expect(v).toMatch(/var\(--bg-viewer\)/);
expect(v).toMatch(/!important/);
});
it('.cm-gutters color 必须是 var(--fg-tertiary) !important', () => {
const v = __editorThemeSpecForTests.gutterColor;
expect(v).toMatch(/var\(--fg-tertiary\)/);
expect(v).toMatch(/!important/);
});
// @codemirror/view 的 base theme 用 `&light .cm-activeLineGutter` / `&dark` 规则
//(特异性 (0,2,0))给 active 行 gutter 涂硬编码色(亮 #e2f2ff / 暗 #222227
// 我们的 .cm-activeLineGutter 是 (0,1,0),必须用 !important 才能让 var(--bg-hover) 稳定生效。
// 否则亮色下「活动行 gutter 变浅蓝色块」就是这个原因。
it('.cm-activeLine backgroundColor 必须是 var(--bg-hover) !important', () => {
const v = __editorThemeSpecForTests.activeLineBackgroundColor;
expect(v).toMatch(/var\(--bg-hover\)/);
expect(v).toMatch(/!important/);
});
it('.cm-activeLineGutter backgroundColor 必须是 var(--bg-hover) !important', () => {
const v = __editorThemeSpecForTests.activeLineGutterBackgroundColor;
expect(v).toMatch(/var\(--bg-hover\)/);
expect(v).toMatch(/!important/);
});
it('.cm-activeLineGutter color 必须是 var(--fg-secondary) !important', () => {
const v = __editorThemeSpecForTests.activeLineGutterColor;
expect(v).toMatch(/var\(--fg-secondary\)/);
expect(v).toMatch(/!important/);
});
// base theme 给 .cm-cursor 设黑色,&dark 下设 #528bffoneDark 的 cursor 蓝)。
// 我们用 var(--accent),需要 !important 才能不被覆盖。
it('.cm-cursor/.cm-dropCursor borderLeftColor 必须是 var(--accent) !important', () => {
const v = __editorThemeSpecForTests.cursorBorderLeftColor;
expect(v).toMatch(/var\(--accent\)/);
expect(v).toMatch(/!important/);
});
// .cm-content caretColor 同样被 base theme 影响 —— 加 !important 兜底。
it('.cm-content caretColor 必须是 var(--accent) !important', () => {
const v = __editorThemeSpecForTests.caretColor;
expect(v).toMatch(/var\(--accent\)/);
expect(v).toMatch(/!important/);
});
// base theme `&light .cm-panels { background: #f5f5f5; color: black }` /
// `&dark { background: #333338; color: white }` 会盖住搜索面板的颜色。
it('.cm-panels backgroundColor 必须是 var(--bg-elevated) !important', () => {
const v = __editorThemeSpecForTests.panelsBackgroundColor;
expect(v).toMatch(/var\(--bg-elevated\)/);
expect(v).toMatch(/!important/);
});
it('.cm-panels color 必须是 var(--fg-primary) !important', () => {
const v = __editorThemeSpecForTests.panelsColor;
expect(v).toMatch(/var\(--fg-primary\)/);
expect(v).toMatch(/!important/);
});
});

View File

@@ -0,0 +1,229 @@
// src/editor-toolbar.js
//
// 三态preview / edit / split视图模式控件图标按钮 + aria-pressed +
// body.dataset.editorModeCSS 据此切布局)。构造函数绑 click → selectMode。
//
// 测试覆盖:
// - selectMode 合法值:触发 onChange + DOM 更新 + aria-pressed 切换
// - selectMode 非法值:被静默忽略(不抛、不触发 onChange、不动 DOM
// - selectMode 同模式no-op不重复触发 onChange
// - setMode同步 DOM 但不触发 onChange用于外部「持久化设置改了」
// 反向同步 UI避免循环触发
// - getMode从 body.dataset.editorMode 读,默认 'preview'
// - cyclepreview → edit → split → preview不依赖 setMode 路径,
// 走 selectMode 路径所以每次都触发 onChange
// - a11y每个按钮都拿到 aria-pressed不仅 active 那个);不依赖按钮
// 缺省时整个 component 仍能工作(按钮可选)
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EditorToolbar, EDITOR_MODES } from '../../src/editor-toolbar.js';
function makeBtn() {
const btn = document.createElement('button');
btn.type = 'button';
return btn;
}
function setupDOM(initialMode = null) {
document.body.innerHTML = '';
if (initialMode === null) {
delete document.body.dataset.editorMode;
} else {
document.body.dataset.editorMode = initialMode;
}
const btnPreview = makeBtn();
const btnEdit = makeBtn();
const btnSplit = makeBtn();
document.body.append(btnPreview, btnEdit, btnSplit);
return { btnPreview, btnEdit, btnSplit };
}
describe('EditorToolbar 构造 + 事件绑定', () => {
it('按钮 click → selectMode(对应 mode) + onChange', () => {
// 起始 'split' —— 不与任何一个按钮 mode 重合,确保 click 都触发 onChange
const els = setupDOM('split');
const onChange = vi.fn();
new EditorToolbar({ elements: els, onChange });
els.btnPreview.click();
expect(onChange).toHaveBeenCalledWith('preview');
onChange.mockClear();
els.btnEdit.click();
expect(onChange).toHaveBeenCalledWith('edit');
onChange.mockClear();
els.btnSplit.click();
expect(onChange).toHaveBeenCalledWith('split');
});
it('缺按钮:对应 click 不绑、构造不抛', () => {
const els = setupDOM();
const onChange = vi.fn();
// 只给 btnEditbtnPreview / btnSplit 缺
const partial = { btnEdit: els.btnEdit };
expect(() => new EditorToolbar({ elements: partial, onChange })).not.toThrow();
els.btnEdit.click();
expect(onChange).toHaveBeenCalledWith('edit');
// 缺按钮的 click 不应触发 selectMode没绑事件
els.btnPreview.click();
// onChange 仍只被调用 1 次(来自 btnEdit 这次)
expect(onChange).toHaveBeenCalledTimes(1);
});
});
describe('EditorToolbar.selectMode', () => {
it('合法值:更新 body.dataset.editorMode + aria-pressed + is-active class', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.selectMode('edit');
expect(document.body.dataset.editorMode).toBe('edit');
expect(els.btnEdit.classList.contains('is-active')).toBe(true);
expect(els.btnPreview.classList.contains('is-active')).toBe(false);
expect(els.btnSplit.classList.contains('is-active')).toBe(false);
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('true');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('false');
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('false');
});
it('非法值:被忽略(不抛、不触发 onChange、不动 DOM', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
document.body.dataset.editorMode = 'split';
expect(() => tb.selectMode('wide')).not.toThrow();
expect(() => tb.selectMode('')).not.toThrow();
expect(() => tb.selectMode(null)).not.toThrow();
expect(() => tb.selectMode(undefined)).not.toThrow();
expect(onChange).not.toHaveBeenCalled();
expect(document.body.dataset.editorMode).toBe('split');
});
it('同模式 → no-op不重复触发 onChange', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.selectMode('edit');
expect(onChange).toHaveBeenCalledTimes(1);
tb.selectMode('edit');
expect(onChange).toHaveBeenCalledTimes(1);
tb.selectMode('split');
expect(onChange).toHaveBeenCalledTimes(2);
});
it('无 onChange 回调:不抛', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els });
expect(() => tb.selectMode('split')).not.toThrow();
expect(document.body.dataset.editorMode).toBe('split');
});
});
describe('EditorToolbar.setMode外部同步 UI不触发 onChange', () => {
it('同步 DOM 但不调用 onChange避免循环', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setMode('split');
expect(document.body.dataset.editorMode).toBe('split');
expect(els.btnSplit.classList.contains('is-active')).toBe(true);
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('true');
expect(onChange).not.toHaveBeenCalled();
});
it('同模式也执行 setDOM与 selectMode 行为不同)', () => {
// 用途:外部从持久化设置读了模式,反向同步 UI。即便模式已经是
// 当前值,仍要走一遍 setDOM 确保 aria-pressed / is-active 状态
// 是最新的(防止外部代码直接改过 dataset 但 UI 状态没跟上)。
const els = setupDOM();
document.body.dataset.editorMode = 'edit';
els.btnEdit.classList.add('is-active');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.setMode('edit');
// 即使「同模式」_setDOM 仍走一遍aria-pressed / is-active 被重写)
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('true');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('false');
});
it('非法值:静默忽略', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
document.body.dataset.editorMode = 'preview';
tb.setMode('not-a-mode');
expect(document.body.dataset.editorMode).toBe('preview');
});
});
describe('EditorToolbar.getMode', () => {
it('从 body.dataset.editorMode 读', () => {
const els = setupDOM();
document.body.dataset.editorMode = 'split';
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
expect(tb.getMode()).toBe('split');
});
it('dataset 未设置:返回默认 "preview"', () => {
const els = setupDOM();
delete document.body.dataset.editorMode;
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
expect(tb.getMode()).toBe('preview');
});
});
describe('EditorToolbar.cycle', () => {
it('preview → edit → split → preview每次都触发 onChange', () => {
const els = setupDOM();
const onChange = vi.fn();
delete document.body.dataset.editorMode;
const tb = new EditorToolbar({ elements: els, onChange });
tb.cycle();
expect(tb.getMode()).toBe('edit');
expect(onChange).toHaveBeenLastCalledWith('edit');
tb.cycle();
expect(tb.getMode()).toBe('split');
expect(onChange).toHaveBeenLastCalledWith('split');
tb.cycle();
expect(tb.getMode()).toBe('preview');
expect(onChange).toHaveBeenLastCalledWith('preview');
expect(onChange).toHaveBeenCalledTimes(3);
});
});
describe('EditorToolbar a11y', () => {
it('三个按钮都拿到 aria-pressed不仅 active 那个)', () => {
// 屏幕阅读器依靠 aria-pressed 判状态;非 active 按钮也要明确
// 'false' 而不是缺省(缺省时 SR 不知道这是 toggle
// 起始 'edit'selectMode('preview') 不会因同模式被早退。
const els = setupDOM('edit');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.selectMode('preview');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('true');
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('false');
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('false');
});
it('EDIT_MODES 导出值与内部一致', () => {
expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']);
});
});

View File

@@ -0,0 +1,145 @@
// src/event-emitter.js
//
// 极简 EventEmitter被 settings-store 等组件复用 —— 没有测试守护的话:
// - emit 不隔离异常 → 一个抛错的 handler 阻断后续 handler 与其它事件订阅者
// - on 返回的 unsubscribe 漏掉 → 测试 / HMR 场景下旧 listener 触发回调
// - off 不存在的 handler 抛错 → emit 路径异常
//
// 覆盖:
// - on / emit 基本语义payload 透传、多 handler
// - on 返回的 unsubscribe 函数(同一 handler 多次 on → 多次 unsub 才彻底移除)
// - emit 异常隔离:一个抛错不影响其它 handler也不影响后续 emit
// - emit 无订阅者no-op
// - off 不存在的 handler / eventno-op
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from '../../src/event-emitter.js';
describe('EventEmitter 基础语义', () => {
it('on + emithandler 被调用payload 透传', () => {
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('ping', handler);
ee.emit('ping', { a: 1 });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ a: 1 });
});
it('多个 handler 订阅同一事件 → emit 全部调用', () => {
const ee = new EventEmitter();
const h1 = vi.fn();
const h2 = vi.fn();
ee.on('x', h1);
ee.on('x', h2);
ee.emit('x', 'payload');
expect(h1).toHaveBeenCalledWith('payload');
expect(h2).toHaveBeenCalledWith('payload');
});
it('不同事件互不影响', () => {
const ee = new EventEmitter();
const h1 = vi.fn();
const h2 = vi.fn();
ee.on('a', h1);
ee.on('b', h2);
ee.emit('a', 1);
expect(h1).toHaveBeenCalledWith(1);
expect(h2).not.toHaveBeenCalled();
});
it('emit 无订阅者的事件no-op不抛错', () => {
const ee = new EventEmitter();
expect(() => ee.emit('nothing', 'x')).not.toThrow();
});
});
describe('EventEmitter unsubscribe', () => {
it('on 返回的函数等价于 off可作为 unsubscribe', () => {
const ee = new EventEmitter();
const handler = vi.fn();
const unsub = ee.on('e', handler);
expect(typeof unsub).toBe('function');
unsub();
ee.emit('e', 'x');
expect(handler).not.toHaveBeenCalled();
});
it('off 显式移除 handler', () => {
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('e', handler);
ee.off('e', handler);
ee.emit('e', 'x');
expect(handler).not.toHaveBeenCalled();
});
it('off 不存在的 handlerno-op不抛错', () => {
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('e', handler);
expect(() => ee.off('e', () => {})).not.toThrow();
// 原 handler 仍有效
ee.emit('e', 'x');
expect(handler).toHaveBeenCalledWith('x');
});
it('off 不存在的事件no-op不抛错', () => {
const ee = new EventEmitter();
expect(() => ee.off('no-such-event', () => {})).not.toThrow();
});
it('同一 handler on 多次 → unsubscribe 多次才彻底移除', () => {
// Set 语义:同一个 handler 加入 Set 多次只占一个 slotoff 一次就够。
// 这里验证 unsub/off 不会因为「多订阅一次」就漏移除。
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('e', handler);
ee.on('e', handler); // 重复订阅Set 语义:仍是 1 个 entry
ee.off('e', handler);
ee.emit('e', 'x');
expect(handler).not.toHaveBeenCalled();
});
});
describe('EventEmitter 异常隔离', () => {
let errorSpy;
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('一个 handler 抛错 → 其它 handler 仍执行(隔离)', () => {
const ee = new EventEmitter();
const after = vi.fn();
ee.on('e', () => {
throw new Error('boom');
});
ee.on('e', after);
expect(() => ee.emit('e', 'x')).not.toThrow();
expect(after).toHaveBeenCalledWith('x');
// 抛错被 console.error 记录
expect(errorSpy).toHaveBeenCalled();
// 记录的应该包含事件名 + error
const call = errorSpy.mock.calls[0];
expect(call[0]).toContain('EventEmitter');
expect(call[0]).toContain('e');
});
it('抛错的 handler 不影响后续 emit', () => {
const ee = new EventEmitter();
const good = vi.fn();
ee.on('e', () => {
throw new Error('boom');
});
ee.on('e', good);
ee.emit('e', 1);
// 第二次 emit抛错 handler 仍是同一闭包引用,照样抛、照样隔离
good.mockClear();
ee.emit('e', 2);
expect(good).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,83 @@
// shared/extension-lists.js 的单一事实源测试
// ============================================================================
//
// 之前 EDITABLE_EXTS 与 MARKDOWN_EXTS 在 main/file-ops.js 和 src/app.js 各
// 维护一份,列表漂移会导致「侧栏显示可编辑但 md 链接打不开」或反之的体验
// 割裂。本测试覆盖共享列表的内容与不变量,确保新增/删除扩展名只在
// shared/extension-lists.js 一处即可生效。
//
// 不变量:
// - MARKDOWN_EXTS ⊆ EDITABLE_EXTSMarkdown 一定可编辑)
// - 大小写:所有 key 已规范化为小写、不含点
// - 关键用户场景:.md / .markdown / .txt / .py / .json / .html 都覆盖
// - 防御:二进制扩展(.png / .exe一定不在
// ============================================================================
import { describe, it, expect } from 'vitest';
import { EDITABLE_EXTS, MARKDOWN_EXTS } from '../../shared/extension-lists.js';
describe('EDITABLE_EXTS', () => {
it('关键 Markdown 扩展在白名单内', () => {
expect(EDITABLE_EXTS.has('md')).toBe(true);
expect(EDITABLE_EXTS.has('markdown')).toBe(true);
});
it('常见纯文本 / 数据 / 配置扩展在白名单内', () => {
const expected = ['txt', 'text', 'log', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'ini', 'env'];
for (const ext of expected) {
expect(EDITABLE_EXTS.has(ext), `expected '${ext}' in EDITABLE_EXTS`).toBe(true);
}
});
it('主流编程语言扩展在白名单内', () => {
const expected = [
'py', 'js', 'ts', 'tsx', 'jsx',
'html', 'css', 'scss', 'vue', 'svelte',
'java', 'go', 'rs', 'rb', 'php',
'sh', 'bash', 'ps1', 'sql',
'c', 'cpp', 'h', 'cs', 'swift', 'scala', 'lua', 'pl', 'r', 'dart',
];
for (const ext of expected) {
expect(EDITABLE_EXTS.has(ext), `expected '${ext}' in EDITABLE_EXTS`).toBe(true);
}
});
it('二进制扩展一定不在白名单内', () => {
const binaries = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'pdf', 'zip', 'tar', 'gz', 'exe', 'dll', 'so', 'dylib', 'mp4', 'mp3', 'wav', 'docx', 'xlsx', 'pptx'];
for (const ext of binaries) {
expect(EDITABLE_EXTS.has(ext), `binary '${ext}' must NOT be in EDITABLE_EXTS`).toBe(false);
}
});
it('所有 key 都规范化为小写、不含点', () => {
for (const ext of EDITABLE_EXTS) {
expect(ext).toBe(ext.toLowerCase());
expect(ext).not.toMatch(/^\./);
expect(ext).not.toMatch(/\./);
}
});
});
describe('MARKDOWN_EXTS', () => {
it('仅 md / markdown', () => {
expect(MARKDOWN_EXTS.has('md')).toBe(true);
expect(MARKDOWN_EXTS.has('markdown')).toBe(true);
expect(MARKDOWN_EXTS.size).toBe(2);
});
it('所有 key 都规范化为小写、不含点', () => {
for (const ext of MARKDOWN_EXTS) {
expect(ext).toBe(ext.toLowerCase());
expect(ext).not.toMatch(/^\./);
expect(ext).not.toMatch(/\./);
}
});
});
describe('不变量', () => {
it('MARKDOWN_EXTS ⊆ EDITABLE_EXTSMarkdown 一定可编辑)', () => {
for (const ext of MARKDOWN_EXTS) {
expect(EDITABLE_EXTS.has(ext), `MARKDOWN_EXTS 的 '${ext}' 必须在 EDITABLE_EXTS 中`).toBe(true);
}
});
});

188
tests/unit/feedback.test.js Normal file
View File

@@ -0,0 +1,188 @@
// Stage 7+: feedback.js (showToast)
//
// 覆盖 feedback.js 的「被广泛调用但没测试」的分支:
// - #toast-container 缺失 → 静默 return不抛、不 console 噪声)
// - MAX_VISIBLE_TOASTS 超出 → 同步移除最早的(不等淡出动画)
// - dismiss() 重入守卫dataset.dismissed=1 后第二次调用直接 return
// (防 transition 重入 + 防 double-remove
// - duration=0 → 不挂 auto-close timer但 click 仍能关)
// - duration>0 → 计时器到期自动 dismiss
// - 默认 type='info' → className 含 is-info
// - message / type 写入 DOMrole=status + textContent
//
// 75+ 个调用点app.js / file-ops.js / settings-dialog.js 等。补上 guard 守护,
// 防止未来重构时无意改坏 eviction / dismiss 语义(数据丢失风险虽低,但 toast
// 满天飞也是用户能感知的 bug
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { showToast } from '../../src/feedback.js';
beforeEach(() => {
// 标准 toast container —— src/index.html 里的 id
document.body.innerHTML = '<div id="toast-container"></div>';
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
/**
* 等待一次 raf 让 DOM 操作 / 微任务走完 —— jsdom 里 setTimeout / 微任务
* 时序差异大,必须显式推进。
*/
function flushAll() {
// 推进 fake timersduration 到期)+ queueMicrotask
return Promise.resolve().then(() => Promise.resolve());
}
describe('showToast 基础行为', () => {
it('#toast-container 缺失 → 静默 return不抛、不写 console', () => {
document.body.innerHTML = '';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(() => showToast('hi')).not.toThrow();
expect(warn).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
warn.mockRestore();
log.mockRestore();
});
it('默认 type=info → toast 含 className "toast is-info"', () => {
showToast('hello');
const t = document.querySelector('#toast-container > .toast');
expect(t).not.toBeNull();
expect(t.className).toContain('is-info');
});
it('type=success / warning / error → 各自 className', () => {
showToast('a', 'success');
showToast('b', 'warning');
showToast('c', 'error');
const list = Array.from(document.querySelectorAll('#toast-container > .toast'));
expect(list.map((el) => el.className)).toEqual([
expect.stringContaining('is-success'),
expect.stringContaining('is-warning'),
expect.stringContaining('is-error'),
]);
});
it('message 写入 textContentrole=status', () => {
showToast('自定义消息', 'warning');
const t = document.querySelector('#toast-container > .toast');
expect(t.textContent).toBe('自定义消息');
expect(t.getAttribute('role')).toBe('status');
});
});
describe('showToast 自动关闭 + duration', () => {
it('duration>0 → 计时器到期后自动 dismiss', () => {
showToast('auto', 'info', 1000);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(1);
vi.advanceTimersByTime(1000);
// dismiss 内部还有 150ms 的 fade-out setTimeout
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
it('duration=0 → 不挂 auto-close timer但 click 仍能关', () => {
showToast('sticky', 'info', 0);
const t = document.querySelector('#toast-container > .toast');
expect(t).not.toBeNull();
vi.advanceTimersByTime(60_000);
// 还在
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(1);
// click 关闭
t.click();
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
});
describe('showToast click 立即关闭', () => {
it('点击 toast → 立即触发 dismiss', () => {
showToast('clickable', 'info', 60_000);
const t = document.querySelector('#toast-container > .toast');
t.click();
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
});
describe('showToast MAX_VISIBLE_TOASTS 上限', () => {
// MAX_VISIBLE_TOASTS = 5 — 反馈模块内部常量。如果改了这里也要改测试。
// 抽成常量让失败信息更可读。
const MAX = 5;
it('到上限再 push → 最旧的被同步移除', () => {
for (let i = 0; i < MAX; i += 1) showToast(`t${i}`);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(MAX);
showToast(`t${MAX}`);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(MAX);
// 最旧的 t0 应被踢出
const texts = Array.from(document.querySelectorAll('#toast-container > .toast'))
.map((el) => el.textContent);
expect(texts).not.toContain('t0');
expect(texts).toContain(`t${MAX}`);
});
it('连续 push 6 条 → 仍只剩最新 5 条', () => {
for (let i = 0; i < MAX + 1; i += 1) showToast(`t${i}`);
const list = Array.from(document.querySelectorAll('#toast-container > .toast'));
expect(list).toHaveLength(MAX);
const texts = list.map((el) => el.textContent);
expect(texts).toEqual(['t1', 't2', 't3', 't4', 't5']);
});
it('被踢出的 toast 带 data-dismissed="1"(防 timer / click 回调闭包污染)', () => {
for (let i = 0; i < MAX + 1; i += 1) showToast(`t${i}`);
// 已经从 DOM 移除 —— 但闭包里的 dismiss 标记应当仍为 1。
// 这里只能从新增的第 6 条倒推:第 6 条入 DOM 时把 t0 踢出,
// t0.dataset.dismissed 应是 1。
// 由于 t0 已经脱离 DOMdataset 仍可读dataset 是元素本身的属性)。
// 我们拿到的是被踢的节点引用 —— 没法直接从 container 查。
// 改为验证:再 push 1 条时,没有 console errordismiss 回调跑过、被守卫挡住)。
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
showToast('extra');
// 让所有到期 timer 跑一遍(包括 t0 的 auto-close timer —— 若 dismiss 没被
// 守卫挡,会触发 container.removeChild(t0),但 t0 已经脱离 DOM
// removeChild 抛 NotFoundError 进 console
vi.advanceTimersByTime(60_000);
expect(err).not.toHaveBeenCalled();
err.mockRestore();
});
});
describe('showToast dismiss() 重入守卫', () => {
it('同一 toast 的 dismiss 被多次触发auto + click 抢跑)→ 只移除一次', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
showToast('race', 'info', 1000);
const t = document.querySelector('#toast-container > .toast');
// click 先触发 dismissdataset.dismissed=1, fade-out timer 排队)
t.click();
// 同时计时器到期再触发 dismiss —— 应被 dataset 守卫挡住
vi.advanceTimersByTime(1000);
// 让两段 fade-out 完成
vi.advanceTimersByTime(500);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
// removeChild 不抛 NotFoundError —— 第二次 removeChild 被 parentElement 检查挡住
expect(err).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
err.mockRestore();
});
});

View File

@@ -0,0 +1,258 @@
// 集成测试:模拟完整的「右键文件 → 弹菜单 → 点击重命名」流程。
// 验证 file-list 的 contextmenu 委托、showFileListContextMenu 的菜单构造、
// 以及 ContextMenu 的 onSelect 回调链路。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileList } from '../../src/file-list.js';
import { ContextMenu } from '../../src/context-menu.js';
import { createFileOps } from '../../src/file-ops.js';
describe('右键文件 → 弹菜单流程', () => {
let list;
let onContextMenu;
let menu;
let container;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<div id="context-menu-test-container"></div>
<div id="modal-root"></div>
`;
container = document.getElementById('context-menu-test-container');
menu = new ContextMenu({ container });
onContextMenu = vi.fn();
list = new FileList({
element: document.getElementById('files'),
onSelect: vi.fn(),
onContextMenu,
});
list.setFiles([
{ name: 'a.md', path: '/d/a.md' },
{ name: 'b.md', path: '/d/b.md' },
]);
});
it('右键 li → onContextMenu 拿到对应 file', () => {
const li = document.querySelectorAll('.file-item')[1];
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
clientX: 100, clientY: 200,
});
li.dispatchEvent(evt);
expect(onContextMenu).toHaveBeenCalledTimes(1);
expect(onContextMenu.mock.calls[0][0].name).toBe('b.md');
expect(onContextMenu.mock.calls[0][1]).toBe(100);
expect(onContextMenu.mock.calls[0][2]).toBe(200);
});
it('右键 li → preventDefault 已被调(否则浏览器原生菜单会闪)', () => {
const li = document.querySelector('.file-item');
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
});
li.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
});
it('右键 ul 上的非 li 区域 → 不触发 onContextMenu不 preventDefault', () => {
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
});
document.getElementById('files').dispatchEvent(evt);
expect(onContextMenu).not.toHaveBeenCalled();
expect(evt.defaultPrevented).toBe(false);
});
});
describe('showFileListContextMenu 集成', () => {
let menu;
let container;
let lastOnSelect;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<div id="context-menu-test-container"></div>
<div id="modal-root"></div>
`;
container = document.getElementById('context-menu-test-container');
menu = new ContextMenu({ container });
lastOnSelect = null;
// 拦截 onSelect 以便测试断言「点击菜单项最终走到哪个回调」
const origShow = menu.show.bind(menu);
menu.show = (opts) => {
lastOnSelect = opts.onSelect;
return origShow(opts);
};
});
// 复刻 src/file-ops.js:260-277 的菜单构造逻辑(不 import file-ops
// 因为它依赖 state / controls / api / callbacks 太重)
function fakeShowFileListContextMenu(entry, x, y, contextMenu, callbacks) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) items.push({ label: '重命名', value: 'rename' });
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
contextMenu.show({
x, y, items,
onSelect: (value) => {
if (value === 'rename') callbacks.onRename(entry);
else if (value === 'reveal') callbacks.onReveal(entry);
else if (value === 'delete') callbacks.onDelete(entry);
},
});
}
it('菜单包含 rename / reveal / delete + separator', () => {
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu,
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
'重命名', '在文件夹中显示', '删除',
]);
});
it('folder 条目 → 菜单不包含 rename', () => {
fakeShowFileListContextMenu(
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
'在文件夹中显示', '删除',
]);
});
it('点击「重命名」 → onRename 回调被调,且菜单关闭', () => {
const onRename = vi.fn();
const onReveal = vi.fn();
const onDelete = vi.fn();
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu,
{ onRename, onReveal, onDelete }
);
expect(menu.isVisible()).toBe(true);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[0].click();
expect(onRename).toHaveBeenCalledTimes(1);
expect(onRename.mock.calls[0][0].name).toBe('a.md');
expect(onReveal).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
expect(menu.isVisible()).toBe(false);
});
it('点击「在文件夹中显示」 → onReveal 被调', () => {
const cb = { onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() };
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu, cb
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[1].click(); // 第二个按钮是「在文件夹中显示」
expect(cb.onReveal).toHaveBeenCalledTimes(1);
expect(cb.onRename).not.toHaveBeenCalled();
});
it('点击「删除」 → onDelete 被调', () => {
const cb = { onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() };
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu, cb
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[2].click(); // 第三个按钮是「删除」
expect(cb.onDelete).toHaveBeenCalledTimes(1);
});
});
// ==========================================================================
// revealFile 分支:文件走 showItemInFolder文件夹走 openDir
// ==========================================================================
//
// 背景:旧实现把文件和文件夹都丢给 api.showItemInFolder但主进程会
// stat.isFile() 校验目录被拒NOT_A_FILE用户在 folder 条目上点
// 「在文件夹中显示」反而弹「不是一个文件」错误 toast。修复后文件夹走
// api.openDirshell.openPath 直接打开目录本身)。
//
// 这里直接 import createFileOps用 vi.fn() 桩 api.openDir /
// api.showItemInFolder / window.api.friendlyFsError断言 revealFile 命中
// 正确的 IPC。
describe('revealFile IPC 分支', () => {
let fileOps;
let api;
let originalWindowApi;
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
api = {
showItemInFolder: vi.fn().mockResolvedValue({ ok: true }),
openDir: vi.fn().mockResolvedValue({ ok: true }),
};
// window.api.friendlyFsError 在 revealFile 失败分支用到
originalWindowApi = globalThis.window && globalThis.window.api;
if (typeof globalThis.window === 'undefined') globalThis.window = {};
globalThis.window.api = { friendlyFsError: vi.fn().mockReturnValue('stub') };
fileOps = createFileOps({
state: { currentFile: null, isDirty: false },
controls: {
contextMenu: { show: vi.fn(), hide: vi.fn() },
viewer: {},
fileList: {},
editor: { isDirty: () => false },
},
api,
callbacks: {
openFile: vi.fn(),
updateWindowTitle: vi.fn(),
clearCurrentFile: vi.fn(),
},
});
});
afterEach(() => {
if (originalWindowApi === undefined) {
delete globalThis.window.api;
} else {
globalThis.window.api = originalWindowApi;
}
});
it('editable 文件 → 走 api.showItemInFolder不调 openDir', async () => {
await fileOps.revealFile({ name: 'a.md', path: '/d/a.md', entryType: 'editable' });
expect(api.showItemInFolder).toHaveBeenCalledWith('/d/a.md');
expect(api.openDir).not.toHaveBeenCalled();
});
it('binary 文件 → 走 api.showItemInFolder', async () => {
await fileOps.revealFile({ name: 'img.png', path: '/d/img.png', entryType: 'binary' });
expect(api.showItemInFolder).toHaveBeenCalledWith('/d/img.png');
expect(api.openDir).not.toHaveBeenCalled();
});
it('folder 条目 → 走 api.openDir不调 showItemInFolder', async () => {
await fileOps.revealFile({ name: 'subdir', path: '/d/subdir', entryType: 'folder' });
expect(api.openDir).toHaveBeenCalledWith('/d/subdir');
expect(api.showItemInFolder).not.toHaveBeenCalled();
});
it('folder → openDir 返回失败 → 走 friendlyFsError toast不静默吞', async () => {
api.openDir.mockResolvedValue({ ok: false, code: 'NOT_A_DIRECTORY', message: '路径不是文件夹' });
await fileOps.revealFile({ name: 'subdir', path: '/d/subdir', entryType: 'folder' });
expect(globalThis.window.api.friendlyFsError).toHaveBeenCalledWith(
'NOT_A_DIRECTORY',
'路径不是文件夹'
);
});
it('缺 path 的 entry → 不发任何 IPC', async () => {
await fileOps.revealFile({ name: 'x', entryType: 'editable' });
expect(api.showItemInFolder).not.toHaveBeenCalled();
expect(api.openDir).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,348 @@
// src/file-list.js 测试
//
// 覆盖:
// - fuzzyScore 评分函数(纯函数):子串 vs 子序列、单词边界、相邻匹配、空 query
// - setFiles外部删除通知 (audit 2.1)
// - applyFilter排序模式name / mtime-desc、搜索 vs sortMode 优先级
// - moveActive边界空列表、首/尾)+ 键盘导航
// - dispose 后 listener 失效
//
// 涉及 DOM 的部分用 jsdom。注意fuzzyScore 未导出,本文件通过动态 import +
// 反射拿到(避免在生产代码里加 _test_ 前缀的导出)。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as FileListMod from '../../src/file-list.js';
const { FileList } = FileListMod;
// fuzzyScore 是模块内私有函数。办法:通过 setFiles + 排序结果反推(看哪个文件名排前)。
// 简单点:直接构造一个内部 fuzzyScore 的副本契约测试 —— 同样的输入应该得同样的排序。
// 真值表通过构造几个代表性文件名对比 sortMode= name vs search 顺序来锁定。
describe('FileList 搜索 / 排序行为', () => {
let element;
let searchInput;
let list;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<input id="search-input" type="text" />
`;
element = document.getElementById('files');
searchInput = document.getElementById('search-input');
list = new FileList({ element, searchInput, onSelect: vi.fn() });
});
// 工具:拿到 setFiles 后渲染到 DOM 的 li 列表(按渲染顺序)
function renderedNames() {
return Array.from(element.querySelectorAll('.file-item'))
.map((li) => li.dataset.path)
.map((p) => p.split(/[\\/]/).pop());
}
function feed(files) {
list.setFiles(files);
}
it('默认按文件名zh-CN locale排序', () => {
feed([
{ name: 'b.md', path: '/d/b.md' },
{ name: 'a.md', path: '/d/a.md' },
{ name: 'c.md', path: '/d/c.md' },
]);
expect(renderedNames()).toEqual(['a.md', 'b.md', 'c.md']);
});
it('mtime-desc 模式按修改时间倒序', () => {
list.setSort('mtime-desc');
feed([
{ name: 'old.md', path: '/d/old.md', mtimeMs: 100 },
{ name: 'new.md', path: '/d/new.md', mtimeMs: 300 },
{ name: 'mid.md', path: '/d/mid.md', mtimeMs: 200 },
]);
expect(renderedNames()).toEqual(['new.md', 'mid.md', 'old.md']);
});
it('无 mtime 时 mtime-desc 模式按 name 兜底(不抛错)', () => {
list.setSort('mtime-desc');
feed([
{ name: 'b.md', path: '/d/b.md' },
{ name: 'a.md', path: '/d/a.md' },
]);
// undefined 排序是稳定的 JS 行为;至少不该抛错
expect(renderedNames()).toHaveLength(2);
});
it('搜索词非空时 fuzzy 排序覆盖 mtime-desc', () => {
list.setSort('mtime-desc');
feed([
{ name: 'alpha.md', path: '/d/alpha.md', mtimeMs: 300 },
{ name: 'beta.md', path: '/d/beta.md', mtimeMs: 100 },
{ name: 'welcome.md', path: '/d/welcome.md', mtimeMs: 50 },
]);
// 搜 'wel' 应该让 welcome.md 排前,不管它的 mtime 是最旧的
searchInput.value = 'wel';
searchInput.dispatchEvent(new Event('input'));
// 等 debounce flush100ms
return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()[0]).toBe('welcome.md');
});
});
it('搜索词大小写不敏感', () => {
feed([
{ name: 'README.md', path: '/d/README.md' },
{ name: 'notes.md', path: '/d/notes.md' },
]);
searchInput.value = 'read';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()).toEqual(['README.md']);
});
});
it('搜索无匹配 → 空状态 + 提示文案', () => {
feed([
{ name: 'alpha.md', path: '/d/alpha.md' },
{ name: 'beta.md', path: '/d/beta.md' },
]);
searchInput.value = 'xyz-no-match';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()).toEqual([]);
// 空状态文案:搜索 vs 目录空 区分
expect(element.dataset.empty).toMatch(/没有匹配/);
});
});
it('目录真为空 → 空状态文案 = "此文件夹为空"', () => {
feed([]);
expect(element.dataset.empty).toBe('此文件夹为空');
});
it('搜索词触发模糊匹配:子序列允许字符不相邻', () => {
feed([
{ name: 'foo.md', path: '/d/foo.md' },
{ name: 'f_o_o_bar.md', path: '/d/f_o_o_bar.md' },
]);
searchInput.value = 'fob';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
// 'f-o-b' 在 'f_o_o_bar' 里按子序列能匹配f, o[4], b[7]
expect(renderedNames()).toContain('f_o_o_bar.md');
});
});
it('搜索高亮:匹配的子串用 <mark> 包起来', () => {
feed([{ name: 'README.md', path: '/d/README.md' }]);
searchInput.value = 'read';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
const mark = element.querySelector('.file-item mark');
expect(mark).not.toBeNull();
expect(mark.textContent.toLowerCase()).toBe('read');
});
});
});
describe('FileList 外部删除通知 (audit 2.1)', () => {
let element;
let list;
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
});
it('当前激活文件不在新 entries → activePath 清零(高亮消失)', () => {
// M-series fix (audit Phase M-H2)setFiles 不再触发 onFileRemoved。
// 删除判定上移到 app.js#applyEntries 用 directory 比对做精确区分。
// 这里只验证 setFiles 的渲染侧合约:高亮跟着 activePath 走,
// 不在新列表里的 active 文件会让 activePath 自动归零。
list = new FileList({ element, onSelect: vi.fn() });
list.setActive('/d/target.md');
expect(list.activePath).toBe('/d/target.md');
list.setFiles([{ name: 'other.md', path: '/d/other.md' }]);
expect(list.activePath).toBe(null);
});
it('当前激活文件仍存在 → activePath 保持', () => {
list = new FileList({ element, onSelect: vi.fn() });
list.setActive('/d/target.md');
list.setFiles([
{ name: 'target.md', path: '/d/target.md' },
{ name: 'other.md', path: '/d/other.md' },
]);
expect(list.activePath).toBe('/d/target.md');
});
it('新 entries 只剩同名文件夹folder entryType→ activePath 清零', () => {
// 文件路径 active 时,新 entries 只剩同名文件夹也视为"不在了"
// 因为 activePath 永远指向文件而非目录。这是 Phase 8 旧合约的延伸。
list = new FileList({ element, onSelect: vi.fn() });
list.setActive('/d/target');
list.setFiles([{ name: 'target', path: '/d/target', isFolder: true, entryType: 'folder' }]);
expect(list.activePath).toBe(null);
});
});
describe('FileList entryType 向后兼容', () => {
let element;
let list;
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
list = new FileList({ element, onSelect: vi.fn() });
});
it('无 entryType 字段 → 视为 editable', () => {
list.setFiles([{ name: 'foo.md', path: '/d/foo.md' }]);
const li = element.querySelector('.file-item');
expect(li.dataset.entryType).toBe('editable');
expect(li.classList.contains('is-editable')).toBe(true);
});
it('folder entryType → is-folder class', () => {
list.setFiles([
{ name: 'subdir', path: '/d/subdir', isFolder: true, entryType: 'folder' },
]);
const li = element.querySelector('.file-item');
expect(li.dataset.entryType).toBe('folder');
expect(li.classList.contains('is-folder')).toBe(true);
});
it('binary entryType → is-binary class + meta 包含"不可编辑"', () => {
list.setFiles([
{ name: 'image.png', path: '/d/image.png', size: 1234, entryType: 'binary' },
]);
const li = element.querySelector('.file-item');
expect(li.dataset.entryType).toBe('binary');
expect(li.classList.contains('is-binary')).toBe(true);
expect(li.querySelector('.file-meta').textContent).toMatch(/不可编辑/);
});
});
describe('FileList 键盘导航 / 委托', () => {
let element;
let list;
let onSelect;
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
onSelect = vi.fn();
list = new FileList({ element, onSelect });
list.setFiles([
{ name: 'a.md', path: '/d/a.md' },
{ name: 'b.md', path: '/d/b.md' },
{ name: 'c.md', path: '/d/c.md' },
]);
});
it('点击 li → onSelect 收到对应 file', () => {
const items = element.querySelectorAll('.file-item');
items[1].click();
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].name).toBe('b.md');
});
it('Enter 键在 li 上 → onSelect', () => {
const items = element.querySelectorAll('.file-item');
items[0].focus();
items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].name).toBe('a.md');
});
it('Space 键在 li 上 → onSelect', () => {
const items = element.querySelectorAll('.file-item');
items[2].focus();
items[2].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].name).toBe('c.md');
});
it('↓/↑ 不直接触发 onSelect仅移动高亮', () => {
const items = element.querySelectorAll('.file-item');
items[0].focus();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(onSelect).not.toHaveBeenCalled();
// 高亮应该挪到第二项
expect(items[1].classList.contains('is-active')).toBe(true);
});
it('↑/↓ 越界 → 钳制在 [0, last]', () => {
const items = element.querySelectorAll('.file-item');
list.setActive('/d/a.md');
items[0].focus();
// 第一项再按 ↑ → 应仍在第一项
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
expect(items[0].classList.contains('is-active')).toBe(true);
list.setActive('/d/c.md');
items[2].focus();
// 最后一项再按 ↓ → 应仍在最后一项
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(items[2].classList.contains('is-active')).toBe(true);
});
it('Home / End → first / last', () => {
const items = element.querySelectorAll('.file-item');
items[1].focus();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
expect(items[2].classList.contains('is-active')).toBe(true);
items[2].focus();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
expect(items[0].classList.contains('is-active')).toBe(true);
});
});
describe('FileList dispose lifecycle', () => {
let element;
let searchInput;
let list;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<input id="search-input" type="text" />
`;
element = document.getElementById('files');
searchInput = document.getElementById('search-input');
});
it('dispose 后点击 li 不再触发 onSelect', () => {
const onSelect = vi.fn();
list = new FileList({ element, searchInput, onSelect });
list.setFiles([{ name: 'a.md', path: '/d/a.md' }]);
list.dispose();
element.querySelector('.file-item').click();
expect(onSelect).not.toHaveBeenCalled();
});
it('dispose 后 searchInput 触发不再 crashdebounce timer 已清)', () => {
list = new FileList({ element, searchInput, onSelect: vi.fn() });
list.setFiles([{ name: 'a.md', path: '/d/a.md' }]);
list.dispose();
searchInput.value = 'query';
expect(() => {
searchInput.dispatchEvent(new Event('input'));
}).not.toThrow();
});
it('dispose 后不会因挂起的 debounce 触发出错', async () => {
list = new FileList({ element, searchInput, onSelect: vi.fn() });
list.setFiles([{ name: 'a.md', path: '/d/a.md' }]);
searchInput.value = 'a';
searchInput.dispatchEvent(new Event('input'));
// 在 100ms debounce 之内 dispose
list.dispose();
// 等到 debounce 应该 fire 的时间点
await new Promise((r) => setTimeout(r, 150));
// 没有任何报错监听已解绑flushSearch 不会跑)
});
});

1033
tests/unit/file-ops.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
// src/focus-trap.js 单测Phase O-L14 audit
//
// 覆盖:
// - IME 合成期间isComposing=true按 Tab 不抢焦点
// - keyCode=229Firefox / 旧 Chromium fallback按 Tab 不抢焦点
// - 普通 Tab 行为不变(首尾循环 / 容器内跳转)
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach } from 'vitest';
import { createFocusTrap } from '../../src/focus-trap.js';
let container;
let firstBtn;
let middleBtn;
let lastBtn;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
container.tabIndex = -1;
firstBtn = document.createElement('button');
firstBtn.textContent = 'first';
middleBtn = document.createElement('button');
middleBtn.textContent = 'middle';
lastBtn = document.createElement('button');
lastBtn.textContent = 'last';
container.appendChild(firstBtn);
container.appendChild(middleBtn);
container.appendChild(lastBtn);
document.body.appendChild(container);
});
function fireKey(target, opts) {
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...opts });
target.dispatchEvent(ev);
return ev;
}
describe('focus-trap IME 合成守门Phase O-L14', () => {
it('isComposing=true 时 Tab 不抢焦点preventDefault 不触发)', () => {
const trap = createFocusTrap(container);
// 模拟 IME 合成期间按 Tab
const ev = fireKey(middleBtn, { key: 'Tab', isComposing: true });
// IME 期间不拦 Tab让浏览器处理候选词
expect(ev.defaultPrevented).toBe(false);
trap.dispose();
});
it('keyCode=229Firefox / 旧 Chromium fallback时 Tab 不抢焦点', () => {
const trap = createFocusTrap(container);
const ev = fireKey(middleBtn, { key: 'Tab', keyCode: 229 });
expect(ev.defaultPrevented).toBe(false);
trap.dispose();
});
});

View File

@@ -0,0 +1,111 @@
// shared/friendly-fs-error.js —— 渲染端 + 主进程共用的 errno → 中文提示
//
// 覆盖 P0Round 4 收尾前 renderer 端有三份独立 mappingsrc/app.js /
// src/file-ops.js / main/file-ops.js用户看到的 toast 文案 EROFS / ENAMETOOLONG
// / ENOTDIR / ENOTEMPTY 不一致。本测试钉死 shared 模块的 mapping 文案main /
// preload / renderer 三处全依赖此单一事实源。每条 case 必须有断言 —— 防止未来
// 重构时改坏 mapping用户会看到误导的"未知错误")。
//
// shared/friendly-fs-error.js 是 CJSmodule.exports = { friendlyFsError }
// ESM 解析器不支持 named import from CJS —— vitest 在 node 环境下走 require 链路。
import { describe, it, expect } from 'vitest';
const { friendlyFsError } = require('../../shared/friendly-fs-error.js');
describe('friendlyFsError —— 已知 errno 映射', () => {
it('EACCES → 权限 / 占用提示', () => {
expect(friendlyFsError('EACCES')).toMatch(/占用|权限/);
});
it('EPERM → 权限 / 占用提示(与 EACCES 共用文案)', () => {
expect(friendlyFsError('EPERM')).toBe(friendlyFsError('EACCES'));
});
it('ENOSPC → 磁盘空间不足', () => {
expect(friendlyFsError('ENOSPC')).toBe('磁盘空间不足');
});
it('EROFS → 只读文件系统', () => {
expect(friendlyFsError('EROFS')).toBe('只读文件系统,无法写入');
});
it('EIO → 磁盘 I/O 错误', () => {
expect(friendlyFsError('EIO')).toBe('磁盘 I/O 错误');
});
it('EBUSY → 文件被其他程序占用', () => {
expect(friendlyFsError('EBUSY')).toBe('文件被其他程序占用');
});
it('ENAMETOOLONG → 路径过长', () => {
expect(friendlyFsError('ENAMETOOLONG')).toBe('路径过长');
});
it('ENOTDIR → 父目录不是目录', () => {
expect(friendlyFsError('ENOTDIR')).toBe('父目录不是目录');
});
it('EISDIR → 目标路径是文件夹', () => {
expect(friendlyFsError('EISDIR')).toBe('目标路径是文件夹,无法写入');
});
it('ENOTEMPTY → 目标文件夹不为空', () => {
expect(friendlyFsError('ENOTEMPTY')).toBe('目标文件夹不为空');
});
});
describe('friendlyFsError —— fallback 行为', () => {
it('未知 errno + 有 fallback → 返回 fallback', () => {
expect(friendlyFsError('EEXIST', '文件已存在')).toBe('文件已存在');
});
it('未知 errno + 无 fallback → 返回「未知错误」', () => {
expect(friendlyFsError('EEXIST')).toBe('未知错误');
});
it('未知 errno + fallback=空串 → 走 fallback空串本身', () => {
// 注意:空串是合法 fallback —— caller 故意传空串时不应被「未知错误」盖掉。
// 但代码实现是 `fallback || '未知错误'`,所以空串会被替换为「未知错误」。
// 这是有意的(避免空 toast但要把行为钉死。
expect(friendlyFsError('EEXIST', '')).toBe('未知错误');
});
it('undefined code → 走 default', () => {
expect(friendlyFsError(undefined, 'fallback')).toBe('fallback');
});
it('null code → 走 default', () => {
expect(friendlyFsError(null, 'fallback')).toBe('fallback');
});
it('空字符串 code → 走 default', () => {
expect(friendlyFsError('', 'fallback')).toBe('fallback');
});
});
describe('friendlyFsError —— 业务码K1-R4 audit 合约)', () => {
// audit fix (K1-R4):所有 file:* / shell:* IPC 错误返回都有 code 字段。
// 业务码PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED / FILE_TOO_LARGE 等)不在
// errno 白名单里renderer 一律走 fallback而 renderer 8 个调用点现在传
// result.message中文而不是 result.error业务码字符串做 fallback
// 这样业务码错误也能给用户友好提示。
it('PATH_NOT_ALLOWED → 走 fallback中文化 message', () => {
expect(friendlyFsError('PATH_NOT_ALLOWED', '路径不在数据目录内')).toBe('路径不在数据目录内');
});
it('SYMLINK_NOT_ALLOWED → 走 fallback', () => {
expect(friendlyFsError('SYMLINK_NOT_ALLOWED', '不允许在符号链接目录下创建文件')).toBe('不允许在符号链接目录下创建文件');
});
it('FILE_TOO_LARGE → 走 fallback', () => {
expect(friendlyFsError('FILE_TOO_LARGE', '文件超过 50 MB 上限')).toBe('文件超过 50 MB 上限');
});
it('FILE_CHANGED_EXTERNALLY → 走 fallback', () => {
expect(friendlyFsError('FILE_CHANGED_EXTERNALLY', '文件在外部被修改')).toBe('文件在外部被修改');
});
it('NOT_A_FILE → 走 fallback', () => {
expect(friendlyFsError('NOT_A_FILE', '该路径是文件夹')).toBe('该路径是文件夹');
});
});

View File

@@ -0,0 +1,364 @@
// Stage 8 tests: shared/markdown-diff.js
//
// 覆盖:
// - computeFullMarkdownDiff纯增 / 纯删 / 改 / 跨多 region / 空内容
// - applyDiffRegionSafely成功路径 + conflict找不到 / 多个匹配)
// - endsWithNewline 保留
// - 行级 MAX_LINE_DIFF_CELLS 守卫(过大输入不爆炸)
// - tokenDiff 选项生效路径(仅断言结构,不验证具体分词)
//
// shared/ 是 CommonJS直接 require 即可。
import { describe, it, expect } from 'vitest';
const { computeFullMarkdownDiff, applyDiffRegionSafely } = require('../../shared/markdown-diff.js');
describe('computeFullMarkdownDiff', () => {
it('identical content → 空 regionsrows 只剩 context', () => {
const out = computeFullMarkdownDiff('a\nb\nc', 'a\nb\nc');
expect(out.regions).toEqual([]);
// 没有 regionId 的行就是纯 context
const regionsInRows = out.rows.filter((r) => r.regionId).length;
expect(regionsInRows).toBe(0);
});
it('空内容 + 空内容 → 无 rows / 无 regions', () => {
const out = computeFullMarkdownDiff('', '');
expect(out.rows).toEqual([]);
expect(out.regions).toEqual([]);
});
it('BOM 仅出现在文件头 → 被剥掉,不影响 diff 结果Phase 8 fix', () => {
// Windows Notepad / PowerShell pipeline 写 UTF-8 BOM (U+FEFF) 在文件头。
// 不剥 → 首行变成 "# Title"LCS 看到 oldLines[0] !== newLines[0]
// 每次「应用全部」都把首行当成「被改」渲染。
const base = '# Title\nline2';
const next = '# Title\nline2'; // 一字未动
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toEqual([]);
// rows 只剩 context无任何 regionId
expect(out.rows.filter((r) => r.regionId)).toEqual([]);
});
it('BOM 只剥头部一次,正文中残留的 BOM 保留', () => {
// intra-content 的 BOM 不剥:可能是有意为之(极少),且剥错会破坏内容。
const base = 'line1\nmiddle\nline3';
const next = 'line1\nmiddle\nline3';
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toEqual([]);
});
it('BOM + 真改动diff 只反映真改动BOM 不计入 regions', () => {
const base = '# Title\nold';
const next = '# Title\nnew';
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toHaveLength(1);
// region 应只包含改动的行,不包含被剥的 BOM
const changed = out.regions[0];
expect(changed.newLines).toEqual(['new']);
expect(changed.oldLines).toEqual(['old']);
});
it('纯新增一行:产生 1 个 region + 1 个 added row', () => {
const out = computeFullMarkdownDiff('a\nb', 'a\nx\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual([]);
expect(region.newLines).toEqual(['x']);
// 新增行的 type 应是 'added'
const addedRows = out.rows.filter((r) => r.type === 'added' && r.regionId === region.id);
expect(addedRows).toHaveLength(1);
expect(addedRows[0].segments[0].text).toBe('x');
});
it('纯删除一行oldLines 有内容newLines 为空', () => {
const out = computeFullMarkdownDiff('a\nx\nb', 'a\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual(['x']);
expect(region.newLines).toEqual([]);
const removedRows = out.rows.filter((r) => r.type === 'removed' && r.regionId === region.id);
expect(removedRows).toHaveLength(1);
expect(removedRows[0].segments[0].text).toBe('x');
});
it('修改一行:产生 removed + added 一对oldLines/newLines 各 1', () => {
const out = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual(['foo']);
expect(region.newLines).toEqual(['bar']);
});
it('多处独立修改:产生多个 region', () => {
const out = computeFullMarkdownDiff(
'a\nfoo1\nb\nfoo2\nc',
'a\nbar1\nb\nbar2\nc'
);
expect(out.regions).toHaveLength(2);
});
it('行级 MAX_LINE_DIFF_CELLS 守卫超大输入不爆炸fallback 到行级 diff', () => {
// MAX_LINE_DIFF_CELLS = 200_000构造 n*m > 200_000 但内容仍合法的输入
// 期望返回结果仍合理rows / regions 数量与内容一致),不会抛错或返回 undefined
const n = 1000;
const oldLines = Array.from({ length: n }, (_, i) => `old-${i}`);
const newLines = oldLines.map((l, i) => (i === 500 ? `NEW-${i}` : l));
const oldContent = oldLines.join('\n');
const newContent = newLines.join('\n');
// 1000 * 1000 = 1_000_000 cells >> 200_000 守卫
const out = computeFullMarkdownDiff(oldContent, newContent);
expect(out).toBeTruthy();
expect(Array.isArray(out.rows)).toBe(true);
expect(Array.isArray(out.regions)).toBe(true);
// 至少能识别出那 1 处修改
expect(out.regions.length).toBeGreaterThanOrEqual(1);
});
it('H3行级 fallback 触发时返回 warnings 字段audit H3 修复)', () => {
// MAX_LINE_DIFF_CELLS = 200_000。需要构造一个 oldMiddle × newMiddle > 200_000
// 的输入。如果两批行毫无公共前后缀prefix/suffix trim 起不到作用,
// middle 长度就是 n必然走 fallback。
const n = 1000;
const oldLines = Array.from({ length: n }, (_, i) => `old-${i}`);
// 完全打乱:所有 new 行都不等于任何 old 行 → LCS 退化为 O(n²)
const newLines = Array.from({ length: n }, (_, i) => `new-${i}`);
// sanity1000 * 1000 = 1_000_000 >> 200_000
const out = computeFullMarkdownDiff(oldLines.join('\n'), newLines.join('\n'));
expect(Array.isArray(out.warnings)).toBe(true);
expect(out.warnings.length).toBeGreaterThanOrEqual(1);
// 至少一条提示「行级 diff 超过 ... cells 上限」
expect(out.warnings.some((w) => /行级 diff 超过/.test(w))).toBe(true);
});
it('H3正常 diff 不带 warnings 字段(保持 return shape 稳定)', () => {
const out = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
expect(out.warnings).toBeUndefined();
});
it('tokenDiff 选项不破坏结构(接口兼容性)', () => {
const out = computeFullMarkdownDiff('hello world', 'hello there', { tokenDiff: true });
expect(out).toBeTruthy();
expect(Array.isArray(out.rows)).toBe(true);
expect(Array.isArray(out.regions)).toBe(true);
});
});
describe('applyDiffRegionSafely', () => {
it('成功替换:唯一匹配', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb');
});
it('保留末尾换行endsWithNewline', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb\n', 'a\nbar\nb\n');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb\n', region);
expect(r.ok).toBe(true);
expect(r.content.endsWith('\n')).toBe(true);
expect(r.content).toBe('a\nbar\nb\n');
});
it('成功替换 + 没有末尾换行:不补 \\n', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content.endsWith('\n')).toBe(false);
});
it('CRLF 文件apply 后行尾仍是 CRLF不被改成 LF', () => {
const before = 'a\r\nfoo\r\nb\r\n';
const after = 'a\r\nbar\r\nb\r\n';
// 用 LF 描述 diff算法内部按 LF 算 region但实际磁盘上是 CRLF。
const diff = computeFullMarkdownDiff('a\nfoo\nb\n', 'a\nbar\nb\n');
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.includes('\r\n')).toBe(true);
expect(r.content.includes('\nfoo')).toBe(false); // 行内不含裸 LF
});
it('LF 文件apply 后行尾仍是 LF不被改成 CRLF', () => {
const before = 'a\nfoo\nb\n';
const after = 'a\nbar\nb\n';
const diff = computeFullMarkdownDiff(before, after);
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.includes('\r\n')).toBe(false);
});
it('CRLF 文件 + 末尾无换行apply 后仍无末尾换行', () => {
const before = 'a\r\nfoo\r\nb';
const after = 'a\r\nbar\r\nb';
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.endsWith('\r\n')).toBe(false);
expect(r.content.endsWith('\n')).toBe(false);
});
it('conflict旧行已被删除 → 0 匹配 → ok:false', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 当前内容里 "foo" 已经被改成别的
const r = applyDiffRegionSafely('a\nbaz\nb', region);
expect(r.ok).toBe(false);
expect(typeof r.reason).toBe('string');
expect(r.reason.length).toBeGreaterThan(0);
});
it('conflict多处重复 + oldStart 失效 → 多匹配 → ok:false', () => {
// region.oldStart 是算法给出的「最佳位置」hint —— 当 preferredIndex 处的行
// 跟 oldLines[0] 对不上时findSequence 才退回去扫描全部匹配。
// 构造场景:用户编辑后,原 region 指向的"foo"被改成别的,
// 而文件其它位置仍出现 "foo"(多次匹配)—— 算法无法确定应替换哪一处。
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 当前内容:原 region 位置的 "foo" 已被改成 "X",但文件其它两处仍有 "foo"
// findSequence 找不到 preferredIndex 命中 → 扫描全文 → 2 处匹配 → conflict
const r = applyDiffRegionSafely('foo\nX\nfoo\nb', region);
expect(r.ok).toBe(false);
expect(typeof r.reason).toBe('string');
});
it('纯新增 + 唯一上下文:能安全插入', () => {
const diff = computeFullMarkdownDiff('a\nb', 'a\nx\nb');
expect(diff.regions).toHaveLength(1);
const region = diff.regions[0];
// region.oldLines=[],但 beforeContext=['a'], afterContext=['b']
expect(region.oldLines).toEqual([]);
expect(region.newLines).toEqual(['x']);
expect(region.beforeContext).toEqual(['a']);
expect(region.afterContext).toEqual(['b']);
const r = applyDiffRegionSafely('a\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nx\nb');
});
it('apply 完一轮后内容应当再次能 computeidempotent 内容一致)', () => {
const oldContent = 'a\nfoo\nb';
const newContent = 'a\nbar\nb';
const diff = computeFullMarkdownDiff(oldContent, newContent);
const region = diff.regions[0];
const applied = applyDiffRegionSafely(oldContent, region);
expect(applied.ok).toBe(true);
// 应用之后再算一次 diffregions 应为空
const secondDiff = computeFullMarkdownDiff(applied.content, newContent);
expect(secondDiff.regions).toEqual([]);
});
});
describe('C1 整篇替换 / 无上下文 regionaudit C1 fix', () => {
it('整篇替换region 无上下文 → 标 conflict 引导走应用全部', () => {
// 场景AI 把整段换成 X/Y前后没保留任何 context 行
const out = computeFullMarkdownDiff('A\nB', 'X\nY');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.beforeContext).toEqual([]);
expect(region.afterContext).toEqual([]);
expect(region.conflict).toMatch(/无上下文/);
});
it('在空文档插入新内容region 无上下文 → 标 conflict', () => {
const out = computeFullMarkdownDiff('', 'X\nY');
expect(out.regions).toHaveLength(1);
expect(out.regions[0].beforeContext).toEqual([]);
expect(out.regions[0].afterContext).toEqual([]);
expect(out.regions[0].conflict).toBeTruthy();
});
it('局部修改(保留 contextregion 不应被标 conflict', () => {
const out = computeFullMarkdownDiff('A\nfoo\nB', 'A\nbar\nB');
expect(out.regions).toHaveLength(1);
expect(out.regions[0].conflict).toBeUndefined();
});
});
describe('C3 歧义匹配二次校验audit C3 fix', () => {
it('oldLines 唯一匹配但上下文漂移 → 拒绝应用', () => {
// region 算出来 oldStart=1前后 context 期望为 ["a"]/["b"]
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 用户在前面又加了一个完全相同的 "foo"——oldLines 还能匹配到 preferredIndex 那个,
// 但**该位置的前后文**与 region 算出的不同(前面是 "X" 不是 "a",后面是 "foo" 不是 "b")。
// 这就是「复制粘贴造成歧义」场景——必须靠 beforeContext/afterContext 二次校验兜底。
const drifted = 'X\nfoo\nfoo\nfoo';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/上下文漂移/);
});
it('oldLines 唯一匹配 + 上下文一致 → 正常替换', () => {
// 控制组:完全没漂移的常规 case
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb');
});
// fix(audit 2026-08):多匹配但 context 唯一 → 仍能安全应用。
// 场景用户在其它位置粘了相同行oldLines 匹配多个下标,但只有一个位置的
// 前后文与 region 一致 —— 用 context 过滤掉歧义,应用 context 唯一的那处。
it('多匹配 + 唯一 context → 应用 context 匹配位置', () => {
// region 期望 oldLines=['foo'], beforeContext=['a'], afterContext=['b']
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// drifted: 有 2 个 foo但只有 index 1 处的 context 是 a...bindex 3 上下文是 c...d
const drifted = 'a\nfoo\nb\nc\nfoo\nd';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb\nc\nfoo\nd');
});
// fix(audit 2026-08):多匹配 + 多处 context 都一致 → 真歧义refuse。
it('多匹配 + 多处 context 都一致 → 拒绝', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 两段都是 a...foo...b 上下文 + foo 行重复 —— region context 与两处都一致
const duplicated = 'a\nfoo\nb\n...\na\nfoo\nb';
const r = applyDiffRegionSafely(duplicated, region);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/多个相同位置/);
});
// fix(audit 2026-08)preferredIndex 命中 ≠ 唯一匹配时,必须扫描全文。
// 旧版在 preferredIndex 命中时直接 early-return [preferredIndex],跳过扫描 →
// 其它位置的重复匹配被静默忽略apply 会改错行。
// 新行为:扫描全文 → context 过滤 → 唯一 context 匹配位置 → apply。
it('preferredIndex 命中 + 其它位置重复 + context 唯一 → 应用正确位置', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0]; // oldStart=1, context ['a']/['b']
// preferredIndex=1 处仍是 'a\nfoo\nb'context 匹配index 4 也匹配 foo
// 但 index 4 处上下文是 ['X']/['Y'],不匹配 region
const drifted = 'a\nfoo\nb\nX\nfoo\nY';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb\nX\nfoo\nY');
});
});
describe('C4 整段删除不被空防线误伤audit C4 fix', () => {
// 注意:纯 region 删除合法性的拦截逻辑在 ai-controller.jshandlesApplyRegion
// 空内容防线。shared/markdown-diff.js 自身允许 appliedContent 为空字符串——
// 这部分测试在 ai-controller 层覆盖。这里只验证算法层"整段删除"算出来就是 ""。
it('整段删除apply 后内容为 ""', () => {
const diff = computeFullMarkdownDiff(' \n ', '');
expect(diff.regions).toHaveLength(1);
const region = diff.regions[0];
expect(region.oldLines.length).toBeGreaterThan(0);
expect(region.newLines).toEqual([]);
const r = applyDiffRegionSafely(' \n ', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('');
});
});

View File

@@ -0,0 +1,205 @@
// src/markdown-editor.js 测试 —— IME compositionend dirty flush
//
// 回归背景updateListener 里 `!isComposing` 守卫在 IME 合成期间屏蔽 onChange
// 回调(避免 CJK 每个拼音字母触发一次 scheduleLivePreview 全页重建)。但
// compositionend 后 CM6 不一定再产生新 transaction —— 此时 onChange 永远不
// 触发state.isDirty 保持 falsesave() 走 !isDirty 早退返回 true用户
// 看到中文已上屏但磁盘文件没写入。
//
// 修复compositionend handler 主动比较 doc 与 lastSavedDoc不同则补发
// onChange 让 app 层翻脏态。本文件覆盖这条新路径。
//
// 也覆盖:
// - IME 期间 _composing 标记正确翻转
// - IME 取消doc 未变)不触发 onChange
// - dispose 后 compositionend 不抛
// - applyUserChange 在 IME 期间抛 EDITOR_COMPOSING
//
// 注意jsdom 不实现 contenteditable / 真实 IME 事件,所以直接调
// editor._onCompositionStart / _onCompositionEnd 模拟。
/* @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownEditor } from '../../src/markdown-editor.js';
// jsdom 不实现 scrollIntoViewMarkdownEditor 本身不调,但 CM6 basicSetup 可能
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// CM6 的 requestAnimationFrame 探测 —— jsdom 没有polyfill
if (typeof window.requestAnimationFrame === 'undefined') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
// CM6 RectangleMarker 测量选中区时调 Range#getClientRectsjsdom 没实现
// Range#textRange 是 jsdom 假实现但没挂 getClientRects。每次 measure 抛 TypeError
// 会被 vitest 写到 stderr污染测试输出。返回空数组让 CM6 跳过选中区绘制。
if (typeof Range.prototype.getClientRects !== 'function') {
Range.prototype.getClientRects = function () {
// 真实浏览器返回 DOMRectListCM6 只迭代 + 读 .top/.left/.width/.height。
// 用 array-like 占位即可,避免导入完整 DOMRect 带来的兼容开销
const arr = [];
arr.item = (i) => arr[i] || null;
return arr;
};
}
if (typeof Range.prototype.getBoundingClientRect !== 'function') {
Range.prototype.getBoundingClientRect = function () {
return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 };
};
}
function createEditor(opts = {}) {
const el = document.createElement('div');
document.body.appendChild(el);
const onChange = vi.fn();
const onSave = vi.fn();
const editor = new MarkdownEditor({ element: el, onChange, onSave });
return { editor, el, onChange, onSave };
}
describe('MarkdownEditor IME compositionend dirty flush', () => {
let editor, el, onChange;
beforeEach(() => {
({ editor, el, onChange } = createEditor());
editor.loadFile({ path: '/test.md', name: 'test.md' }, 'hello');
});
it('IME 提交后 doc 变化 → compositionend 补发 onChange', () => {
// 初始doc === lastSavedDoc → isDirty = false
expect(editor.isDirty()).toBe(false);
expect(onChange).not.toHaveBeenCalled();
// 模拟 IME 开始
editor._onCompositionStart();
expect(editor._composing).toBe(true);
// 模拟 IME 期间 CM6 把中文写入了 doc。
// userEvent: 'input.type.compose' 让 updateListener 识别为 IME 合成事件,
// isComposing=true → onChange 被守卫屏蔽(这是原来的设计意图)。
const view = editor.view;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '你好世界' },
userEvent: 'input.type.compose',
});
// 合成期间 onChange 被守卫屏蔽(这是原来的设计意图)
expect(onChange).not.toHaveBeenCalled();
// 但 doc 已经变了
expect(view.state.doc.toString()).toBe('你好世界');
expect(editor.isDirty()).toBe(true);
// 模拟 IME 结束 —— 这是修复的关键compositionend 必须补发 onChange
editor._onCompositionEnd();
expect(editor._composing).toBe(false);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('IME 取消doc 未变)→ compositionend 不补发 onChange', () => {
expect(editor.isDirty()).toBe(false);
editor._onCompositionStart();
expect(editor._composing).toBe(true);
// 用户取消 IME按 Esc—— doc 没变
editor._onCompositionEnd();
expect(editor._composing).toBe(false);
// doc 没变 → 不应触发 onChange
expect(onChange).not.toHaveBeenCalled();
});
it('IME 期间已有 onChange非 IME 字符触发)→ compositionend 不重复调', () => {
// 场景IME 期间用户也敲了一个英文字符updateListener 因 !isComposing
// 守卫不调 onChange但如果将来守卫被移除或行为变化compositionend 也不
// 应该重复调(因为 doc 已在 updateListener 里触发过 onChange
// 注意:当前实现中 updateListener 在 isComposing 时不调 onChange所以
// 这个测试验证 compositionend 只按「doc != lastSavedDoc」判断
// 不关心 onChange 之前是否已被调过。
editor._onCompositionStart();
const view = editor.view;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '中文' },
userEvent: 'input.type.compose',
});
// 手动模拟 updateListener 路径已调过 onChange假设守卫被绕过的场景
onChange.mockClear();
// compositionend 仍然会触发 onChange因为 doc != lastSavedDoc
// 这是「宁可多调一次,不可漏调」的设计选择
editor._onCompositionEnd();
expect(onChange).toHaveBeenCalledTimes(1);
});
it('dispose 后 compositionend 不抛', () => {
editor._onCompositionStart();
editor.dispose();
// dispose 后 view 被销毁compositionend 不应抛
expect(() => editor._onCompositionEnd()).not.toThrow();
expect(editor._composing).toBe(false);
});
it('IME 期间 applyUserChange 抛 EDITOR_COMPOSING', () => {
editor._onCompositionStart();
expect(() => editor.applyUserChange('新内容')).toThrow('EDITOR_COMPOSING');
});
it('非 IME 期间 applyUserChange 不抛', () => {
expect(() => editor.applyUserChange('新内容')).not.toThrow();
});
// audit fix (Round 4 F2)setExternalContent 也要 IME 守卫。
// fs-watcher 在 IME 合成期推「外部修改」→ view.dispatch 全量替换 doc
// 会摧毁 IME 合成 buffer已输入的拼音字符消失。与 applyUserChange 行为
// 对齐:合成期间抛 EDITOR_COMPOSING 让调用方 catch 后给用户「请稍后再试」。
it('IME 期间 setExternalContent 抛 EDITOR_COMPOSING', () => {
editor._onCompositionStart();
expect(() => editor.setExternalContent('外部内容')).toThrow('EDITOR_COMPOSING');
// doc 没被替换
expect(editor.getContent()).toBe('hello');
});
it('非 IME 期间 setExternalContent 不抛 + lastSavedDoc 同步更新', () => {
expect(editor.isDirty()).toBe(false);
editor.setExternalContent('外部内容');
expect(editor.getContent()).toBe('外部内容');
expect(editor.isDirty()).toBe(false);
});
});
describe('MarkdownEditor 基础行为', () => {
it('loadFile 后 isDirty = false', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'content');
expect(editor.isDirty()).toBe(false);
expect(editor.getContent()).toBe('content');
});
it('输入后 isDirty = true', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'content');
editor.view.dispatch({
changes: { from: 0, to: editor.view.state.doc.length, insert: 'changed' },
});
expect(editor.isDirty()).toBe(true);
expect(editor.getContent()).toBe('changed');
});
it('markSaved 后 isDirty 归零', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'old');
editor.view.dispatch({
changes: { from: 0, to: editor.view.state.doc.length, insert: 'new' },
});
expect(editor.isDirty()).toBe(true);
editor.markSaved();
expect(editor.isDirty()).toBe(false);
});
it('unload 后再 compositionend 不抛view 已 null', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, '');
editor.unload();
expect(() => editor._onCompositionEnd()).not.toThrow();
});
});

View File

@@ -0,0 +1,778 @@
// src/markdown-viewer.js 测试
//
// 覆盖:
// - 链接点击委托:内部锚点 / 外部链接 / 空 href 区分
// - scrollToAnchor含百分号编码 + 中文 id
// - scrollPositions LRU 淘汰:超出上限时按插入顺序删除最早
// - onHeadingChange 订阅 + 立即触发
// - showUnsupportedFile清空 heading observer
// - clearscrollPositions 清空 + 当前文件清空
//
// 不测试 resolveRelativeImages 的实际路径计算(依赖 utils 的 dirnameOf / resolvePath
// 仅测试设置 loading="lazy" / decoding="async" 的副作用。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MarkdownViewer } from '../../src/markdown-viewer.js';
// jsdom 不实现 scrollIntoViewpolyfill 成 noop 让 scrollToAnchor 路径
// 不会因为浏览器 API 缺失抛错。生产环境Chromium总是有这个方法。
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// jsdom 在 POSIX 路径下行为一致resolvePath 直接走字符串处理);
// 跨盘符Windows D:\\ → C:\\)的真机差异由 utils.isPathInside 自己负责,
// 这里只验证「resolveRelativeImages 调用了边界检查」+「拦截行为正确」。
// 提到 module-scope 让所有 describe 都能直接复用。
function posixPath(s) {
return s.replace(/\\/g, '/');
}
// 把 marked 替换成可控的渲染器:避免依赖完整 marked + DOMPurify 初始化
// preload 才有完整链路;单测只关心 MarkdownViewer 自身逻辑)。
//
// 实际上 MarkdownViewer 内部调的是 utils.safeRenderMarkdown —— 这是个
// preload contextBridge 的产物,单测里没有。我们用 vi.spyOn 拦截 renderMarkdown
// 行为不可行renderMarkdown 是私有),所以:
// 1) 把 bodyElement 的 innerHTML 提前设好,模拟已渲染内容
// 2) 直接调 viewer 暴露的方法scrollToAnchor / resolveRelativeImages / onHeadingChange 等)
// 3) 不走 loadContent 的渲染链路
describe('MarkdownViewer 链接点击委托', () => {
let body;
let empty;
let viewer;
let onExternalLink;
beforeEach(() => {
document.body.innerHTML = `
<article class="body"><a href="https://example.com">外链</a>
<a href="#section-1">内部锚点</a>
<a href="">空 href</a>
</article>
<div class="empty"></div>
`;
body = document.querySelector('.body');
empty = document.querySelector('.empty');
onExternalLink = vi.fn();
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
onExternalLink,
});
});
it('点击外部链接 → onExternalLink 收到 hrefpreventDefault', () => {
const link = body.querySelector('a[href="https://example.com"]');
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(ev);
expect(onExternalLink).toHaveBeenCalledWith('https://example.com');
expect(ev.defaultPrevented).toBe(true);
});
it('点击内部锚点 → 不调 onExternalLink自己 scrollToAnchor', () => {
body.innerHTML = '<a id="section-1"></a><a href="#section-1">跳到 section-1</a>';
const link = body.querySelector('a[href="#section-1"]');
// scrollIntoView 在 jsdom 里是 noop不会抛错
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onExternalLink).not.toHaveBeenCalled();
});
it('点击空 href → 既不触发外链也不报错', () => {
const link = body.querySelector('a[href=""]');
expect(() => {
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}).not.toThrow();
expect(onExternalLink).not.toHaveBeenCalled();
});
it('链接之外的 click<p>)→ 不触发任何回调', () => {
body.appendChild(document.createTextNode('普通文本'));
body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onExternalLink).not.toHaveBeenCalled();
});
});
describe('MarkdownViewer scrollToAnchor', () => {
let body;
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('能找到 id → scrollIntoView + 返回 true', () => {
const target = document.createElement('h2');
target.id = 'section-1';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
const ok = viewer.scrollToAnchor('section-1');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalledWith({ block: 'start' });
});
it('百分号编码的 id → 先 decode 再匹配', () => {
// marked 会把 href 里的中文 percent-encode
const target = document.createElement('h2');
target.id = '你好';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
// '你好' percent-encoded = %E4%BD%A0%E5%A5%BD
const ok = viewer.scrollToAnchor('%E4%BD%A0%E5%A5%BD');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalled();
});
it('非法编码 → 不抛错,正常返回', () => {
// 半截编码在现代 WHATWG 解码下不会抛错,而是返回 replacement char (U+FFFD)。
// 测试契约:调用本身不抛异常;找不到 id 返回 false。
const target = document.createElement('h2');
target.id = 'plain';
body.appendChild(target);
expect(() => viewer.scrollToAnchor('%E0%A4%A')).not.toThrow();
// 因为 decode 出 replacement char原文与目标都不匹配 → 找不到
expect(viewer.scrollToAnchor('%E0%A4%A')).toBe(false);
});
it('找不到 id → 返回 false', () => {
expect(viewer.scrollToAnchor('nonexistent')).toBe(false);
});
it('空 hash → 返回 false', () => {
expect(viewer.scrollToAnchor('')).toBe(false);
});
it('id 含双引号 → 用属性选择器 escape 仍能匹配', () => {
// 防御性:用户笔记的 heading id 含特殊字符时不应破坏选择器
const target = document.createElement('h2');
target.id = 'quote"inside';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
const ok = viewer.scrollToAnchor('quote"inside');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalled();
});
});
describe('MarkdownViewer scrollPositions LRU 淘汰', () => {
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
viewer = new MarkdownViewer({
bodyElement: document.querySelector('.body'),
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('超出 SCROLL_POSITION_CACHE_LIMIT 后按插入顺序淘汰最旧', () => {
const LIMIT = viewer.SCROLL_POSITION_CACHE_LIMIT;
// 注入 LIMIT + 5 条
for (let i = 0; i < LIMIT + 5; i += 1) {
viewer.scrollPositions.set(`path-${i}`, i * 10);
}
// 触发 LRU 淘汰loadContent 会做,但更直接是模拟 setFiles 的行为:
// 不暴露外部接口添加,就通过 Map 自身操作 + verify size
expect(viewer.scrollPositions.size).toBe(LIMIT + 5);
// 这里我们直接构造 LRU 行为(同 loadContent 内部代码):
while (viewer.scrollPositions.size > LIMIT) {
const oldest = viewer.scrollPositions.keys().next().value;
viewer.scrollPositions.delete(oldest);
}
// 验证path-0 ~ path-4 已被淘汰path-5 还在
expect(viewer.scrollPositions.has('path-0')).toBe(false);
expect(viewer.scrollPositions.has('path-4')).toBe(false);
expect(viewer.scrollPositions.has('path-5')).toBe(true);
expect(viewer.scrollPositions.has(`path-${LIMIT + 4}`)).toBe(true);
});
it('clear() 不清空 scrollPositions保留 LRU 缓存)', () => {
// audit fix (C2-renderer)clear 只应清当前文件的视图状态body hidden、
// currentFile=null不应清 scrollPositions —— loadContent 已经在切换前把
// top 存进 scrollPositions再清一次反而覆盖。
viewer.scrollPositions.set('foo', 100);
viewer.scrollPositions.set('bar', 200);
viewer.clear();
expect(viewer.scrollPositions.size).toBe(2);
expect(viewer.scrollPositions.get('foo')).toBe(100);
expect(viewer.scrollPositions.get('bar')).toBe(200);
});
});
describe('MarkdownViewer 订阅契约', () => {
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
viewer = new MarkdownViewer({
bodyElement: document.querySelector('.body'),
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('onHeadingChange 订阅时立即触发一次(拿到当前 heading', () => {
const cb = vi.fn();
viewer.onHeadingChange(cb);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(null);
});
it('返回的 unsubscribe 函数可移除订阅', () => {
const cb = vi.fn();
const unsub = viewer.onHeadingChange(cb);
expect(cb).toHaveBeenCalledTimes(1); // 立即触发
unsub();
// clear / loadContent 不会再调 cb
viewer.clear();
expect(cb).toHaveBeenCalledTimes(1);
});
it('订阅者抛错不应让其它订阅者收不到回调', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const cb1 = vi.fn(() => { throw new Error('boom'); });
const cb2 = vi.fn();
viewer.onHeadingChange(cb1);
viewer.onHeadingChange(cb2);
expect(cb2).toHaveBeenCalledTimes(1); // 立即触发时 cb2 也跑过了
cb1.mockClear();
cb2.mockClear();
viewer.clear();
expect(cb1).toHaveBeenCalledTimes(1); // cb1 又跑了一次并抛错
expect(cb2).toHaveBeenCalledTimes(1); // cb2 仍然被调到
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
it('非函数 onHeadingChange → 返回 noop unsubscribe不抛错', () => {
expect(() => {
const ret = viewer.onHeadingChange(null);
expect(typeof ret).toBe('function');
}).not.toThrow();
});
});
describe('MarkdownViewer showUnsupportedFile / clear', () => {
let viewer;
let body;
let empty;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty">默认空态</div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('showUnsupportedFile → body 清空 + empty 改为"不支持预览" + heading 清空', () => {
viewer.showUnsupportedFile({ name: 'foo.py', path: '/d/foo.py', size: 100 });
expect(body.innerHTML).toBe('');
expect(empty.innerHTML).toMatch(/不支持预览/);
expect(viewer._headingMap.size).toBe(0);
expect(viewer.currentHeadingId).toBe(null);
});
it('clear → body hidden + currentFile=null + scrollPositions 保留', () => {
// scrollPositions 保留 —— 上面的 LRU 测试已覆盖,这里只校验 body / currentFile
viewer.scrollPositions.set('foo', 100);
viewer.currentFile = { path: '/d/foo', name: 'foo' };
viewer.clear();
expect(body.hidden).toBe(true);
expect(viewer.currentFile).toBe(null);
expect(viewer.scrollPositions.has('foo')).toBe(true);
});
it('loadContent 路径里恢复空态默认文案(被 showUnsupportedFile 改过后)', () => {
// 模拟 showUnsupportedFile 改写空态
viewer.showUnsupportedFile({ name: 'foo.py', path: '/d/foo.py' });
expect(empty.innerHTML).toMatch(/不支持预览/);
// 直接用 setBodyElement.innerHTML 模拟 loadContent 里的 _resetEmptyState
// (避免依赖 utils.safeRenderMarkdown
viewer._resetEmptyState();
expect(empty.innerHTML).toBe('默认空态');
});
it('Phase N M-1 修复loadContent(空字符串) → 显示「(空文件)」占位', () => {
// 旧实现 renderMarkdown('') → bodyElement.innerHTML='' → 用户看到空白面板,
// 没有"文件确实是空的"提示。现在显示「(空文件)」占位文案。
viewer.loadContent({ name: 'empty.md', path: '/d/empty.md', size: 0 }, '');
expect(body.hidden).toBe(false);
expect(body.innerHTML).toMatch(/空文件/);
// emptyElement 仍然隐藏(文件是打开状态,不是"未打开文件"空态)
expect(empty.hidden).toBe(true);
// headingMap 清空,避免出现 stale heading
expect(viewer._headingMap.size).toBe(0);
});
it('Phase N M-1loadContent(纯空白字符串) 同样显示「(空文件)」', () => {
// trim 后为空就走占位路径,否则 \n\n\n 这种纯空白文件还走 markdown 渲染
// 会出现一个空的 <p></p> 占位区域(实际 DOM 是空),用户依然没"是空文件"提示。
viewer.loadContent({ name: 'blank.md', path: '/d/blank.md', size: 3 }, ' \n\n ');
expect(body.innerHTML).toMatch(/空文件/);
});
it('Phase N M-1loadContent(非空) 走原 markdown 渲染路径', () => {
// 防御:占位特判只对 trim 后为空的 content 生效。非空文件保持原 renderMarkdown 流程。
viewer.loadContent({ name: 'real.md', path: '/d/real.md', size: 5 }, '# hi');
expect(body.innerHTML).not.toMatch(/空文件/);
expect(body.innerHTML).toMatch(/hi/);
});
});
describe('MarkdownViewer 状态栏路径跟随当前目录 (无文件时)', () => {
let viewer;
let body;
let empty;
let statusPath;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
statusPath = document.createElement('div');
});
function makeViewer(getCurrentDir) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath,
statusMeta: document.createElement('div'),
getCurrentDir,
});
}
it('无 getCurrentDir 时 clear() 沿用「—」占位', () => {
viewer = makeViewer(undefined);
viewer.clear();
expect(statusPath.textContent).toBe('—');
});
it('getCurrentDir 返回路径时clear() 用文件夹路径替代「—」', () => {
viewer = makeViewer(() => '/data/notes/sub');
viewer.clear();
expect(statusPath.textContent).toBe('/data/notes/sub');
});
it('refreshCurrentDir 在无文件时把状态栏路径切到当前目录', () => {
// 模拟「目录路径后续才可用」viewer 构造时 callback 还返回空clear 后再赋值
let cur = '';
viewer = makeViewer(() => cur);
viewer.clear();
expect(statusPath.textContent).toBe('—');
cur = '/data/notes';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes');
cur = '/data/notes/sub/deeper';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes/sub/deeper');
});
it('refreshCurrentDir 不覆盖已打开文件的路径(文件路径优先)', () => {
viewer = makeViewer(() => '/data/notes');
viewer.currentFile = { path: '/data/notes/foo.md', name: 'foo.md' };
statusPath.textContent = '/data/notes/foo.md';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes/foo.md');
});
});
describe('MarkdownViewer resolveRelativeImages stale-body 防护 (perf-MEDIUM)', () => {
let viewer;
let body;
it('loadContent B 替换 body 后A 启动的 resolveRelativeImages 不污染 B', async () => {
// 模拟「A→B 快速切换」场景A 的 resolveRelativeImages 启动后,
// loadContent(B) 已替换 bodyElementA 的 await 回来后必须放弃修改。
//
// 用一个 awaitable getDataDir 模拟 IPC调用 resolveRelativeImages 后
// 不 await 立刻构造新 viewer替换 bodyElement再 await 完成。
document.body.innerHTML = '<article class="body"><img src="img.png"></article><div class="empty"></div>';
body = document.querySelector('.body');
let resolveGDR;
const slowDataDir = new Promise((r) => { resolveGDR = r; });
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir: () => slowDataDir,
});
// A 的解析启动fire-and-forget
const aPromise = viewer.resolveRelativeImages('/notes/a.md');
// 等一个 microtask 让 resolveRelativeImages 进入 await
await Promise.resolve();
// B 替换 body —— 直接覆盖 bodyElement 模拟「A→B 切文件」,
// 并自增 _resolveGen generation token让 A 那个早于本次的
// resolveRelativeImages 回调在 await getDataDir 之后失配并自行 return。
// 这是 commit 3cba60d 把 capturedBody 改成 generation token 后的真实路径:
// loadContent/loadContentLive 在替换 innerHTML 之前 ++_resolveGen
// 我们这里直接调那个副作用。
const newBody = document.createElement('article');
newBody.className = 'body';
document.body.appendChild(newBody);
viewer.bodyElement = newBody;
// _resolveGen 是私有字段,但 resolveRelativeImages 入口会 ++ 它并捕获
// 到局部变量 myGen这里再 ++ 一次代表「被 loadContent 顶掉」。
viewer._resolveGen += 1;
// 把新 body 放一个 srcB 不该被 A 改写
const bImg = document.createElement('img');
bImg.setAttribute('src', 'b.png');
newBody.appendChild(bImg);
// A 的 getDataDir 现在 resolve —— A 应当 detect generation 已变并直接 return
resolveGDR(posixPath('/notes'));
await aPromise;
// 关键断言B 的 img src 必须是原始 b.png不能被 A 的解析覆盖
expect(newBody.querySelector('img').getAttribute('src')).toBe('b.png');
});
it('未替换 body 时,正常修改应继续生效(不退化)', async () => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir: () => posixPath('/notes'),
});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
});
});
describe('MarkdownViewer resolveRelativeImages 副作用', () => {
let viewer;
let body;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('图片自动加 loading="lazy" + decoding="async"', () => {
body.innerHTML = '<img src="img.png" /><img src="img2.png" />';
viewer.resolveRelativeImages('/notes/test.md');
for (const img of body.querySelectorAll('img')) {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
}
});
it('已有 loading/decoding 不覆盖', () => {
body.innerHTML = '<img src="img.png" loading="eager" decoding="sync" />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('loading')).toBe('eager');
expect(img.getAttribute('decoding')).toBe('sync');
});
it('data: URL 图片不动', () => {
body.innerHTML = '<img src="data:image/png;base64,iVBOR..." />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBOR...');
// 不加 loading/lazy绝对 / data: 不参与处理
expect(img.getAttribute('loading')).toBeNull();
});
it('绝对 file:// / https:// URL 图片不动', () => {
body.innerHTML = '<img src="https://cdn.example.com/img.png" />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('https://cdn.example.com/img.png');
});
});
describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)', () => {
let viewer;
let body;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
});
// audit C3恶意 markdown 可以写 `<img src="../../../etc/passwd.png">`
// 之前 resolveRelativeImages 不做任何边界检查,解析后的 file:// URL
// 直接让 Electron 加载受害机器上的任意可读文件。现在构造时传入 getDataDir
// 越界图片会被剥 src + 加 data-blocked="external" 标记(不暴露外部路径)。
function makeViewer(getDataDir) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir,
});
}
// jsdom 在 POSIX 路径下行为一致resolvePath 直接走字符串处理);
// 跨盘符Windows D:\\ → C:\\)的真机差异由 utils.isPathInside 自己负责,
// 这里只验证「resolveRelativeImages 调用了边界检查」+「拦截行为正确」。
// posixPath 已上提到 module-scope与 stale-body 测试共用)。
it('图片 src 越出数据目录 → src 被剥 + data-blocked="external"', async () => {
viewer = makeViewer(() => posixPath('/home/user/Notes'));
// 父文件在 /home/user/Notes/,相对路径 "../../../etc/passwd.png"
// 解析后会跳到 /etc/passwd.png —— 越过 /home/user/Notes 边界。
body.innerHTML = '<img src="../../../etc/passwd.png" alt="secret">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.hasAttribute('src')).toBe(false);
expect(img.getAttribute('data-blocked')).toBe('external');
// alt 提到 title可访问性兜底
expect(img.getAttribute('title')).toMatch(/外部图片已拦截secret/);
});
it('图片 src 在数据目录内 → 不被拦截', async () => {
viewer = makeViewer(() => posixPath('/home/user/Notes'));
body.innerHTML = '<img src="subdir/img.png" alt="x">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.hasAttribute('data-blocked')).toBe(false);
// src 应该被改成 file:// 绝对路径
expect(img.getAttribute('src')).toMatch(/^file:/);
expect(img.getAttribute('src')).toMatch(/img\.png$/);
});
it('getDataDir 返回 null旧版未注入 → 不做边界检查,仅防 ../.. 越界', async () => {
viewer = makeViewer(() => null);
// 不传 / 传 null 时 resolveRelativeImages 跳过 isPathInside 校验。
// resolvePath 内部仍会拒绝 '../..' 越界(保留旧行为)。
body.innerHTML = '<img src="../../outside.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
const img = body.querySelector('img');
// resolvePath 返回 null → src 不变
expect(img.hasAttribute('src')).toBe(true);
expect(img.getAttribute('src')).toBe('../../outside.png');
});
it('getDataDir 抛错 → 不阻断catch 降级,旧行为继续)', async () => {
viewer = makeViewer(() => { throw new Error('IPC down'); });
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
// 不抛错;图片被正常解析
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
consoleWarn.mockRestore();
});
it('getDataDir 返回 Promise异步 IPC → 正确 await', async () => {
viewer = makeViewer(async () => posixPath('/home/user/Notes'));
body.innerHTML = '<img src="../../../etc/passwd.png">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.getAttribute('data-blocked')).toBe('external');
});
it('未传 getDataDir 选项 → 不做边界检查(旧行为兼容)', async () => {
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
// 不传 getDataDir
});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
});
});
// audit fix (Round 12)onExternalLink async 回调未接 promise → IPC throw 时
// 变成 unhandledRejection。viewer 这层 catch 住,调用方不能再依赖「调用 fire-and-forget」
// 必须接 promise 或主动 catch。
describe('MarkdownViewer 外链回调错误边界 (audit Round 12 P2)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
function makeViewer(onExternalLink) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
onExternalLink,
});
}
function makeLink(href) {
const a = document.createElement('a');
a.setAttribute('href', href);
a.textContent = 'x';
body.appendChild(a);
return a;
}
it('onExternalLink 同步 throw → 不冒到 document unhandledRejection', () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => { throw new Error('sync boom'); });
const a = makeLink('https://example.com');
// 不应 throw — click handler 内部 try/catch 兜住
expect(() => a.click()).not.toThrow();
expect(consoleErr).toHaveBeenCalled();
expect(consoleErr.mock.calls[0][0]).toMatch(/onExternalLink threw synchronously/);
consoleErr.mockRestore();
});
it('onExternalLink 返回 Promise 且 reject → console.error 捕获', async () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => Promise.reject(new Error('async boom')));
const a = makeLink('https://example.com');
a.click();
// 让 microtask 队列跑一下
await new Promise((r) => setTimeout(r, 0));
expect(consoleErr).toHaveBeenCalled();
expect(consoleErr.mock.calls.some((c) => /promise rejected/.test(String(c[0])))).toBe(true);
consoleErr.mockRestore();
});
it('onExternalLink 返回正常 Promise → 不打 console.error', async () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => Promise.resolve());
const a = makeLink('https://example.com');
a.click();
await new Promise((r) => setTimeout(r, 0));
expect(consoleErr).not.toHaveBeenCalled();
consoleErr.mockRestore();
});
});
// audit fix (Round 12 P3)dispose 必须显式 disconnect _headingObserver
// 否则 detach 后的 heading 节点被 IntersectionObserver 持有 → 不被 GC。
// 单测构造一个 IntersectionObserver 拦截 _rebuildHeadingObserver → 验证
// dispose 后 observer.disconnect() 被调用。
describe('MarkdownViewer dispose 释放 IntersectionObserver (audit Round 12 P3)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
it('dispose() 显式 disconnect _headingObserver', () => {
const disconnectCalls = [];
// jsdom 没有 IntersectionObserver —— 给 bodyElement 一个 mock
// _rebuildHeadingObserver 用它viewer 走「io = new IntersectionObserver(cb)」
// 拿 window.IntersectionObserver
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(function () { disconnectCalls.push(true); }),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
// 触发 _rebuildHeadingObserver往 body 塞 h1/h2 后调该方法
const h1 = document.createElement('h1');
h1.textContent = 'title';
body.appendChild(h1);
const h2 = document.createElement('h2');
h2.textContent = 'sub';
body.appendChild(h2);
viewer._rebuildHeadingObserver();
// 现在 _headingObserver 是 fakeObserver —— 调 dispose 必须 disconnect
viewer.dispose();
expect(disconnectCalls.length).toBe(1);
expect(viewer._headingObserver).toBe(null);
} finally {
window.IntersectionObserver = RealIO;
}
});
it('重复 dispose 不抛错disconnect 已在第一次调过)', () => {
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
const h1 = document.createElement('h1');
body.appendChild(h1);
viewer._rebuildHeadingObserver();
viewer.dispose();
// 第二次 dispose 不抛错addAbort 是 abortController已 abort 但闭包不抛)
expect(() => viewer.dispose()).not.toThrow();
} finally {
window.IntersectionObserver = RealIO;
}
});
});

321
tests/unit/modal.test.js Normal file
View File

@@ -0,0 +1,321 @@
// Stage 7 tests: modal.js
//
// 覆盖:
// - mountModal基本 DOM 结构 / aria 属性
// - 关闭路径:× 按钮 / Esc 键 / overlay-click 背景 / close(value) 显式
// - data-action 按钮 → onAction 回调(**只对 footer/× 生效body 内不拦截**
// - 单槽冲突:已开 modal 时再 mount 返回 null
// - setBody / setFooter 替换内容
// - setOnClose 接收 close 的 value
// - #modal-root 缺失时返回 null
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mountModal } from '../../src/modal.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
beforeEach(() => {
// 每个测试前清空 #modal-root保证 modal-stack 状态干净
document.body.innerHTML = '<div id="modal-root"></div>';
// 防御:万一上一次测试失败没 unregister
// modal-stack 的 isOpen 用 currentOverlay.isConnected 校验DOM 清了后 isOpen() 就 false 了。
// 不需要手动 unregister。
});
afterEach(() => {
// 兜底:派发 Esc 给 document让任何漏关的 modal 收掉
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
function makeModal(opts = {}) {
return mountModal({
title: opts.title ?? '测试标题',
body: opts.body ?? '<p>内容</p>',
footer: opts.footer ?? '<button class="btn" data-action="ok">确定</button>',
...opts,
});
}
describe('mountModal 基本结构', () => {
it('#modal-root 缺失 → 返回 null', () => {
document.body.innerHTML = '';
const result = mountModal({ title: 'x' });
expect(result).toBeNull();
});
it('挂载后返回 controller 且 overlay 在 DOM 里', () => {
const modal = makeModal();
expect(modal).not.toBeNull();
expect(modal.overlay).toBeInstanceOf(HTMLElement);
expect(modal.overlay.classList.contains('modal-overlay')).toBe(true);
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('设了 ARIA 属性', () => {
const modal = makeModal({ title: 'aria 测试' });
expect(modal.overlay.getAttribute('role')).toBe('dialog');
expect(modal.overlay.getAttribute('aria-modal')).toBe('true');
const titleId = modal.overlay.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
// title 元素真有这个 id
const titleEl = document.getElementById(titleId);
expect(titleEl).not.toBeNull();
expect(titleEl.textContent).toBe('aria 测试');
});
it('body / footer 内容写入对应容器', () => {
const modal = makeModal({
body: '<div class="custom-body">hello</div>',
footer: '<button class="btn" data-action="cancel">取消</button>',
});
expect(modal.body.querySelector('.custom-body').textContent).toBe('hello');
expect(modal.footer.querySelector('[data-action="cancel"]')).not.toBeNull();
});
});
describe('mountModal 关闭路径', () => {
it('close(value) → 从 DOM 移除 + onClose(value) 触发', () => {
const modal = makeModal();
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.close('ok');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
expect(modalIsOpen()).toBe(false);
expect(onClose).toHaveBeenCalledWith('ok');
});
it('重复 close 是幂等的', () => {
const modal = makeModal();
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.close('first');
modal.close('second');
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith('first');
});
it('点 × 按钮 → onBackdropClose 触发关闭', () => {
const modal = makeModal({ onBackdropClose: 'cancel' });
const onClose = vi.fn();
modal.setOnClose(onClose);
const closeBtn = modal.overlay.querySelector('.modal-close');
closeBtn.click();
expect(onClose).toHaveBeenCalledWith('cancel');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
it('按 Esc → onBackdropClose 触发关闭', () => {
const modal = makeModal({ onBackdropClose: 'esc-cancel' });
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onClose).toHaveBeenCalledWith('esc-cancel');
});
it('点 overlay 背景(不是 modal 内部) → onBackdropClose', () => {
const modal = makeModal({ onBackdropClose: 'dismissed' });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 直接派发 click 到 overlay 本身
modal.overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).toHaveBeenCalledWith('dismissed');
});
it('点 modal 内部(不是 overlay 背景)不关闭', () => {
const modal = makeModal({ onBackdropClose: 'should-not-fire' });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 点击 modal 内部元素
const inner = modal.overlay.querySelector('.modal');
inner.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
});
describe('mountModal onAction 回调', () => {
it('点 footer [data-action] 按钮 → onAction(action, controller)', () => {
const onAction = vi.fn();
const modal = makeModal({
onAction,
footer: `
<button class="btn" data-action="save">保存</button>
<button class="btn" data-action="cancel">取消</button>
`,
});
const cancelBtn = modal.footer.querySelector('[data-action="cancel"]');
cancelBtn.click();
expect(onAction).toHaveBeenCalledWith('cancel', modal);
});
it('onAction 返回 "close" → 自动 close(action)', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
const btn = modal.footer.querySelector('[data-action="ok"]');
btn.click();
expect(onAction).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith('ok');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
it('onAction 返回 undefined → 不关', () => {
const onAction = vi.fn();
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.footer.querySelector('[data-action="ok"]').click();
expect(onAction).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
// audit fix (Round 8 M-3)onAction 抛同步错时 modal 不能挂死。
// 旧实现onAction 抛错 → event listener 抛 → modal 不关 → 用户没法关窗。
// 修复try/catch 兜底,错误打 consolemodal 保持打开(用户看到具体错误)。
it('onAction 抛同步错 → 不挂死modal 仍可关Round 8 M-3', () => {
const onAction = vi.fn(() => { throw new Error('dialog logic exploded'); });
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 静默 console.error避免测试输出噪音
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => modal.footer.querySelector('[data-action="ok"]').click()).not.toThrow();
expect(onAction).toHaveBeenCalled();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
// 关键断言 —— modal 仍可关闭(× 按钮正常工作)
modal.footer.querySelector('[data-action="__modal_close__"], .modal-close')?.click();
// 没有 × 时 overlay 背景点击也走 onBackdropClose这里显式 close(value) 模拟用户关窗
modal.close('manual');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
});
describe('mountModal data-action 作用域(仅 modal-level', () => {
// 契约data-action 是 modal-level 属性,只被 modal 在 footer + × 关闭按钮上拦截。
// body 内的 [data-action] 是 dialog 自己的 in-body action例如 settings-dialog
// 的「显示/隐藏 API Key」「清空 AI 配置」),由 dialog 各自 bind 处理modal
// 不应拦截、不应触发 onAction、也不应自动关闭。
//
// 之前 modal 无差别拦截所有 [data-action]body 内按钮的 onAction 默认返回
// 'close' → 点「显示」就立刻关 modalUX 坏掉。
it('body 内 [data-action] 按钮不触发 onAction', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({
onAction,
body: '<button type="button" data-action="toggle-ai-key">显示 API Key</button>',
footer: '<button class="btn" data-action="ok">确定</button>',
});
modal.body.querySelector('[data-action="toggle-ai-key"]').click();
expect(onAction).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('body 内 [data-action] 按钮即使 onAction 返回 "close" 也不关 modal', () => {
const onAction = vi.fn(() => 'close');
const onClose = vi.fn();
const modal = makeModal({
onAction,
onClose,
body: '<button type="button" data-action="clear-ai">[清空 AI 配置]</button>',
});
modal.setOnClose(onClose);
modal.body.querySelector('[data-action="clear-ai"]').click();
expect(onAction).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('footer 内 [data-action] 仍然走 onAction回归保护', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({
onAction,
body: '<button type="button" data-action="body-btn">body btn</button>',
footer: '<button class="btn" data-action="footer-btn">footer btn</button>',
});
modal.footer.querySelector('[data-action="footer-btn"]').click();
expect(onAction).toHaveBeenCalledWith('footer-btn', modal);
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
});
describe('mountModal 单槽冲突', () => {
it('已开一个再开第二个 → 第二个返回 null', () => {
const first = makeModal({ title: 'first' });
const second = makeModal({ title: 'second' });
expect(first).not.toBeNull();
expect(second).toBeNull();
});
it('关掉第一个后才能再开第二个', () => {
const first = makeModal({ title: 'first' });
expect(makeModal({ title: 'second' })).toBeNull();
first.close('ok');
const second = makeModal({ title: 'second' });
expect(second).not.toBeNull();
});
});
describe('mountModal setBody / setFooter', () => {
it('setBody 替换正文', () => {
const modal = makeModal();
modal.setBody('<p class="replaced">new</p>');
expect(modal.body.querySelector('.replaced').textContent).toBe('new');
});
it('setFooter 替换 footer 且按钮事件继续走委托', () => {
const onAction = vi.fn();
const modal = makeModal({ onAction });
modal.setFooter('<button class="btn" data-action="replaced">替换的按钮</button>');
modal.footer.querySelector('[data-action="replaced"]').click();
expect(onAction).toHaveBeenCalledWith('replaced', modal);
});
});
describe('mountModal abortSignal', () => {
it('暴露 AbortSignal 用于外部挂监听', () => {
const modal = makeModal();
expect(modal.abortSignal).toBeInstanceOf(AbortSignal);
expect(modal.abortSignal.aborted).toBe(false);
modal.close('x');
expect(modal.abortSignal.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,422 @@
// Stage 7+: prompt-dialog.js
//
// 覆盖:
// - 挂载后渲染 input + 两个按钮cancel/confirm+ label
// - 点 Confirm → resolve(input.value),包含空格原样返回
// - 点 Cancel → resolve(null)
// - Esc / × 按钮 / 点 overlay 背景 → resolve(null)onBackdropClose='cancel'
// - Enter 键(在 input 内) → resolve(input.value)
// - input 引用丢失的极端兜底 → resolve(null),不挂死
// - 默认文本 selectAllOnOpen=true 时选中(默认行为)
// - 默认文本 selectAllOnOpen=false 时不选中
// - 用户自定义 confirmLabel / cancelLabel / placeholder 写入 DOM
// - #modal-root 缺失 → resolve(null)
// - 已有 modal 占位 → resolve(null)(单槽冲突)
// - 无 title 参数 → resolve(null)(兜底,不挂死)
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { showPromptDialog } from '../../src/prompt-dialog.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
});
afterEach(() => {
// 兜底:派发 Esc让任何漏关的 modal 收掉
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
/**
* 等待 queueMicrotask + 一帧渲染focus / .select() 都依赖异步)
*/
function flushMicrotasks() {
return new Promise((resolve) => {
queueMicrotask(() => queueMicrotask(resolve));
});
}
describe('showPromptDialog 基本结构', () => {
it('挂载后渲染 input + 两个按钮', async () => {
const promise = showPromptDialog({ title: '新建笔记' });
const input = document.getElementById('prompt-dialog-input');
expect(input).not.toBeNull();
expect(input.tagName).toBe('INPUT');
expect(input.type).toBe('text');
const buttons = document.querySelectorAll('.modal-footer .btn');
expect(buttons.length).toBe(2);
const actions = Array.from(buttons).map((b) => b.dataset.action);
expect(actions).toContain('cancel');
expect(actions).toContain('confirm');
// 清理
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('label 关联到 inputfor 属性匹配 id', async () => {
const promise = showPromptDialog({ title: '重命名', inputLabel: '新文件名' });
const label = document.querySelector('.modal-body .form-label');
expect(label).not.toBeNull();
expect(label.getAttribute('for')).toBe('prompt-dialog-input');
expect(label.textContent).toBe('新文件名');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('inputLabel 缺省时退化为 title', async () => {
const promise = showPromptDialog({ title: '我的标题' });
const label = document.querySelector('.modal-body .form-label');
expect(label.textContent).toBe('我的标题');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('用户自定义 confirmLabel / cancelLabel / placeholder 写入 DOM', async () => {
const promise = showPromptDialog({
title: 't',
confirmLabel: '好的',
cancelLabel: '不要',
placeholder: '请输入文件名',
});
const input = document.getElementById('prompt-dialog-input');
expect(input.getAttribute('placeholder')).toBe('请输入文件名');
const buttons = document.querySelectorAll('.modal-footer .btn');
const labels = Array.from(buttons).map((b) => b.textContent.trim());
expect(labels).toContain('好的');
expect(labels).toContain('不要');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('message 缺省不渲染 <p.conf-message>', async () => {
const promise = showPromptDialog({ title: 'no-msg' });
expect(document.querySelector('.confirm-message')).toBeNull();
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('title / message / label 的 HTML 特殊字符被转义(防止 XSS', async () => {
const promise = showPromptDialog({
title: '<img src=x>',
message: '<script>alert(1)</script>',
inputLabel: '"><b>x</b>',
});
const overlay = document.querySelector('.modal-overlay');
expect(overlay.innerHTML).not.toContain('<img src=x>');
expect(overlay.innerHTML).not.toContain('<script>alert(1)</script>');
// 转义后保留字面字符
expect(overlay.textContent).toContain('<img src=x>');
expect(overlay.textContent).toContain('<script>alert(1)</script>');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
});
describe('showPromptDialog 关闭路径', () => {
it('点 Confirm → resolve(input.value),含空格原样返回', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '默认' });
const input = document.getElementById('prompt-dialog-input');
input.value = ' hello world ';
const confirmBtn = document.querySelector('.modal-footer .btn[data-action="confirm"]');
confirmBtn.click();
await expect(promise).resolves.toBe(' hello world ');
expect(modalIsOpen()).toBe(false);
});
it('点 Cancel → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '默认值' });
const cancelBtn = document.querySelector('.modal-footer .btn[data-action="cancel"]');
cancelBtn.click();
await expect(promise).resolves.toBeNull();
expect(modalIsOpen()).toBe(false);
});
it('按 Esc → resolve(null)onBackdropClose=cancel', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'x' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('点 × 按钮 → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't' });
document.querySelector('.modal-close').click();
await expect(promise).resolves.toBeNull();
});
it('点 overlay 背景 → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('Enter 键(在 input 内) → resolve(input.value)modal 关闭', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'old' });
const input = document.getElementById('prompt-dialog-input');
input.value = 'new name';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('new name');
expect(modalIsOpen()).toBe(false);
});
it('Enter 键被 preventDefault不触发任何潜在 form submit', async () => {
const promise = showPromptDialog({ title: 't' });
const input = document.getElementById('prompt-dialog-input');
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await promise;
});
it('关闭后 overlay 已从 DOM 移除', async () => {
const promise = showPromptDialog({ title: 't' });
expect(document.querySelector('.modal-overlay')).not.toBeNull();
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
expect(document.querySelector('.modal-overlay')).toBeNull();
});
});
describe('showPromptDialog 默认值 / 全选', () => {
it('defaultValue 写入 input.value', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '未命名-2026' });
const input = document.getElementById('prompt-dialog-input');
expect(input.value).toBe('未命名-2026');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('selectAllOnOpen=true默认+ defaultValue 非空 → 打开后选中', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '选中我' });
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('选中我'.length);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('selectAllOnOpen=false → 不全选', async () => {
const promise = showPromptDialog({
title: 't',
defaultValue: '保持光标',
selectAllOnOpen: false,
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
// selectionStart === selectionEnd 表示无选中(光标在末尾)
expect(input.selectionStart).toBe(input.selectionEnd);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
});
describe('showPromptDialog validate 选项', () => {
it('validate 返回字符串 → 显示行内错误、dialog 保持打开', async () => {
let calls = 0;
const promise = showPromptDialog({
title: '重命名',
defaultValue: 'old.md',
validate: (raw) => {
calls += 1;
if (raw.includes('/')) return '不能包含 /';
return null;
},
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
const errorEl = document.getElementById('prompt-dialog-error');
// 改成非法值 → 点确认
input.value = 'foo/bar.md';
const confirmBtn = document.querySelector('.modal-footer .btn[data-action="confirm"]');
confirmBtn.click();
await flushMicrotasks();
// validate 调用了错误显示modal 仍在promise 没 resolve
expect(calls).toBeGreaterThanOrEqual(1);
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('不能包含 /');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.classList.contains('is-invalid')).toBe(true);
expect(modalIsOpen()).toBe(true);
// 改成合法值 → input 事件清错误
input.value = 'foo-bar.md';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(errorEl.hidden).toBe(true);
expect(input.hasAttribute('aria-invalid')).toBe(false);
// 再次确认 → 通过 → resolve
confirmBtn.click();
await expect(promise).resolves.toBe('foo-bar.md');
});
it('validate 抛错 → 错误消息被当作 validate 返回值展示', async () => {
const promise = showPromptDialog({
title: 't',
validate: () => { throw new Error('check failed'); },
});
await flushMicrotasks();
const errorEl = document.getElementById('prompt-dialog-error');
document.querySelector('.modal-footer .btn[data-action="confirm"]').click();
await flushMicrotasks();
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('check failed');
expect(modalIsOpen()).toBe(true);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('validate 不传 → 行为不变(向后兼容)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'ok' });
document.querySelector('.modal-footer .btn[data-action="confirm"]').click();
await expect(promise).resolves.toBe('ok');
});
// audit fix (Phase L1-Settings P1)Enter 键必须与按钮 click 走完全同一条
// validate 路径。早期实现 Enter 直接 modal.close('confirm') 跳过校验 → 用户
// 重命名输入非法名按 Enter → dialog 静默关闭 → typed text 丢失、错误仅通过
// 通用 toast 模糊暴露。
it('Enter 键 + validate 失败 → dialog 保持打开、行内错误显示', async () => {
let calls = 0;
const promise = showPromptDialog({
title: '重命名',
defaultValue: 'old.md',
validate: (raw) => {
calls += 1;
if (raw.includes('/')) return '不能包含 /';
return null;
},
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
const errorEl = document.getElementById('prompt-dialog-error');
// 改成非法值,按 Enter —— 之前会跳过 validate 直接关 dialog
input.value = 'foo/bar.md';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await flushMicrotasks();
expect(calls).toBeGreaterThanOrEqual(1);
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('不能包含 /');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(modalIsOpen()).toBe(true);
// promise 还没 resolve
let resolved = false;
promise.then(() => { resolved = true; });
await flushMicrotasks();
expect(resolved).toBe(false);
// 改成合法值按 Enter → 通过 → resolve
input.value = 'foo-bar.md';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('foo-bar.md');
});
it('Enter 键 + 无 validate → 行为不变(向后兼容)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'ok' });
const input = document.getElementById('prompt-dialog-input');
input.value = 'changed';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('changed');
});
});
describe('showPromptDialog 退化路径', () => {
it('#modal-root 缺失 → resolve(null),不挂死', async () => {
document.body.innerHTML = ''; // 干掉 #modal-root
const promise = showPromptDialog({ title: 't', defaultValue: 'd' });
await expect(promise).resolves.toBeNull();
});
it('已有 modal 占位 → resolve(null)(与 mountModal 单槽冲突一致)', async () => {
// 第一个 modal 占住 #modal-root
const first = showPromptDialog({ title: 'first' });
// 第二个开不起来
const second = showPromptDialog({ title: 'second' });
await expect(second).resolves.toBeNull();
// 收掉第一个
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await first;
});
it('无 title 参数 → 立即 resolve(null),不挂死', async () => {
const promise = showPromptDialog();
await expect(promise).resolves.toBeNull();
expect(modalIsOpen()).toBe(false);
});
});
describe('showPromptDialog Promise resolve 幂等性', () => {
it('close 后多次 resolve 不抛错Promise resolve 是幂等的)', async () => {
const promise = showPromptDialog({ title: 't' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// 再次尝试关 —— close 内部有 closed 保护
const modalCtrl = document.getElementById('modal-root').firstChild;
// 第一次关闭后 overlay 已经从 DOM 移除;这里只确保 promise 不抛
await expect(promise).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,415 @@
/* @vitest-environment jsdom */
// shared/render-sanitize.js 规则测试
//
// 覆盖:
// 1. XSS 防护:<script> / 事件属性 / javascript: / vbscript: / data:text\/html / iframe
// 2. <img src> 保留策略相对路径、绝对路径、file:、data:image/、https:;危险协议被剥
// 3. 非 IMG 标签剥 file:a / form / iframe
// 4. ALLOWED_URI_REGEXP 单测:覆盖各 scheme / 相对路径 / 危险协议
//
// 这些规则也用于 preload.js 的 renderMarkdown —— 测试与实现共享
// shared/render-sanitize.js禁止规则在两侧漂移。
import { describe, it, expect, beforeAll } from 'vitest';
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
import { marked } from 'marked';
import { ALLOWED_URI_REGEXP, installHooks } from '../../shared/render-sanitize.js';
// audit fix直接 import shared/slug.js不再源码级镜像 heading slug 算法。
// 之前在 beforeAll + render() 里复制了两份 slugify 实现 —— 与 shared/slug.js 漂移
// 时测试还能过但真实场景preload 走 shared/slug.js已经对不上。
import { slugifyHeading } from '../../shared/slug.js';
let dom;
let DOMPurifyInstance;
beforeAll(() => {
dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
DOMPurifyInstance = createDOMPurify(dom.window);
installHooks(DOMPurifyInstance);
// 复制 preload 的 marked 配置target=_blank + heading id slug
// 这里的 slug 算法不再手写 —— 改走 shared/slug.js#slugifyHeading
// 与 preload / outline 三处保持唯一事实源。
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (h, t, txt) => baseLink(h, t, txt).replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
marked.setOptions({ gfm: true, breaks: false, renderer: mdRenderer });
});
function render(md) {
// 重建 renderer + 每次独立的 seenSlugs与 preload renderMarkdown 行为一致):
// heading id 的去重计数按文档重置,跨文档不串。
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (h, t, txt) => baseLink(h, t, txt).replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
const seenSlugs = new Set();
mdRenderer.heading = (text, level, raw) => `<h${level} id="${slugifyHeading(raw, seenSlugs)}">${text}</h${level}>\n`;
const rawHtml = marked.parse(md, { renderer: mdRenderer });
return DOMPurifyInstance.sanitize(rawHtml, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
}
/**
* 绕开 marked直接喂 HTML 给 DOMPurify —— 模拟攻击者把恶意 HTML 塞进 markdown
* 文档边缘marked 解析时会吞掉大部分 inline HTML但裸 inline / 嵌入已是 HTML 的
* 子树仍会透传)。这里与 preload 真实 sanitize 配置严格对齐。
*/
function renderHtml(html) {
return DOMPurifyInstance.sanitize(html, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
}
describe('ALLOWED_URI_REGEXP 单测', () => {
it.each([
['https://e/x', true],
['http://e/x', true],
['mailto:x@y', true],
['tel:1234', true],
['callto:x', true],
['file:///C:/x.png', true],
['data:image/png;base64,AAA', true],
['#anchor', true],
['img/a.png', true],
['./other.md', true],
['/abs/x.png', true],
['x.png', true],
['../up.md', true],
// Windows 反斜杠路径(用户在 Windows 上写 `![img\foo.png](img\foo.png)`
['img\\foo.png', true],
['.\\foo.png', true],
['..\\up.md', true],
['\\abs\\x.png', true],
['javascript:alert(1)', false],
['vbscript:msgbox(1)', false],
['data:text/html,xxx', false],
['about:blank', false],
['chrome-extension://x/y', false],
// 协议相对 URL// 或 \\ 开头会被浏览器解析为「继承当前 scheme」
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
// 必须与绝对路径 /abs/x.png 区分开 —— 单 / 放行,双 / 拒绝。
['//evil.com/x.png', false],
['\\\\evil.com\\x.png', false],
])('"%s" 匹配 = %s', (input, expected) => {
expect(ALLOWED_URI_REGEXP.test(input)).toBe(expected);
});
});
describe('XSS 防护', () => {
it('<script> 被剥离', () => {
const html = render('# x\n\n<script>alert(1)</script>');
expect(html).not.toMatch(/<script/i);
});
it('onerror= 事件属性被剥离', () => {
const html = render('<img src="x" onerror="alert(1)">');
expect(html).not.toMatch(/onerror/i);
});
it('javascript: 在 <a href> 被剥离', () => {
const html = render('[c](javascript:alert(1))');
expect(html).not.toMatch(/javascript:/i);
});
it('vbscript: 被剥离', () => {
const html = render('[c](vbscript:msgbox(1))');
expect(html).not.toMatch(/vbscript:/i);
});
it('data:text/html 被剥离(仅 data:image/* 放行)', () => {
const html = render('[c](data:text/html,<script>x</script>)');
expect(html).not.toMatch(/href="data:text\/html/i);
});
it('<iframe> 标签被剥离', () => {
const html = render('<iframe src="https://x"></iframe>');
expect(html).not.toMatch(/<iframe/i);
});
it('<form action="javascript:"> action 被剥离', () => {
const html = render('<form action="javascript:alert(1)"><input></form>');
expect(html).not.toMatch(/action="javascript:/i);
});
it('//evil.com/x.png协议相对 URL在 <a href> 被剥离', () => {
// 之前 ALLOWED_URI_REGEXP 的 `[^a-z]` 分支匹配任何非字母字符,
// 放过 `//evil.com/x.png`。浏览器解析为「继承当前 scheme」可静默
// 导航到外站(或在 renderer 上下文里利用 about:blank 之类)。
const html = render('[click](//evil.com/x.png)');
expect(html).not.toMatch(/href="[^"]*evil\.com/i);
});
it('//evil.com/x.png 在 <img src> 被剥(不依赖 DOMPurify 内置)', () => {
const html = render('![a](//evil.com/x.png)');
expect(html).not.toMatch(/src="[^"]*evil\.com/i);
});
});
describe('<img src> 保留策略', () => {
it.each([
['相对路径 img/a.png', 'img/a.png'],
['相对 ./other.md', './other.md'],
['绝对 /abs/x.png', '/abs/x.png'],
['file:/// 协议', 'file:///C:/x.png'],
['data:image/png base64', 'data:image/png;base64,AAA'],
['https://', 'https://e/x.png'],
])('%s 保留 src', (_label, src) => {
const html = render(`![a](${src})`);
expect(html).toContain(`src="${src}"`);
});
it('javascript: 在 <img src> 被剥hook 主动剥,不依赖 DOMPurify 内置)', () => {
// 现在所有 src/href/action 都走 DANGEROUS_URI_REGEXP 主动剥 —— 不再依赖
// DOMPurify v3 内置兜底(虽然 v3 仍会兜底剥 javascript:,但 hook 是第一道闸)。
const html = render('![a](javascript:alert(1))');
expect(html).not.toMatch(/src="javascript:/);
});
it('vbscript: 在 <img src> 被剥', () => {
// 旧实现对 vbscript: 放行keepAttr=true—— DOMPurify v3 不兜底这个。
// 现在由 hook 主动剥,与 javascript: 行为一致。
const html = render('![a](vbscript:msgbox(1))');
expect(html).not.toMatch(/src="vbscript:/i);
});
it('about:blank 在 <img src> 被剥policy 统一:未在白名单的 scheme 一律不放)', () => {
// 旧实现会放行 about:blank不是危险协议但也不在白名单现在统一被剥。
const html = render('![a](about:blank)');
expect(html).not.toMatch(/src="about:/i);
});
it('data:text/html 在 <img src> 被剥(统一策略:仅 data:image/* 放行)', () => {
// 之前测试断言「保留」(理由是浏览器不把 img 渲染为 HTML—— 但跨浏览器 / 跨版本
// 一致性差,且与 ALLOWED_URI_REGEXP 单点策略不一致。现在统一被剥。
const html = render('![a](data:text/html,<script>x</script>)');
expect(html).not.toMatch(/src="data:text\/html/i);
});
// fix(audit 2026-08)SVG 数据 URL 在 <a href> 上点击会导航到 top-level SVG
// 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。
// ALLOWED_URI_REGEXP 限定 data:image 为栅格格式png/jpeg/gif/webp/...
// 显式拒绝 svg+xml —— Markdown 几乎不会内嵌 SVGraster 已覆盖 99% 场景。
it('data:image/svg+xml 在 <img src> 被剥(栅格白名单显式排除 SVG', () => {
const html = render('![a](data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnPjxzY3JpcHQ+YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4=)');
expect(html).not.toMatch(/src="data:image\/svg\+xml/i);
});
// audit fix (Round 13 / Sec-H2)file:// 必须严格 file:///... 三斜杠。
// 拒绝 file://host/... Windows 上解析为 \\host\share触发 SMB NTLMv2 认证,
// 攻击者能抓受害者的 domain\user + NTLM hash —— CVE-2023-23397 类原语)。
it.each([
['远程主机 evil.comCVE-2023-23397 原语)', 'file://evil.com/share/pixel.png'],
['UNC 形式 ////', 'file:////evilhost/share/x.png'],
['显式 localhost也应拒绝 — 唯一合法形式只有 ///', 'file://localhost/C:/x.png'],
['大小写混合 UNC', 'FILE:////evilhost/x.png'],
['前导空格 + 远程主机(绕过 trim', ' file://evil.com/x.png'],
])('file:// %s 在 <img src> 被剥', (_label, src) => {
const html = render(`![a](${src})`);
expect(html).not.toMatch(/src="file:/i);
});
it('file:/// 三斜杠(无 host在 <img src> 仍保留(合法本地图片)', () => {
const html = render('![a](file:///C:/Users/me/Notes/x.png)');
expect(html).toContain('src="file:///C:/Users/me/Notes/x.png"');
});
// audit fix (Round 13 / Sec-M):项目里 markdown 保留合法 inline stylecolor /
// font-size / background:url(https://...) 等纯视觉属性),由 isDangerousStyleValue
// 按 CSS 属性名做 denylist 拦截。denylist 覆盖了所有能改变布局 / 跳出文档流 /
// 遮挡 UI / 隐藏元素 / 让用户看不清真实界面的属性 —— 即便攻击者把整个浏览器
// 窗口当画布也办不到。
//
// 这些测试走 renderHtml() 而不是 render() —— marked 的 lexer 会对裸 inline
// HTML 做容错(部分 inline 会按字符实体 / 段落拆分),无法精确验证 DOMPurify
// 在 ALLOWED 名单下的剔除策略。绕开 marked 直喂 HTML 给 sanitize行为更可预测。
it.each([
['position:fixed 全屏覆盖层', '<a href="https://phish.example" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647;display:block">x</a>'],
['display:none不让用户看到真实 UI', '<div style="display:none">hidden</div>'],
['opacity:0透明覆盖层', '<div style="opacity:0;position:absolute;top:0;left:0;width:100vw;height:100vh">overlay</div>'],
['visibility:hidden', '<span style="visibility:hidden">x</span>'],
['pointer-events:none透传点击到下层', '<span style="pointer-events:none;position:absolute;inset:0">trap</span>'],
['transform绕过父级 contain', '<span style="transform:translate(-100vw,-100vh)">off-screen</span>'],
['content注入伪元素文本', '<span style="content:&apos;会话已过期&apos;">x</span>'],
['top/left/right/bottom/inset绝对定位', '<span style="position:absolute;top:0;left:0">x</span>'],
])('style 含危险属性 %s → style 属性被剥', (_label, htmlIn) => {
const html = renderHtml(htmlIn);
expect(html).not.toMatch(/style=/i);
});
it('合法 stylecolor / font-size / text-align保留', () => {
// 视觉自定义应通过合法 CSS 属性而非 inline style —— 但项目历史包袱允许
// 一些纯样式 style。回归测试确保 denylist 没误伤。
const html = renderHtml('<span style="color:red;font-size:14px;text-align:center">x</span>');
expect(html).toMatch(/color\s*:\s*red/i);
expect(html).toMatch(/font-size\s*:\s*14px/i);
});
it('<svg> 标签被剥(防 SVG-namespaced <style> 注入文档级 CSS', () => {
const html = renderHtml('<svg><style>body::after{content:"phish"}</style></svg>');
expect(html).not.toMatch(/<svg/i);
expect(html).not.toMatch(/phish/);
});
it('<form>/<input> 被剥', () => {
const html = renderHtml('<form action="https://evil.example"><input type="text" name="x"></form>');
expect(html).not.toMatch(/<form/i);
expect(html).not.toMatch(/<input/i);
});
it('data:image/png 仍保留(栅格白名单基线)', () => {
const html = render('![a](data:image/png;base64,iVBORw0KGgo=)');
expect(html).toMatch(/src="data:image\/png;base64,iVBORw0KGgo="/);
});
});
describe('非 IMG 标签的 file: 协议', () => {
it('<a href="file://..."> 被剥', () => {
const html = render('[l](file:///C:/x.txt)');
expect(html).not.toMatch(/href="file:\/\/\/C:\/x\.txt/);
});
it('<form action="file://..."> action 被剥', () => {
const html = render('<form action="file:///C:/x"><input></form>');
expect(html).not.toMatch(/action="file:\/\/\/C:\/x/);
});
// audit fix (Round 4 P1-5):非 IMG 标签的 URI 属性也走 ALLOWED 校验剥
// data:image/svg+xml。ALLOWED_URI_REGEXP 显式排除 svg+xml只允许栅格
// 之前非 IMG 只走 DANGEROUS 负向预查放过 data:image/svg+xml → 完全依赖
// DOMPurify v3 内置兜底。现在 hook 第二道闸显式剥。
it('<a href="data:image/svg+xml;..."> 被剥(非 IMG 也走 ALLOWED', () => {
const html = render('[l](data:image/svg+xml;base64,PHN2Zz4=)');
expect(html).not.toMatch(/href="data:image\/svg\+xml/i);
});
it('<a href="https://..."> 仍保留(普通 https 不被误剥)', () => {
const html = render('[l](https://example.com)');
expect(html).toMatch(/href="https:\/\/example\.com"/);
});
it('target=_blank 自动注入的 rel="noopener noreferrer" 不被误剥', () => {
// 之前 bugnon-IMG ALLOWED 检查对所有非 IMG 属性生效,把 DOMPurify 自动
// 加的安全 rel="noopener noreferrer" 也判定为不通过 ALLOWED → 误剥。
// 现在限定为 URI 类属性href/src/action 等),安全属性放过。
const html = render('[x](https://e)');
expect(html).toMatch(/rel="noopener noreferrer"/);
});
});
describe('<img src> data:image 大小上限 (audit Sec-M3)', () => {
it('正常大小 data:image/png base64 保留', () => {
const html = render('![a](data:image/png;base64,AAAA)');
expect(html).toMatch(/src="data:image\/png;base64,AAAA"/);
});
it('巨大 data:image (>10MB) 被剥 src', () => {
// 绕过 marked 直接 sanitizemarked 的 lexer 在 11MB base64 上会先栈溢出,
// 走 marked 路径反而测不到我们要保护的那一层。这里手搓 <img> 直接喂给
// DOMPurify —— 验证 hook 在 data:image 巨长 base64 上会主动 keepAttr=false。
const huge = `data:image/png;base64,${'A'.repeat(11 * 1024 * 1024)}`;
const html = DOMPurifyInstance.sanitize(`<img src="${huge}">`, {
ADD_ATTR: ['src'],
ALLOWED_URI_REGEXP,
});
expect(html).not.toMatch(new RegExp(`src="data:image/png;base64,${'A'.repeat(100)}`));
});
it('10MB 临界值正好放过(不挡合理大小)', () => {
// 边界:刚好等于上限应当放过;实际我们想留余量,写测试时只看 < 1MB
const small = `data:image/png;base64,${'A'.repeat(1024 * 1024)}`;
const html = render(`![x](${small})`);
expect(html).toMatch(/src="data:image\/png;base64,A{100}/);
});
it('非 data: 的超长 URL 不受影响(绝对 file:/https: 走原路径)', () => {
// file:/// 与 https:// 不应被本次 size 限制波及;行为与先前一致。
const longHttps = `https://e.com/${'a'.repeat(1024 * 1024)}.png`;
const html = render(`![x](${longHttps})`);
expect(html).toMatch(/^<p><img src="https:\/\/e\.com\//);
});
});
// audit fix (Round 9)style 属性里嵌的危险 CSS 模式javascript:/expression/
// behavior/-moz-binding/@import走 DANGEROUS_STYLE_PATTERNS 主动剥。
// DOMPurify v3 自带部分保护,但跨版本一致性差;这里做防御性深度扫描。
describe('style 属性危险 CSS 模式Round 9 防御性深度扫描)', () => {
it('style 里 url(javascript:...) 被剥', () => {
const html = render('<a href="https://e.com" style="background:url(javascript:alert(1))">x</a>');
expect(html).not.toMatch(/url\s*\(\s*javascript:/i);
// 整条 style 被剥(保守策略)
expect(html).not.toMatch(/style=/i);
});
it('style 里 url("javascript:...") 引号包裹也被剥', () => {
const html = render('<span style="background:url(\'javascript:alert(1)\')">x</span>');
expect(html).not.toMatch(/javascript:/i);
});
it('style 里 url(VBScript:) 大小写不敏感也被剥', () => {
const html = render('<span style="background:url(VBScript:msgbox(1))">x</span>');
expect(html).not.toMatch(/vbscript:/i);
});
it('style 里 url(data:text/html...) 被剥(拒绝非 image data:', () => {
const html = render('<span style="background:url(data:text/html,<script>alert(1)</script>)">x</span>');
expect(html).not.toMatch(/data:text\/html/i);
});
it('style 里 expression(alert(1)) legacy IE 模式被剥', () => {
const html = render('<div style="width:expression(alert(1))">x</div>');
expect(html).not.toMatch(/expression\s*\(/i);
});
it('style 里 behavior:url(...) legacy IE HTC 模式被剥', () => {
const html = render('<div style="behavior:url(#default#anchorclick)">x</div>');
expect(html).not.toMatch(/behavior\s*:/i);
});
it('style 里 -moz-binding:url(...) legacy Mozilla XBL 模式被剥', () => {
const html = render('<div style="-moz-binding:url(\'http://evil/xbl.xml#x\')">x</div>');
expect(html).not.toMatch(/-moz-binding/i);
});
it('style 里 @import 外链资源加载被剥', () => {
const html = render('<div style="@import url(http://evil/x.css); color:red">x</div>');
expect(html).not.toMatch(/@import/i);
});
it('合法 style 保留color / font-size 等无害属性不动)', () => {
const html = render('<span style="color:red; font-size:14px">x</span>');
expect(html).toMatch(/style="[^"]*color\s*:\s*red/i);
expect(html).toMatch(/style="[^"]*font-size\s*:\s*14px/i);
});
it('合法 url()http: / data:image/)保留', () => {
const html = render('<div style="background:url(https://e.com/x.png); color:red">x</div>');
expect(html).toMatch(/style="[^"]*color\s*:\s*red/i);
// 字符串里出现的 url(https://...) 是合法上下文不应剥
expect(html).not.toMatch(/style="[^"]*javascript:|vbscript:|expression\s*\(|@import/i);
});
});
describe('marked 输出增强', () => {
it('链接注入 target=_blank + rel="noopener noreferrer"', () => {
const html = render('[x](https://e)');
expect(html).toMatch(/target="_blank"/);
expect(html).toMatch(/rel="noopener noreferrer"/);
});
it('中文标题生成 id 锚点', () => {
const html = render('## 你好世界');
expect(html).toMatch(/<h2 id="你好世界">/);
});
it('同名标题生成 -1/-2 后缀', () => {
const html = render('## 你好世界\n\n## 你好世界');
expect(html).toMatch(/id="你好世界"/);
expect(html).toMatch(/id="你好世界-1"/);
});
});

View File

@@ -0,0 +1,50 @@
// safeRenderMarkdown typeof guard 测试Round 8 R-1
//
// 单独成文件 + jsdom env不污染 utils.test.js 的 node env
// - utils.test.js 里 pathsEqual Windows case-fold 测试依赖 Node 24 的
// navigator.platform='Win32'jsdom 25 是 ''),不同 env 命中不同 isWindows
// 分支。utils.test.js 留在 node env 跑基线更稳。
// - 本测试需要 window 存在以挂 window.api stub → 必须 jsdom。
//
// 覆盖renderMarkdown 契约是 string但 jsdom 单测或未来 mock 替换可能返回
// 非字符串。typeof guard 防 null / undefined / 数字 直接 setBody 触发下游
// DOM 解析错。**关键反例**:必须用 typeof不能用 `||`,因为 '' 是空 markdown
// 的合法返回preload 对空字符串返回 '')—— `result ||` 会把合法空 markdown
// 误降级为 <pre>,污染空文档渲染。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { safeRenderMarkdown } from '../../src/utils.js';
describe('safeRenderMarkdown —— typeof guard (Round 8 R-1)', () => {
let savedApi;
beforeEach(() => {
savedApi = window.api;
});
afterEach(() => {
window.api = savedApi;
});
it('window.api.renderMarkdown 返回 null → 降级为 <pre>', () => {
window.api = { renderMarkdown: () => null };
expect(safeRenderMarkdown('text')).toContain('<pre');
});
it('window.api.renderMarkdown 返回 undefined → 降级为 <pre>', () => {
window.api = { renderMarkdown: () => undefined };
expect(safeRenderMarkdown('text')).toContain('<pre');
});
it('window.api.renderMarkdown 返回数字 → 降级为 <pre>', () => {
window.api = { renderMarkdown: () => 42 };
expect(safeRenderMarkdown('text')).toContain('<pre');
});
it('window.api.renderMarkdown 返回合法字符串 → 透传', () => {
window.api = { renderMarkdown: () => '<p>ok</p>' };
expect(safeRenderMarkdown('text')).toBe('<p>ok</p>');
});
it('空 markdown → window.api.renderMarkdown 返回空字符串 → 透传空字符串(不降级)', () => {
// 关键反例:旧版用 `||` 会把 '' 误降级为 <pre>,污染空文档渲染。
window.api = { renderMarkdown: () => '' };
expect(safeRenderMarkdown('')).toBe('');
});
});

View File

@@ -0,0 +1,200 @@
// src/save-button-ui.js 测试
//
// 覆盖保存按钮 UI 同步函数的三态:
// - 干净isDirty=false, savingCount=0
// - 有待保存改动isDirty=true, savingCount=0
// - 保存中isDirty=*, savingCount>0
// - 同时保存中 + 脏
//
// 设计要点(见 src/save-button-ui.js 注释):
// - 始终显示按钮([[feedback-save-button-always-visible]]),不切 hidden
// - aria-busy 与 is-saving class 同步
// - btnSave=null 时静默 return不抛
// - setDirty 回调只在 isDirty 翻转时才有意义,但函数本身每次都调一次
// (去重由调用方在调用频率上控制;这里只验证契约)
//
// 限制save() / scheduleAutoSave() 内部使用 savingCount但二者都在 src/app.js
// 闭包里耦合state / els / window.api无法直接 import 单测。本文件只覆盖
// 已抽出的 syncSaveButtonUI 纯函数save() 的 in-flight sentinel / 防抖语义等
// 留待未来把 save() 拆出 helper 后再补。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { syncSaveButton } from '../../src/save-button-ui.js';
function makeBtn() {
// 模拟工具栏保存按钮的最小子集classList + setAttribute + title getter/setter
const btn = document.createElement('button');
btn.id = 'btn-save';
return btn;
}
function makeStatusUnsaved() {
const el = document.createElement('span');
el.id = 'status-unsaved';
el.hidden = true;
el.textContent = '';
return el;
}
describe('syncSaveButton 三态同步', () => {
let btn;
let setDirty;
beforeEach(() => {
btn = makeBtn();
setDirty = vi.fn();
});
it('干净态isDirty=false, savingCount=0移除 is-dirty + is-saving', () => {
const state = { isDirty: false, savingCount: 0 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存');
expect(btn.title).toBe('保存 (Ctrl+S)');
expect(setDirty).toHaveBeenCalledWith(false);
});
it('脏态isDirty=true, savingCount=0加 is-dirty + 改文案', () => {
const state = { isDirty: true, savingCount: 0 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存(有待保存的改动)');
expect(btn.title).toBe('保存(有待保存的改动) (Ctrl+S)');
expect(setDirty).toHaveBeenCalledWith(true);
});
it('保存中isDirty=false, savingCount=1加 is-saving + aria-busy=true', () => {
const state = { isDirty: false, savingCount: 1 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('保存中…');
expect(btn.title).toBe('保存中…');
// savingCount>0 不影响 isDirty 通知setDirty 仍按 isDirty 当前值传
expect(setDirty).toHaveBeenCalledWith(false);
});
it('同时保存中 + 脏savingCount>0 优先于 isDirty 的文案)', () => {
// 在飞行期间用户又敲了字isDirty 重新为 true —— 文案应当显示「保存中…」
// 而不是「有待保存改动」。is-dirty class 保留是为了视觉提示warning 色脉动)
const state = { isDirty: true, savingCount: 1 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('保存中…');
expect(setDirty).toHaveBeenCalledWith(true);
});
it('savingCount 累加(并发 save归零前不切回非 saving 态', () => {
// 模拟 inFlightSave 共享:第一次 save 入口 ++savingCount → 1第二次 save
// 共享路径又 ++savingCount → 2。共享 then 完成时两次 finally 各 -1最终归零。
// 函数本身只读 savingCount>0 判定,所以中间任意调用都保持 is-saving。
const state = { isDirty: false, savingCount: 2 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
});
it('btnSave=null → 静默 return不抛', () => {
const state = { isDirty: true, savingCount: 0 };
// eslint-disable-next-line no-unused-vars
const localSetDirty = vi.fn();
expect(() => syncSaveButton({ btnSave: null, state, setDirty: localSetDirty }))
.not.toThrow();
// btnSave=null 时整个函数提前 return不会调 setDirty
expect(localSetDirty).not.toHaveBeenCalled();
});
it('setDirty 缺省 → 默认空操作(不抛)', () => {
const state = { isDirty: true, savingCount: 0 };
expect(() => syncSaveButton({ btnSave: btn, state })).not.toThrow();
// 即便 btnSave 存在且 state.isDirty=true没传 setDirty 也不抛
expect(btn.classList.contains('is-dirty')).toBe(true);
});
it('savingCount 归零保护helper 不修改 savingCount', () => {
// 回归保护syncSaveButtonUI 是纯 UI 同步,不应修改 state.savingCount。
// savingCount 的累加/递减必须由 save() 入口/finally 唯一管理,否则会
// 与 save() 内部的累加叠加导致计数永远不平衡 → 保存按钮永远卡在
// 「保存中…」态。手动审计 src/app.js:save() 用外层 try/finally 覆盖
// 所有 6 个 return 路径,但 helper 这里也加一道防线。
const state = { isDirty: true, savingCount: 5 };
const before = state.savingCount;
syncSaveButton({ btnSave: btn, state, setDirty });
expect(state.savingCount).toBe(before);
expect(state.savingCount).toBe(5);
});
it('连续调用:脏→保存中→干净,三阶段过渡', () => {
// 模拟完整 save() 生命周期onChange (脏) → save 入口 (savingCount++) → 成功
// (markSaved + savingCount--)
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 0 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(false);
// save 入口
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 1 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
// save 完成isDirty 重读为 falsesavingCount 归零
syncSaveButton({ btnSave: btn, state: { isDirty: false, savingCount: 0 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存');
});
});
describe('syncSaveButton 状态栏未保存 chip', () => {
let btn;
let chip;
let setDirty;
beforeEach(() => {
btn = makeBtn();
chip = makeStatusUnsaved();
setDirty = vi.fn();
});
it('脏态chip 显示「未保存」', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: true, savingCount: 0 }, setDirty });
expect(chip.hidden).toBe(false);
expect(chip.textContent).toBe('未保存');
expect(chip.getAttribute('aria-label')).toBe('当前文件有未保存的改动');
});
it('干净态chip hidden', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: false, savingCount: 0 }, setDirty });
expect(chip.hidden).toBe(true);
});
it('保存中但已脏:仍显示 chip不让过渡态闪烁', () => {
// 设计决策savingCount>0 但 isDirty=true 时仍显示 chip —— 飞行结束后
// 会立刻切回脏/干净,状态栏不要一明一灭的视觉抖动。仅 isDirty 驱动 chip。
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: true, savingCount: 1 }, setDirty });
expect(chip.hidden).toBe(false);
expect(chip.textContent).toBe('未保存');
});
it('保存中且不脏飞行期用户撤销编辑chip hidden', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: false, savingCount: 1 }, setDirty });
expect(chip.hidden).toBe(true);
});
it('chip 不传DOM 不被影响', () => {
// 兼容旧调用方(不传 statusUnsaved不应抛错
expect(() =>
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 0 }, setDirty })
).not.toThrow();
});
});

View File

@@ -0,0 +1,270 @@
// src/save-toast.js 单测Phase N Q-fix + Phase 2 audit 修复)
//
// 覆盖 save() outer toast 决策的所有 (silent × result) 组合:
// Phase N Q-fix 修四个真 bug
// 1. shared path silent=false manual + IIFE stale → 旧逻辑不弹 toast
// 用户按 Ctrl+S 切文件后完全无反馈
// 2. shared path silent=true auto-save + manual silent=false 写盘成功
// → 旧逻辑不弹「已保存」IIFE silent=true 不弹 + shared manual
// 因 IIFE silent 被吞也不弹)
// 3. onSaveRequest silent=false 与 main.js confirmDiscardIfDirty 错误反馈
// 双重 toast
// 4. file:write catch 直回 e.message英文 + 路径泄露)
// Phase 2 audit 修 P1 #3STALE 不再等同「写盘失败」—— writeFile 在 STALE
// 检查前已经 resolvedata 可能已落 targetPath。ok=true 让关窗路径能正常退
// 出,但 toast 文案不变(用户视角的「文件已切换」提示总是合适)。
// 本文件覆盖决策矩阵bug 3 在调用方 silent=trueonSaveRequestbug 4
// 在 main/file-ops.js#friendlyWriteError 处测,见 file-ops.test.js。
import { describe, it, expect, vi } from 'vitest';
import { decideSaveToast } from '../../src/save-toast.js';
function makeShowToast() {
return vi.fn();
}
describe('decideSaveToast —— 基础合约', () => {
it('silent=false + ok=true无 STALE→ 弹「已保存」(success)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: true }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('silent=true + ok=true → 不弹auto-save 静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + reason=STALE → 不弹auto-save 静默,不打扰用户)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + 任何 reason → 都不弹', () => {
// auto-save 飞行期任何失败 IIFE 内已按 result.error 弹过错误 toast与 silent 无关),
// 这里 silent=true 再弹会导致 auto-save 飞行期错误反馈与 silent=false 路径混淆
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(true, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('result=null / undefined → 不抛、不弹', () => {
const showToast = makeShowToast();
expect(() => decideSaveToast(false, null, showToast)).not.toThrow();
expect(() => decideSaveToast(false, undefined, showToast)).not.toThrow();
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— STALE 反馈(核心 bug #1 修复 + Phase 2 P1 #3', () => {
it('silent=false + reason=STALE + ok=false → 弹「文件已切换,未保存到磁盘」(warning)', () => {
// 关键修复 #1飞行期用户切文件 → IIFE 静默 return {ok:false, reason:STALE} →
// silent=false manual caller 必须有反馈,告知「文件已切换,未保存到磁盘」。
// 旧逻辑 silent=false 但 result 不 ok → 不弹 → 用户按 Ctrl+S 什么反馈都没。
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('silent=false + reason=STALE + ok=true → 同样弹「文件已切换」', () => {
// Phase 2 audit 修复 (P1 #3)writeFile 在 STALE 检查前已 resolve
// ok=true 表示 data 已落盘,但 editor 已不在 targetPath 上。用户视角
// 仍然需要「文件已切换」反馈(编辑器和预期的不一致)。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('STALE 优先于 ok=truereason=STALE 时一律走 warning', () => {
// 防呆测试:即使 ok=truereason=STALE 必须弹 warning 而不是 success。
// 否则 Phase 2 修复会把「data 已落盘」的 success 文案误导成「保存好了」。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('silent=false + 其它失败 reasonCONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => {
// 关键防呆IIFE 内部已按 result.error 弹过具体错误 toastFILE_CHANGED_EXTERNALLY
// → 弹冲突对话框 / FILE_NOT_FOUND → 弹「文件已被删除」/ errno → 弹「磁盘满 / 只读」等),
// 这里再弹「文件已切换...」会误导用户以为换文件了。
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(false, { ok: false, reason: 'CONFLICT_DISCARDED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'CONFLICT_KEPT' }, showToast);
decideSaveToast(false, { ok: false, reason: 'FILE_NOT_FOUND' }, showToast);
decideSaveToast(false, { ok: false, reason: 'PATH_NOT_ALLOWED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'SYMLINK_NOT_ALLOWED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=false + ok=false 无 reason → 不弹(防御性)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— shared path 场景(核心 bug #2 修复)', () => {
it('auto-save silent=true 飞行期 + manual silent=false 写盘成功 → 弹「已保存」', () => {
// 关键修复:旧逻辑 IIFE silent=true 不弹 + shared manual silent=false 也
// 被吞(直接 return IIFE 结果)→ 用户按 Ctrl+S 成功写盘无任何反馈。
// 现在 shared path 调 decideSaveToastsilent=false manual 自己决定 → 弹。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('auto-save silent=true 飞行期 + manual silent=false STALE → 弹「文件已切换」', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「文件已切换」', () => {
// Phase 2 P1 #3writeFile 成功但用户已切走 → 关窗路径用 ok=true 退出,
// 但 silent=false manual caller 仍应弹 STALE 反馈。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('同 IIFE 结果auto-save silent=true 不弹 + manual silent=false 弹 → 不互相干扰', () => {
// 这是核心IIFE 写盘成功后auto-save 不弹、manual 弹「已保存」;
// 各自按 silent 决策,避免双重 toastforce_overwrite 递归 + manual 共享
// 的边缘 case 仍会有两次「已保存」,但这是 Phase N 之前已有的取舍)。
const sharedResult = { ok: true };
const autoSaveToast = makeShowToast();
const manualToast = makeShowToast();
decideSaveToast(true, sharedResult, autoSaveToast);
decideSaveToast(false, sharedResult, manualToast);
expect(autoSaveToast).not.toHaveBeenCalled();
expect(manualToast).toHaveBeenCalledTimes(1);
expect(manualToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
});
describe('decideSaveToast —— onSaveRequest silent=true核心 bug #3 修复)', () => {
it('silent=true + 任何失败 reason → 不弹', () => {
// 关键修复onSaveRequest 调 save({silent:true}),关闭路径反馈由主进程
// confirmDiscardIfDirty 原生框统一负责,避免双重 toastrenderer toast +
// main 原生「保存失败」框)。本 helper 在 silent=true 时一律不弹,
// 让 main.js 的原生对话框主导。
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(true, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + STALE ok=true → 不弹(关窗路径由 main.js 原生框主导)', () => {
// Phase 2 P1 #3 回归onSaveRequest silent=true + STALE ok=truewriteFile
// 成功但用户已切走save() outer 返回 true 让 main.js 顺利退出,
// 但 renderer 不应弹任何 toast关窗场景用户看不见且避免与 main 原生框重叠)。
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— NO_CHANGES 反馈audit 2026-08早退路径用户感知', () => {
it('silent=false + reason=NO_CHANGES → 弹「已是最新」(info)', () => {
// 关键修复save() 的 !state.isDirty 早退路径手动调用 decideSaveToast
// 传 { ok:true, reason:'NO_CHANGES' }。旧逻辑早退 return true 不弹任何 toast
// auto-save 静默写盘后用户手动点保存 → 看不到反馈 → 「按钮坏了」类错觉。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
});
it('silent=true + reason=NO_CHANGES → 不弹auto-save 早退时静默)', () => {
// auto-save silent 路径不应被新分支打扰;头部 `if (silent) return` 一并拦下。
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('NO_CHANGES 优先于 ok=truereason=NO_CHANGES 时一律走 info', () => {
// 防呆:传 ok=true 但带 NO_CHANGES reason 必须走 info 分支,不弹 success。
// 否则手动保存「已是最新」会被误显示成「已保存」(success),语义错位。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
});
});
describe('decideSaveToast —— NO_FILE 反馈Round 7 S-3区分「没打开」与「已是最新」', () => {
it('silent=false + reason=NO_FILE → 弹「没有打开文件」(info)', () => {
// 关键修复save() 的 !state.currentFile 早退路径手动调用 decideSaveToast
// 传 { ok:true, reason:'NO_FILE' }。旧版共用 NO_CHANGES 文案「已是最新」,
// 但用户视角「我什么都没打开」与「有文件且未改动」语义不同 —— 改用准确
// 文案「没有打开文件」。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_FILE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('没有打开文件', 'info', 1500);
});
it('silent=true + reason=NO_FILE → 不弹auto-save 早退时静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'NO_FILE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('NO_FILE 不被 NO_CHANGES 截胡:两个 reason 独立走各自分支', () => {
// 防呆NO_FILE 与 NO_CHANGES 是两种不同的早退语义,不能相互覆盖。
const fileToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_FILE' }, fileToast);
expect(fileToast).toHaveBeenCalledWith('没有打开文件', 'info', 1500);
expect(fileToast).not.toHaveBeenCalledWith('已是最新', 'info', 1500);
const changesToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, changesToast);
expect(changesToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(changesToast).not.toHaveBeenCalledWith('没有打开文件', 'info', 1500);
});
});
describe('decideSaveToast —— DELEGATED 反馈Round 8 S-1force_overwrite 委托 inner', () => {
// audit fix (Round 8 S-1)force_overwrite 把写盘委托给递归 inner save()
// inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责
// 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。
// 关键silent=false 也不弹,因为 inner 已经弹过silent=true 也不弹auto-save
it('silent=false + reason=DELEGATED + ok=true → 不弹inner 已弹过「已保存」)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=false + reason=DELEGATED + ok=false → 不弹inner 已弹过错误 toast', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + reason=DELEGATED → 不弹auto-save 静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('DELEGATED 不被 NO_FILE / NO_CHANGES / STALE / 通用 ok=true 截胡reason=DELEGATED 永远走 DELEGATED 分支', () => {
// 反例验证ok=true 时不能被「已保存」分支兜底再弹一个 toast。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
expect(showToast).not.toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('没有打开文件', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
});

View File

@@ -0,0 +1,447 @@
// Stage 7 tests: shared/settings-schema.js
//
// 覆盖:
// - DEFAULT_SETTINGS导出 + 含必要字段
// - coerceLoadedSettings未知字段剥除 / 已知字段合并默认值
// - validateKeyenum / boolean / number clamp / nullable-path
// - validateAndSanitize批量校验遇到非法字段提前失败
//
// shared/ 是 CommonJS直接 require 即可(不像 main/ 需要 _setApp 注入)。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, '../../shared/settings-schema.js');
const schema = require('../../shared/settings-schema.js');
const {
DEFAULT_SETTINGS,
coerceLoadedSettings,
validateKey,
validateAndSanitize,
SETTINGS_SCHEMA,
} = schema;
describe('DEFAULT_SETTINGS', () => {
it('导出且包含核心字段', () => {
expect(DEFAULT_SETTINGS).toBeDefined();
expect(DEFAULT_SETTINGS.theme).toBeDefined();
expect(DEFAULT_SETTINGS.themePalette).toBeDefined();
expect(DEFAULT_SETTINGS.dataDir).toBeDefined();
expect(DEFAULT_SETTINGS.alwaysOnTop).toBe(false);
});
it('AI 默认值provider = openaibaseUrl / apiKey / model / systemPrompt 空串', () => {
expect(DEFAULT_SETTINGS.aiProvider).toBe('openai');
expect(DEFAULT_SETTINGS.aiBaseUrl).toBe('');
expect(DEFAULT_SETTINGS.aiApiKey).toBe('');
expect(DEFAULT_SETTINGS.aiModel).toBe('');
expect(DEFAULT_SETTINGS.aiSystemPrompt).toBe('');
});
});
describe('coerceLoadedSettings', () => {
it('非对象 → 默认值', () => {
expect(coerceLoadedSettings(null)).toEqual({ ...DEFAULT_SETTINGS });
expect(coerceLoadedSettings('str')).toEqual({ ...DEFAULT_SETTINGS });
});
it('已知字段被 raw 覆盖', () => {
const out = coerceLoadedSettings({ theme: 'light', readerFontSize: 19 });
expect(out.theme).toBe('light');
expect(out.readerFontSize).toBe(19);
});
it('未知字段被剥除', () => {
const out = coerceLoadedSettings({ theme: 'light', bogus: 'x' });
expect(out.theme).toBe('light');
expect(out).not.toHaveProperty('bogus');
});
it('缺省字段用默认值', () => {
const out = coerceLoadedSettings({ theme: 'light' });
expect(out.theme).toBe('light');
expect(out.themePalette).toBe(DEFAULT_SETTINGS.themePalette);
});
it('旧 autoSaveIntervalSec (0) → 迁移到 autoSaveDebounceMs (0)', () => {
// 旧版 0 = 关闭;新版 0 = 关闭 —— 直接 1:1 映射
const out = coerceLoadedSettings({ autoSaveIntervalSec: 0 });
expect(out.autoSaveDebounceMs).toBe(0);
expect(out).not.toHaveProperty('autoSaveIntervalSec');
});
it('旧 autoSaveIntervalSec (3 / 10) → 迁移到 autoSaveDebounceMs (500)', () => {
// 旧版「开启」档3 秒、10 秒轮询)→ 新版「500ms 防抖」
// 语义从「每隔 X 秒轮询」变成「停打 X ms 后保存」;统一映射到 500ms 默认值。
expect(coerceLoadedSettings({ autoSaveIntervalSec: 3 }).autoSaveDebounceMs).toBe(500);
expect(coerceLoadedSettings({ autoSaveIntervalSec: 10 }).autoSaveDebounceMs).toBe(500);
});
it('autoSaveDebounceMs 越界 → clamp 到边界', () => {
// 验证 number 类型规则生效:超界值被 clamp不被丢弃
expect(coerceLoadedSettings({ autoSaveDebounceMs: 99999 }).autoSaveDebounceMs).toBe(60000);
expect(coerceLoadedSettings({ autoSaveDebounceMs: -100 }).autoSaveDebounceMs).toBe(0);
});
it('string 字段超长 → 丢弃回默认(同步路径用 rule.max', () => {
// 同步路径coerceLoadedSettings / sanitizeSync一直用 rule.max本次 audit
// 修复的是 async validateKey 误写 opts.max 的问题。两条路径必须保持一致。
const out = coerceLoadedSettings({ aiModel: 'a'.repeat(10_000) });
expect(out.aiModel).toBe(DEFAULT_SETTINGS.aiModel);
});
it('Phase N 修复:下划线前缀的元数据键(如 _hasAiKey保留', () => {
// 主进程 get-settings / save-settings 都会注入 _hasAiKey 作为「是否配置过 API Key」
// 的只读元数据标记。旧实现只迭代 Object.keys(DEFAULT_SETTINGS),未知键全 drop
// 导致 renderer settingsStore.load() 后拿不到 _hasAiKey「显示已填 Key」+ reveal
// 流程全失效。下划线前缀约定为「只读元数据、不写盘」coerce 阶段需保留。
const out = coerceLoadedSettings({ theme: 'dark', _hasAiKey: true });
expect(out.theme).toBe('dark');
expect(out._hasAiKey).toBe(true);
});
it('非下划线前缀的未知键仍被剥除(确保下划线特判不会过度放行)', () => {
// 防御:下划线特判只放行 _ 前缀,普通 unknown 字段仍走原剥离路径,
// 不让攻击者用 _privateName 这种「看上去像元数据」的字段把脏数据灌进内存。
const out = coerceLoadedSettings({ theme: 'dark', _privateNote: 'secret', bogus: 42 });
expect(out).not.toHaveProperty('_privateNote');
expect(out).not.toHaveProperty('bogus');
});
// fix(audit 2026-08)同步路径sanitizeSync / coerceLoadedSettings之前
// 不校验 format 字段,导致手改 settings.json 写 aiBaseUrl: "ftp://x" /
// "not-a-url" 会被静默接受,渲染端拿到无效 URL真正 fetch 时才报 TypeError
// 错误链很难定位到 settings。
it('aiBaseUrl 非法 URL同步路径 → 丢弃回默认空串', () => {
expect(coerceLoadedSettings({ aiBaseUrl: 'ftp://x.y' }).aiBaseUrl).toBe('');
expect(coerceLoadedSettings({ aiBaseUrl: 'not-a-url' }).aiBaseUrl).toBe('');
expect(coerceLoadedSettings({ aiBaseUrl: 'javascript:alert(1)' }).aiBaseUrl).toBe('');
// http:/missing-slash.com —— URL 构造器能解析但 fetch 行为不一致,必须拒绝
expect(coerceLoadedSettings({ aiBaseUrl: 'http:/missing-slash.com' }).aiBaseUrl).toBe('');
});
it('aiBaseUrl 合法 http(s) URL同步路径 → 保留', () => {
expect(coerceLoadedSettings({ aiBaseUrl: 'https://api.openai.com/v1' }).aiBaseUrl).toBe(
'https://api.openai.com/v1',
);
expect(coerceLoadedSettings({ aiBaseUrl: 'http://10.0.0.1:11434/v1' }).aiBaseUrl).toBe(
'http://10.0.0.1:11434/v1',
);
});
// fix(audit 2026-08)Boolean 输入不应被 Number() 强转。
// Number(true) === 1、Number(false) === 0旧版会让 autoSaveDebounceMs: true
// 静默变 1ms激进到每个 keystroke 都存splitRatio: false 变 0无预览
it('number 字段 Boolean 输入 → 丢弃回默认', () => {
expect(coerceLoadedSettings({ autoSaveDebounceMs: true }).autoSaveDebounceMs)
.toBe(DEFAULT_SETTINGS.autoSaveDebounceMs);
expect(coerceLoadedSettings({ splitRatio: false }).splitRatio)
.toBe(DEFAULT_SETTINGS.splitRatio);
expect(coerceLoadedSettings({ readerFontSize: true }).readerFontSize)
.toBe(DEFAULT_SETTINGS.readerFontSize);
});
// fix(audit 2026-08)round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数round:2 才能保留用户的选择。
it('readerLineHeight 1.85 不会被四舍五入到 1.9', () => {
expect(coerceLoadedSettings({ readerLineHeight: 1.85 }).readerLineHeight).toBe(1.85);
expect(coerceLoadedSettings({ readerLineHeight: 1.7 }).readerLineHeight).toBe(1.7);
expect(coerceLoadedSettings({ readerLineHeight: 2.0 }).readerLineHeight).toBe(2.0);
});
});
describe('validateKey', () => {
it('enum 合法值 → ok', async () => {
const r = await validateKey('theme', 'light');
expect(r.ok).toBe(true);
expect(r.value).toBe('light');
});
it('enum 非法值 → 报错', async () => {
const r = await validateKey('theme', 'rainbow');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/theme 取值非法/);
});
it('boolean 非法类型 → 报错', async () => {
const r = await validateKey('alwaysOnTop', 'yes');
expect(r.ok).toBe(false);
});
it('number clamp 越界 → clamp 到边界', async () => {
const tooBig = await validateKey('readerFontSize', 99);
expect(tooBig.ok).toBe(true);
expect(tooBig.value).toBe(SETTINGS_SCHEMA.readerFontSize.max);
const tooSmall = await validateKey('readerFontSize', 1);
expect(tooSmall.ok).toBe(true);
expect(tooSmall.value).toBe(SETTINGS_SCHEMA.readerFontSize.min);
});
it('number 非数字 → 报错', async () => {
const r = await validateKey('readerFontSize', 'big');
expect(r.ok).toBe(false);
});
it('nullable-path 空字符串 → null', async () => {
const r = await validateKey('dataDir', ' ');
expect(r.ok).toBe(true);
expect(r.value).toBe(null);
});
// audit fix (Round 9)nullable-number 与 number 分支对称拒绝空串。
// 旧版 `case 'nullable-number'` 直接 `Number(raw)` → Number('') === 0 →
// clamp 到 minsidebarWidth='' 会被静默改成 200默认 min
it('nullable-number 空字符串 → 报错(不是 0', async () => {
const r = await validateKey('sidebarWidth', '');
expect(r.ok).toBe(false);
});
it('nullable-number 全空白字符串 → 报错', async () => {
const r = await validateKey('sidebarWidth', ' ');
expect(r.ok).toBe(false);
});
it('nullable-number null → null合法', async () => {
const r = await validateKey('sidebarWidth', null);
expect(r.ok).toBe(true);
expect(r.value).toBe(null);
});
it('nullable-number undefined → 视为 null与同步路径一致', async () => {
// 审计修复 (Round 11 deep-fix P2-3)nullable 字段的 undefined 与 null 同义。
// 旧版 async 路径把 undefined 当作错误(与同步 sanitizeSync 行为不一致),
// 导致 load() 接受 + update() 拒绝同一字段。
const r = await validateKey('sidebarWidth', undefined);
expect(r.ok).toBe(true);
expect(r.value).toBeNull();
});
it('nullable-path 非字符串 → 报错', async () => {
const r = await validateKey('dataDir', 123);
expect(r.ok).toBe(false);
});
it('未知字段 → 报错', async () => {
const r = await validateKey('unknown', 'anything');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/未知设置项/);
});
it('autoSaveDebounceMs 合法值 0 / 500 / 60000', async () => {
expect((await validateKey('autoSaveDebounceMs', 0)).ok).toBe(true);
expect((await validateKey('autoSaveDebounceMs', 500)).ok).toBe(true);
expect((await validateKey('autoSaveDebounceMs', 60000)).ok).toBe(true);
});
it('autoSaveDebounceMs 越界 → clamp', async () => {
// -1 → 099999 → 60000clamp 行为)
expect((await validateKey('autoSaveDebounceMs', -1)).value).toBe(0);
expect((await validateKey('autoSaveDebounceMs', 99999)).value).toBe(60000);
});
it('autoSaveDebounceMs 非数字 → 报错', async () => {
const r = await validateKey('autoSaveDebounceMs', 'soon');
expect(r.ok).toBe(false);
});
it('aiProvider openai / anthropic → ok', async () => {
expect((await validateKey('aiProvider', 'openai')).value).toBe('openai');
expect((await validateKey('aiProvider', 'anthropic')).value).toBe('anthropic');
});
it('aiProvider 非法值 → 报错', async () => {
const r = await validateKey('aiProvider', 'gemini');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/aiProvider 取值非法/);
});
it('string 字段超过 rule.max → 报错', async () => {
// audit fix之前误用 opts.max导致 aiApiKey/aiBaseUrl/aiModel/aiSystemPrompt
// 声明的字符上限从未生效。这里锁定 rule.max 路径。
expect((await validateKey('aiModel', 'a'.repeat(257))).ok).toBe(false);
expect((await validateKey('aiBaseUrl', 'a'.repeat(4097))).ok).toBe(false);
expect((await validateKey('aiApiKey', 'k'.repeat(4097))).ok).toBe(false);
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_001))).ok).toBe(false);
});
it('string 字段在 rule.max 范围内 → ok', async () => {
expect((await validateKey('aiModel', 'a'.repeat(256))).ok).toBe(true);
// aiBaseUrl 现在带 url-https 格式校验 —— 用合法 URL 串代替 'a'.repeat
// 否则 URL 校验会拒绝非 URL 字符。最大长度限制的覆盖由上面的「超长」
// 测试用例保证。12 字符 scheme/host + 4084 字符 path = 4096 总长。
expect((await validateKey('aiBaseUrl', 'https://a.aa/' + 'a'.repeat(4083))).ok).toBe(true);
expect((await validateKey('aiApiKey', 'k'.repeat(4096))).ok).toBe(true);
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_000))).ok).toBe(true);
});
it('string 字段超长错误信息含上限值', async () => {
const r = await validateKey('aiModel', 'a'.repeat(257));
expect(r.error).toMatch(/超过 256 字符/);
});
});
describe('validateAndSanitize', () => {
it('合法 batch → 返回 sanitized 对象', async () => {
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 18 });
expect(r.ok).toBe(true);
expect(r.sanitized.theme).toBe('light');
expect(r.sanitized.readerFontSize).toBe(18);
});
it('任一字段非法 → 整个失败', async () => {
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 'huge' });
expect(r.ok).toBe(false);
});
it('非对象 → 报错', async () => {
expect((await validateAndSanitize(null)).ok).toBe(false);
expect((await validateAndSanitize('str')).ok).toBe(false);
expect((await validateAndSanitize([])).ok).toBe(false);
});
it('空对象 → 空 sanitized', async () => {
const r = await validateAndSanitize({});
expect(r.ok).toBe(true);
expect(r.sanitized).toEqual({});
});
});
// ------------------------------------------------------------------
// AI 字段的「空串是合法值」契约
//
// settings-dialog.js:303-326「清空 AI 配置」按钮依赖此契约:按钮设
// aiKeyInput.value = '',保存时 diff 检测 '' !== initial.aiApiKey → patch 含
// aiApiKey: '',走 settingsStore.update → settings-schema 校验 → 写盘。
// 若未来 schema 把 '' 改成 null / undefined 或拒绝空串,清空按钮会无声失效。
// ------------------------------------------------------------------
describe('AI 字段空串是合法值(清空按钮依赖此契约)', () => {
it('aiApiKey: "" → 接受且保留为空串', async () => {
const r = await validateAndSanitize({ aiApiKey: '' });
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('');
});
it('aiBaseUrl / aiModel / aiSystemPrompt 同时清空 → 全部接受', async () => {
const r = await validateAndSanitize({
aiApiKey: '',
aiBaseUrl: '',
aiModel: '',
aiSystemPrompt: '',
});
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('');
expect(r.sanitized.aiBaseUrl).toBe('');
expect(r.sanitized.aiModel).toBe('');
expect(r.sanitized.aiSystemPrompt).toBe('');
});
it('aiApiKey: 任意非空串 → 接受且原样保留', async () => {
const r = await validateAndSanitize({ aiApiKey: 'sk-abc-123' });
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('sk-abc-123');
});
});
// ------------------------------------------------------------------
// nullable-string 类型支持
//
// audit fixvalidateKey / sanitizeSync 的 switch 之前没有 nullable-string
// case遇到该类型的字符串值会落到 default → 「类型未定义」错误。
// 当前 schema 没有 nullable-string 字段aiApiKey 等用 string + 主进程判空),
// 这里用代码存在性测试锁定修复,避免未来回归。
// ------------------------------------------------------------------
describe('nullable-string 类型支持audit fix', () => {
const source = readFileSync(SCHEMA_PATH, 'utf8');
it('validateKey 的 switch 含 nullable-string case', () => {
// 提取 validateKey 函数体的 switch 段,断言其中含 case 'nullable-string'
const match = source.match(/async function validateKey[\s\S]*?^}/m);
expect(match, 'validateKey 函数必须存在').toBeTruthy();
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
});
it('sanitizeSync 的 switch 含 nullable-string case', () => {
// sanitizeSync 没有 async/function 关键字前缀 —— 直接匹配函数签名
const match = source.match(/function sanitizeSync[\s\S]*?^}/m);
expect(match, 'sanitizeSync 函数必须存在').toBeTruthy();
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
});
it('nullable-string 校验含 max 长度检查(与 string 一致)', () => {
const validateMatch = source.match(/async function validateKey[\s\S]*?^}/m);
expect(validateMatch).toBeTruthy();
// nullable-string case 内必须包含 rule.max 长度检查
const nullableCase = validateMatch[0].match(/case\s+['"]nullable-string['"]\s*:[\s\S]*?(?=\n\s+case\s|\n\s+default|\n\s+\})/);
expect(nullableCase, 'validateKey 必须有 nullable-string case 体').toBeTruthy();
expect(nullableCase[0]).toMatch(/rule\.max/);
});
});
// ------------------------------------------------------------------
// Round 4 P1-1nullable-path 同步路径空串归一为 null
//
// 之前 sanitizeSync 直接返回 '',与 validateKey 异步路径归一为 null 不同步。
// coerceLoadedSettings 把磁盘上残留的 "dataDir": "" 保留为 '',但 validateKey
// 会归一为 null → 任何依赖 dataDir === null 判断的代码失配。
// ------------------------------------------------------------------
describe('nullable-path 同步路径空串归一为 nullRound 4 P1-1', () => {
it('coerceLoadedSettings 把 dataDir: "" 归一为 null', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '' });
expect(result.dataDir).toBe(null);
});
it('coerceLoadedSettings 把 dataDir: " "(纯空白)归一为 null', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: ' ' });
expect(result.dataDir).toBe(null);
});
it('coerceLoadedSettings 把 dataDir: "/some/path" 原样保留', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '/some/path' });
expect(result.dataDir).toBe('/some/path');
});
it('dataDir: null 仍是 null不破坏既有契约', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: null });
expect(result.dataDir).toBe(null);
});
});
// ------------------------------------------------------------------
// Round 4 P2-3nullable-number typeof 守卫对称
//
// 原版无 typeof 守卫Number(true) === 1 隐式通过 isFinite。
// 当前 schema 用 nullable-number 的字段sidebarWidth / aiWidthmin 检查
// 会拦下 1但语义与 number 不一致 —— 加 typeof 守卫保持两条路径对称。
// ------------------------------------------------------------------
describe('nullable-number typeof 守卫对称Round 4 P2-3', () => {
it('sidebarWidth: true → 拒绝typeof 不匹配)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: true });
// sidebarWidth 不在 coerce 结果里(或保留 default绝不能是 1
expect(r.sidebarWidth).not.toBe(1);
});
it('sidebarWidth: false → 拒绝', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: false });
expect(r.sidebarWidth).not.toBe(0);
});
it('sidebarWidth: 250 → 接受', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: 250 });
expect(r.sidebarWidth).toBe(250);
});
it('sidebarWidth: "300" → 接受(字符串数字合法)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: '300' });
expect(r.sidebarWidth).toBe(300);
});
it('sidebarWidth: null → 仍是 null', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null });
expect(r.sidebarWidth).toBe(null);
});
});

View File

@@ -0,0 +1,373 @@
// src/shortcuts.js 测试
//
// 覆盖:
// - 8 个全局快捷键派发到对应 handler
// - 编辑器内(.cm-editor 内部)让位给 CM6Ctrl+F / Ctrl+S / Ctrl+Shift+A
// - 模态打开时吞掉特定快捷键(避免抢焦点 / 顶替 dialog
// - Esc 在搜索框内 → 清空搜索
// - non-modifier 按键不触发
//
// 用 jsdom 模拟 document.addEventListener('keydown')。
// modal-stack 用真实模块(之前 modal.test.js 已验证行为稳定)。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mountKeyboardShortcuts } from '../../src/shortcuts.js';
import { mountModal } from '../../src/modal.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
function fireKey(opts) {
// 默认值Ctrl 修饰target = document.body
const init = {
key: 'f',
ctrlKey: true,
bubbles: true,
cancelable: true,
...opts,
};
const target = opts.target || document.body;
const ev = new KeyboardEvent('keydown', init);
target.dispatchEvent(ev);
return ev;
}
describe('shortcuts 基本派发', () => {
let handlers;
let detach;
beforeEach(() => {
document.body.innerHTML = '<input id="search-input" />';
handlers = {
onFocusSearch: vi.fn(),
onClearSearch: vi.fn(),
onSettings: vi.fn(),
onToggleTheme: vi.fn(),
onSave: vi.fn(),
onToggleEditorMode: vi.fn(),
onNewFile: vi.fn(),
onToggleFocusMode: vi.fn(),
onToggleAi: vi.fn(),
onReload: vi.fn(),
};
detach = mountKeyboardShortcuts(handlers);
});
afterEach(() => {
if (typeof detach === 'function') detach();
});
it('Ctrl+F → onFocusSearch不在编辑器内', () => {
fireKey({ key: 'f', ctrlKey: true });
expect(handlers.onFocusSearch).toHaveBeenCalledTimes(1);
expect(handlers.onFocusSearch.mock.invocationCallOrder[0])
.toBeLessThan(Number.MAX_SAFE_INTEGER);
});
it('Ctrl+, → onSettings', () => {
fireKey({ key: ',', ctrlKey: true });
expect(handlers.onSettings).toHaveBeenCalledTimes(1);
});
it('Ctrl+Shift+T → onToggleTheme', () => {
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleTheme).toHaveBeenCalledTimes(1);
fireKey({ key: 't', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleTheme).toHaveBeenCalledTimes(2);
});
it('Ctrl+S → onSave非编辑器内', () => {
fireKey({ key: 's', ctrlKey: true });
expect(handlers.onSave).toHaveBeenCalledTimes(1);
});
it('Ctrl+E → onToggleEditorMode', () => {
fireKey({ key: 'e', ctrlKey: true });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
});
it('Ctrl+N → onNewFile', () => {
fireKey({ key: 'n', ctrlKey: true });
expect(handlers.onNewFile).toHaveBeenCalledTimes(1);
});
it('Ctrl+Shift+F → onToggleFocusMode', () => {
fireKey({ key: 'F', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleFocusMode).toHaveBeenCalledTimes(1);
});
it('Ctrl+Shift+A → onToggleAi', () => {
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleAi).toHaveBeenCalledTimes(1);
});
// audit K1-H3README 提到 Ctrl+R 重新加载界面但之前未注册。
// 补:注册到 onReload handler与主菜单 reload 同源)。
it('Ctrl+R → onReload', () => {
fireKey({ key: 'r', ctrlKey: true });
expect(handlers.onReload).toHaveBeenCalledTimes(1);
// 大写 R 也应命中Shift 不会被按,但 key.toLowerCase() 仍可工作)
fireKey({ key: 'R', ctrlKey: true });
expect(handlers.onReload).toHaveBeenCalledTimes(2);
});
it('非修饰键 → 不触发任何 handler', () => {
fireKey({ key: 'a', ctrlKey: false, metaKey: false });
for (const [name, fn] of Object.entries(handlers)) {
expect(fn, name).not.toHaveBeenCalled();
}
});
it('Cmd+FmacOS → onFocusSearchmetaKey 视为 ctrl', () => {
fireKey({ key: 'f', ctrlKey: false, metaKey: true });
expect(handlers.onFocusSearch).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts 编辑器内让位', () => {
let handlers;
beforeEach(() => {
// 构造编辑器内部 DOM.cm-editor 容器包裹 .cm-content
document.body.innerHTML = `
<div class="cm-editor">
<div class="cm-content" contenteditable="true">hello world</div>
</div>
`;
handlers = {
onFocusSearch: vi.fn(),
onSave: vi.fn(),
onToggleAi: vi.fn(),
onSettings: vi.fn(),
onToggleEditorMode: vi.fn(),
};
mountKeyboardShortcuts(handlers);
});
it('Ctrl+F 在编辑器内 → 不抢焦点(让位 CM6 搜索面板)', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 'f', ctrlKey: true, target: cm });
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
});
it('Ctrl+S 在编辑器内 → 不触发 onSaveCM6 已 save', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 's', ctrlKey: true, target: cm });
expect(handlers.onSave).not.toHaveBeenCalled();
});
it('Ctrl+Shift+A 在编辑器内 → 不抢Shift+A 在编辑器里有内置语义)', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 'A', ctrlKey: true, shiftKey: true, target: cm });
expect(handlers.onToggleAi).not.toHaveBeenCalled();
});
it('Ctrl+E 在编辑器内 → 仍然生效CM6 未占用)', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 'e', ctrlKey: true, target: cm });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts 模态打开时吞键', () => {
let handlers;
beforeEach(() => {
document.body.innerHTML = `
<div id="modal-root"></div>
<input id="search-input" />
`;
handlers = {
onFocusSearch: vi.fn(),
onSettings: vi.fn(),
onToggleTheme: vi.fn(),
onToggleEditorMode: vi.fn(),
onNewFile: vi.fn(),
onToggleFocusMode: vi.fn(),
onToggleAi: vi.fn(),
onSave: vi.fn(),
};
mountKeyboardShortcuts(handlers);
});
afterEach(() => {
// 关闭所有可能残留的 modal
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
it('模态打开时 Ctrl+, → 不打开设置(避免顶替当前 dialog', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
expect(modalIsOpen()).toBe(true);
fireKey({ key: ',', ctrlKey: true });
expect(handlers.onSettings).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+F → 不抢焦点', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 'f', ctrlKey: true });
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+Shift+T → 不切主题', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleTheme).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+Shift+A → 不切 AI dock', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleAi).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+S 仍允许(与编辑器保存一致)', () => {
// 验证「Ctrl+S 在模态内不禁用」这条契约;具体走哪个分支不重要,
// 关键是 onSave 仍会被调用(设置对话框 / 文件新建 dialog 关闭时不丢改动)。
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 's', ctrlKey: true });
expect(handlers.onSave).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts Esc 清空搜索', () => {
let handlers;
beforeEach(() => {
document.body.innerHTML = '<input id="search-input" />';
handlers = {
onClearSearch: vi.fn(),
onFocusSearch: vi.fn(),
};
mountKeyboardShortcuts(handlers);
});
it('焦点在 search-input 时按 Esc → onClearSearch', () => {
const input = document.getElementById('search-input');
input.focus();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(handlers.onClearSearch).toHaveBeenCalledTimes(1);
});
it('焦点不在搜索框时按 Esc → 不触发 onClearSearch', () => {
document.body.focus();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(handlers.onClearSearch).not.toHaveBeenCalled();
});
});
describe('shortcuts handler 缺失时静默', () => {
it('不传 onSettings 也不应抛错', () => {
document.body.innerHTML = '';
mountKeyboardShortcuts({}); // 全空
expect(() => {
fireKey({ key: ',', ctrlKey: true });
fireKey({ key: 'f', ctrlKey: true });
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
fireKey({ key: 'F', ctrlKey: true, shiftKey: true });
fireKey({ key: 's', ctrlKey: true });
fireKey({ key: 'e', ctrlKey: true });
fireKey({ key: 'n', ctrlKey: true });
fireKey({ key: 'O', ctrlKey: true, shiftKey: true });
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
}).not.toThrow();
});
});
describe('shortcuts IME 合成期间C3 audit', () => {
let handlers;
let detach;
beforeEach(() => {
document.body.innerHTML = '';
handlers = {
onToggleEditorMode: vi.fn(),
onNewFile: vi.fn(),
onFocusSearch: vi.fn(),
onSave: vi.fn(),
};
detach = mountKeyboardShortcuts(handlers);
});
afterEach(() => {
if (detach && typeof detach.dispose === 'function') detach.dispose();
});
it('isComposing=true 时 Ctrl+E 不触发 onToggleEditorModeCJK 拼音中途保护)', () => {
fireKey({ key: 'e', ctrlKey: true, isComposing: true });
expect(handlers.onToggleEditorMode).not.toHaveBeenCalled();
});
it('keyCode=229Firefox / 旧 Chromium时 Ctrl+N 不触发 onNewFile', () => {
fireKey({ key: 'n', ctrlKey: true, keyCode: 229 });
expect(handlers.onNewFile).not.toHaveBeenCalled();
});
it('isComposing=true 时 Ctrl+F 也不抢焦点', () => {
fireKey({ key: 'f', ctrlKey: true, isComposing: true });
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
});
it('isComposing=false 时(正常组合键)行为不变', () => {
fireKey({ key: 'e', ctrlKey: true, isComposing: false });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts 模态打开时 Ctrl+W 抑制M1 audit', () => {
let handlers;
let detach;
let modalApi;
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
handlers = { onSettings: vi.fn() };
detach = mountKeyboardShortcuts(handlers);
// 模拟有模态打开
modalApi = mountModal({ title: 't', body: '', footer: '', onBackdropClose: 'cancel' });
});
afterEach(() => {
if (detach && typeof detach.dispose === 'function') detach.dispose();
if (modalApi) modalApi.close('test-cleanup');
});
it('模态打开时 Ctrl+W 不触发默认行为preventDefault', () => {
const ev = fireKey({ key: 'w', ctrlKey: true });
expect(ev.defaultPrevented).toBe(true);
});
it('模态未开时 Ctrl+W 不在抑制列表里normal 行为:不被 preventDefault', () => {
if (modalApi) modalApi.close('test-no-modal');
modalApi = null;
const ev = fireKey({ key: 'w', ctrlKey: true });
// 不被任何 handler 拦截 → defaultPrevented 应该是 false由浏览器处理关窗口
expect(ev.defaultPrevented).toBe(false);
});
});
describe('shortcuts disposePhase O-M2 audit', () => {
it('mountKeyboardShortcuts 返回的 dispose 函数能 removeEventListener', () => {
document.body.innerHTML = '<input id="search-input" />';
const handler1 = vi.fn();
const detach1 = mountKeyboardShortcuts({
onSettings: handler1,
});
expect(typeof detach1).toBe('function');
// 销毁第一个 mount
detach1();
// 再 mount 第二个
const handler2 = vi.fn();
const detach2 = mountKeyboardShortcuts({
onSettings: handler2,
});
// 触发 Ctrl+,
fireKey({ key: ',', ctrlKey: true });
// 旧 handler 已经被 dispose → 不应被调用
expect(handler1).not.toHaveBeenCalled();
// 新 handler 应当被调用
expect(handler2).toHaveBeenCalledTimes(1);
detach2();
});
});

125
tests/unit/slug.test.js Normal file
View File

@@ -0,0 +1,125 @@
// shared/slug.js 的单测
//
// 这是 preload.js 与 src/outline.js 共用的 heading slug 算法唯一事实源。
// 改算法 = 同步改这里 + preload.js + src/outline.js + tests/unit/outline.test.js。
import { describe, it, expect } from 'vitest';
import {
slugifyHeadingBase,
slugifyHeading,
} from '../../shared/slug.js';
describe('slugifyHeadingBase', () => {
it('空字符串 → 空串slugifyHeading 才回退 section', () => {
expect(slugifyHeadingBase('')).toBe('');
});
it('空白字符串 → 空串', () => {
expect(slugifyHeadingBase(' ')).toBe('');
});
it('纯符号 → 空串(兜底由带去重的 slugifyHeading 提供)', () => {
// 这里只测「基础 slug」——空结果由调用方决定是跳过还是兜底为 'section'。
expect(slugifyHeadingBase('!@#$')).toBe('');
});
it('基础小写化 + 空格变 -', () => {
expect(slugifyHeadingBase('Hello World')).toBe('hello-world');
});
it('CJK 保留(基本平面 0x4E00-0x9FFF', () => {
expect(slugifyHeadingBase('你好世界')).toBe('你好世界');
});
it('下划线被洗掉_ 在 [`*_~] 字符集里,连同行内标点一起剥)', () => {
expect(slugifyHeadingBase('hello_world')).toBe('helloworld');
expect(slugifyHeadingBase('foo_bar_baz')).toBe('foobarbaz');
});
it('行内 HTML 标签被剥离', () => {
expect(slugifyHeadingBase('Hello <em>x</em>')).toBe('hello-x');
expect(slugifyHeadingBase('<span class="x">A</span> B')).toBe('a-b');
});
it('行内 markdown 标记符号被剥离', () => {
expect(slugifyHeadingBase('Hello *World*')).toBe('hello-world');
expect(slugifyHeadingBase('Hello `code`')).toBe('hello-code');
expect(slugifyHeadingBase('Hello _em_')).toBe('hello-em');
expect(slugifyHeadingBase('Hello ~~del~~')).toBe('hello-del');
});
it('连续空白合并为单个 -', () => {
expect(slugifyHeadingBase('foo bar baz')).toBe('foo-bar-baz');
expect(slugifyHeadingBase(' a b ')).toBe('a-b');
});
it('标点 / 引号被洗掉', () => {
expect(slugifyHeadingBase('A & B!')).toBe('a-b');
expect(slugifyHeadingBase('foo "bar" baz')).toBe('foo-bar-baz');
expect(slugifyHeadingBase('foo/bar')).toBe('foobar');
expect(slugifyHeadingBase('foo:bar')).toBe('foobar');
});
it('首尾 - 不裁剪(与 marked 旧默认一致)', () => {
expect(slugifyHeadingBase('--foo--bar--')).toBe('--foo--bar--');
});
it('数字开头保留', () => {
expect(slugifyHeadingBase('123 abc')).toBe('123-abc');
});
it('组合记号保留(拉丁语族 / 越南语等)', () => {
// \p{M} 应保留 combining marks
expect(slugifyHeadingBase('café')).toBe('café');
expect(slugifyHeadingBase('naïve résumé')).toBe('naïve-résumé');
});
it('非字符串输入用 toString 兜底(不抛错)', () => {
// 数字正常字符串化null/undefined 在 String() 后是 'null'/'undefined'
// 不会触发 section 兜底——因为这里只看 trim 后的非空判断。
expect(slugifyHeadingBase(123)).toBe('123');
expect(slugifyHeadingBase(null)).toBe('null');
expect(slugifyHeadingBase(undefined)).toBe('undefined');
});
});
describe('slugifyHeading 重复检测', () => {
it('同名 heading 加 -1 / -2 后缀', () => {
const seen = new Set();
expect(slugifyHeading('Same', seen)).toBe('same');
expect(slugifyHeading('Same', seen)).toBe('same-1');
expect(slugifyHeading('Same', seen)).toBe('same-2');
});
it('带标记的同名也会撞后缀(标记先剥)', () => {
const seen = new Set();
expect(slugifyHeading('Title', seen)).toBe('title');
// 同样算 'title' → 撞 'title'(已存在),得 'title-1'
expect(slugifyHeading('**Title**', seen)).toBe('title-1');
// 'Title *bold*' 剥标记 + 小写化后变 'title bold' → 'title-bold',与 'title' 不同
expect(slugifyHeading('Title *bold*', seen)).toBe('title-bold');
});
it('空 Set / 清空后重置计数', () => {
const seen = new Set();
slugifyHeading('foo', seen);
slugifyHeading('foo', seen);
seen.clear();
expect(slugifyHeading('foo', seen)).toBe('foo');
});
it('不同 slug 不互相干扰', () => {
const seen = new Set();
expect(slugifyHeading('A', seen)).toBe('a');
expect(slugifyHeading('B', seen)).toBe('b');
expect(slugifyHeading('A', seen)).toBe('a-1');
expect(slugifyHeading('B', seen)).toBe('b-1');
});
it('空 base 在带去重的 slugifyHeading 里回退为 section', () => {
// 纯符号 heading 走 slugifyHeading 也能得到非空 id
const seen = new Set();
expect(slugifyHeading('!@#$', seen)).toBe('section');
expect(slugifyHeading('!!!', seen)).toBe('section-1');
});
});

125
tests/unit/stats.test.js Normal file
View File

@@ -0,0 +1,125 @@
// Stage 7 tests: stats.js
//
// 覆盖:
// - countWordsCJK 单字 + 拉丁词 + 混合 + 边界
// - getTextStatschars / words / lines 三个字段 + 空文档边界
// - formatStatusStats中文 locale + 0/1/N 行
import { describe, it, expect } from 'vitest';
import { countWords, getTextStats, formatStatusStats } from '../../src/stats.js';
describe('countWords', () => {
it('空字符串 → 0', () => {
expect(countWords('')).toBe(0);
});
it('纯空白 → 0', () => {
expect(countWords(' \n \t')).toBe(0);
});
it('null / undefined → 0', () => {
expect(countWords(null)).toBe(0);
expect(countWords(undefined)).toBe(0);
});
it('非字符串 → 0', () => {
expect(countWords(42)).toBe(0);
expect(countWords({})).toBe(0);
});
it('纯拉丁词按空格分', () => {
expect(countWords('hello world foo bar')).toBe(4);
});
it('中文每个字算 1', () => {
expect(countWords('你好世界')).toBe(4);
});
it('中日韩混合', () => {
expect(countWords('Hello 你好 World 世界')).toBe(6); // 4 CJK + 2 latin
});
it('日文假名 + 韩文', () => {
expect(countWords('こんにちは 안녕하세요')).toBe(10); // 5 hiragana + 5 hangul
});
it('标点不计数', () => {
expect(countWords('!!! ??? ...')).toBe(0);
});
it('「hello-world」是一个词', () => {
expect(countWords('hello-world')).toBe(1);
});
it('多行', () => {
expect(countWords('first line\nsecond line\nthird line')).toBe(6);
});
});
describe('getTextStats', () => {
it('空字符串 → chars 0, words 0, lines 0', () => {
// audit L8空内容不再用 `|| 1` 兜底为 1 行。状态栏会显示「0 词」,
// 避免「刚打开一个空白文件就看到『1 行』误导」。
expect(getTextStats('')).toEqual({ chars: 0, words: 0, lines: 0 });
});
it('纯空白 → lines 0', () => {
const stats = getTextStats(' \n \n ');
expect(stats.lines).toBe(0);
expect(stats.words).toBe(0);
});
it('单行内容', () => {
const stats = getTextStats('hello world');
expect(stats).toEqual({ chars: 11, words: 2, lines: 1 });
});
it('多行非空内容', () => {
const stats = getTextStats('line one\nline two\nline three');
expect(stats.lines).toBe(3);
expect(stats.words).toBe(6);
expect(stats.chars).toBe(28); // 8+1+8+1+10
});
it('空行不计数', () => {
const stats = getTextStats('hello\n\n\nworld');
expect(stats.lines).toBe(2);
expect(stats.words).toBe(2);
});
it('null / 非字符串 → 默认值', () => {
expect(getTextStats(null)).toEqual({ chars: 0, words: 0, lines: 0 });
expect(getTextStats(42)).toEqual({ chars: 0, words: 0, lines: 0 });
});
it('CJK 内容', () => {
const stats = getTextStats('你好世界\n欢迎使用');
expect(stats.words).toBe(8);
expect(stats.lines).toBe(2);
expect(stats.chars).toBe('你好世界\n欢迎使用'.length);
});
});
describe('formatStatusStats', () => {
it('lines > 0 → 「N 词 · M 行」', () => {
expect(formatStatusStats({ words: 100, lines: 5 })).toBe('100 词 · 5 行');
});
it('lines === 0 → 「N 词」', () => {
expect(formatStatusStats({ words: 100, lines: 0 })).toBe('100 词');
});
it('千分位分隔zh-CN locale', () => {
expect(formatStatusStats({ words: 1234, lines: 5 })).toBe('1,234 词 · 5 行');
expect(formatStatusStats({ words: 12345, lines: 5 })).toBe('12,345 词 · 5 行');
});
it('0 词 → 「0 词」', () => {
expect(formatStatusStats({ words: 0, lines: 0 })).toBe('0 词');
});
it('缺字段时 fallback', () => {
expect(formatStatusStats({})).toBe('0 词');
expect(formatStatusStats({ words: 10 })).toBe('10 词');
});
});

366
tests/unit/utils.test.js Normal file
View File

@@ -0,0 +1,366 @@
// src/utils.js 的纯函数测试
// 当前覆盖 isMarkdownFile —— 这是 Stage 8 引入的工具,
// 决定一个文件是否能渲染 Markdown 预览(仅 .md / .markdown
import { describe, it, expect } from 'vitest';
import {
isMarkdownFile,
isPathInside,
pathsEqual,
formatSize,
formatDate,
formatRelativeTime,
escapeHtml,
escapeAttr,
dirnameOf,
basenameOf,
resolvePath,
toFileUrl,
safeRenderMarkdown,
} from '../../src/utils.js';
describe('isPathInside', () => {
it('基本包含关系 → true', () => {
expect(isPathInside('/data/notes/foo.md', '/data/notes')).toBe(true);
expect(isPathInside('/data/notes/sub/foo.md', '/data/notes')).toBe(true);
});
it('完全相等的路径 → true自身视为在内', () => {
expect(isPathInside('/data/notes', '/data/notes')).toBe(true);
expect(isPathInside('/data/notes/', '/data/notes')).toBe(true);
expect(isPathInside('/data/notes', '/data/notes/')).toBe(true);
});
it('前缀相同但不是子目录(避免 /foo 包含 /foobar → false', () => {
expect(isPathInside('/data/foobar.md', '/data/foo')).toBe(false);
});
it('完全不同的路径 → false', () => {
expect(isPathInside('/etc/passwd', '/data/notes')).toBe(false);
expect(isPathInside('/data/foo.md', '/var/log')).toBe(false);
});
it('parent 是 POSIX 根目录 / → 任何非空 child 都视为在内', () => {
// 修复点:旧版本 norm('/') → '',导致 a === '' 与任何 child 都不等、b.startsWith('/') 又为 false
// 误判「根目录下没有路径」。现在显式把空前缀视为「任何子路径都在内」。
expect(isPathInside('/foo/bar', '/')).toBe(true);
expect(isPathInside('/a', '/')).toBe(true);
expect(isPathInside('/notes/x.md', '/')).toBe(true);
});
it('parent 是 Windows 根盘符 C:\\ → 任何非空 child 都视为在内jsdom/Windows 测试场景)', () => {
// 这里没法在 node 里真实触发 isWindows(),但 norm 之后变成 'c:' 是确定的:
// 直接构造「模拟 Windows」路径。算法只看规范化结果不依赖平台检测。
// 这一档测试对应 norm(parent) === 'c:' 的分支。
expect(isPathInside('C:\\Users\\foo\\note.md', 'C:\\')).toBe(true);
});
it('空 child / 空 parent / null → false', () => {
expect(isPathInside('', '/data')).toBe(false);
expect(isPathInside('/data/foo.md', '')).toBe(false);
expect(isPathInside(null, '/data')).toBe(false);
expect(isPathInside('/data/foo.md', null)).toBe(false);
expect(isPathInside(undefined, undefined)).toBe(false);
});
it('child 仅有分隔符(规范化后为空)→ false', () => {
// 修复点:避免「//」或「/」被当成合法 child 漏进根目录
expect(isPathInside('/', '/data')).toBe(false);
expect(isPathInside('//', '/data')).toBe(false);
});
it('混合路径分隔符(正反斜杠)→ 视为相同', () => {
// norm 把所有 \\ → /,所以 'C:\\data' 与 'C:/data' 等价
// node 测试环境下 isWindows 返回 false不会 lowercase
// 这一档主要验证 norm 的归一化效果)
expect(isPathInside('/data/notes/foo.md', '\\data\\notes')).toBe(true);
});
});
describe('pathsEqual', () => {
it('两个相同字符串 → true', () => {
expect(pathsEqual('/a/b/c', '/a/b/c')).toBe(true);
expect(pathsEqual('C:\\Users\\foo', 'C:\\Users\\foo')).toBe(true);
});
it('空值 / null / undefined → false仅双空相等视为 false', () => {
expect(pathsEqual('', '/a')).toBe(false);
expect(pathsEqual('/a', '')).toBe(false);
expect(pathsEqual(null, '/a')).toBe(false);
expect(pathsEqual('/a', undefined)).toBe(false);
});
it('路径内容不同 → false', () => {
expect(pathsEqual('/a/b', '/a/c')).toBe(false);
expect(pathsEqual('C:\\foo', 'C:\\bar')).toBe(false);
});
// Windows case-fold 行为jsdom 25 默认 userAgent 是 'Mozilla/5.0 (win32)'
// → isWindows() 返回 true。所以两条 case 不同的路径在测试环境下应判等。
// 真实的运行环境Windows / POSIX效果以 isPathInside 的 Windows 段已经覆盖的
// 「跨斜杠 + 大小写不敏感」原则一致 —— 不为「假想平台」造用例。
it('Windows 路径大小写不敏感case-fold 后相等 → true', () => {
// jsdom 默认 mock 是 win32 userAgent → 这条断言测的是真实运行行为
expect(pathsEqual('C:\\Users\\Foo\\note.md', 'c:\\users\\foo\\note.md')).toBe(true);
expect(pathsEqual('/A/b.md', '/a/b.md')).toBe(true);
});
});
describe('isMarkdownFile', () => {
it('扩展名为 .md → true', () => {
expect(isMarkdownFile('note.md')).toBe(true);
expect(isMarkdownFile('/abs/path/to/note.md')).toBe(true);
expect(isMarkdownFile('C:\\Users\\foo\\notes\\note.md')).toBe(true);
});
it('扩展名为 .markdown → true', () => {
expect(isMarkdownFile('README.markdown')).toBe(true);
expect(isMarkdownFile('/path/to/README.markdown')).toBe(true);
});
it('大小写不敏感', () => {
expect(isMarkdownFile('Note.MD')).toBe(true);
expect(isMarkdownFile('Note.Md')).toBe(true);
expect(isMarkdownFile('Note.Markdown')).toBe(true);
});
it('其它可编辑扩展 → false仅支持编辑不支持预览', () => {
expect(isMarkdownFile('data.json')).toBe(false);
expect(isMarkdownFile('script.py')).toBe(false);
expect(isMarkdownFile('config.yaml')).toBe(false);
expect(isMarkdownFile('plain.txt')).toBe(false);
expect(isMarkdownFile('main.js')).toBe(false);
expect(isMarkdownFile('main.ts')).toBe(false);
expect(isMarkdownFile('styles.css')).toBe(false);
expect(isMarkdownFile('page.html')).toBe(false);
});
it('无扩展名 / 隐藏文件 / 空字符串 → false', () => {
expect(isMarkdownFile('')).toBe(false);
expect(isMarkdownFile('.gitignore')).toBe(false);
expect(isMarkdownFile('Makefile')).toBe(false);
expect(isMarkdownFile(null)).toBe(false);
expect(isMarkdownFile(undefined)).toBe(false);
expect(isMarkdownFile(42)).toBe(false);
});
it('以 .md 结尾但不是扩展名(如 .md5、.model→ false', () => {
// 防御:'foo.model' 的 lastIndexOf('.') 是 'model' 里的点,但我们按最后一个点判断,
// 应该是 'l',所以 false。'foo.md5' 最后一个点是 '.md5' 之前?还是…… 看实现:
// path.lastIndexOf('.') → 找到最后一个 '.' 的位置。对 'foo.md5'dot 在 3
// 取 dot+1 之后到末尾 'md5',不是集合里的 md。
expect(isMarkdownFile('photo.md5')).toBe(false);
expect(isMarkdownFile('archive.model')).toBe(false);
});
it('多点文件名note.v2.md、a.b.c.markdown→ true', () => {
// basename 末段的最后一个点才是扩展名分隔
expect(isMarkdownFile('note.v2.md')).toBe(true);
expect(isMarkdownFile('a.b.c.markdown')).toBe(true);
});
it('无扩展名的常见文件名 → false', () => {
expect(isMarkdownFile('README')).toBe(false);
expect(isMarkdownFile('LICENSE')).toBe(false);
expect(isMarkdownFile('/abs/path/Makefile')).toBe(false);
});
});
describe('formatSize', () => {
it('字节 / KB / MB 分级', () => {
expect(formatSize(0)).toBe('0 B');
expect(formatSize(512)).toBe('512 B');
expect(formatSize(1024)).toBe('1.0 KB');
expect(formatSize(1536)).toBe('1.5 KB');
expect(formatSize(1024 * 1024)).toBe('1.0 MB');
expect(formatSize(1024 * 1024 * 2.5)).toBe('2.5 MB');
});
it('非法输入 → 占位符', () => {
expect(formatSize(-1)).toBe('—');
expect(formatSize(NaN)).toBe('—');
expect(formatSize('100')).toBe('—');
expect(formatSize(null)).toBe('—');
});
});
describe('formatDate', () => {
it('YYYY-MM-DD HH:mm', () => {
// 用本地时区无关的方式构造:直接取 0 时刻附近
const d = new Date(2024, 0, 15, 9, 5); // 2024-01-15 09:05 本地
expect(formatDate(d.getTime())).toBe('2024-01-15 09:05');
});
it('非法输入 → 空串', () => {
expect(formatDate(NaN)).toBe('');
expect(formatDate('xxx')).toBe('');
expect(formatDate(null)).toBe('');
});
});
describe('formatRelativeTime', () => {
const now = new Date(2024, 5, 15, 12, 0, 0).getTime(); // 2024-06-15 12:00:00
it('< 60s → "刚刚"', () => {
expect(formatRelativeTime(now - 5_000, now)).toBe('刚刚');
expect(formatRelativeTime(now - 59_000, now)).toBe('刚刚');
});
it('< 60min → "N 分钟前"', () => {
expect(formatRelativeTime(now - 60_000, now)).toBe('1 分钟前');
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5 分钟前');
expect(formatRelativeTime(now - 59 * 60_000, now)).toBe('59 分钟前');
});
it('同日 < 24h → "N 小时前"', () => {
expect(formatRelativeTime(now - 60 * 60_000, now)).toBe('1 小时前');
expect(formatRelativeTime(now - 3 * 60 * 60_000, now)).toBe('3 小时前');
});
it('昨天 → "昨天"', () => {
const yesterday = now - 25 * 60 * 60_000; // 跨过 0 点
expect(formatRelativeTime(yesterday, now)).toBe('昨天');
});
it('< 7d → "N 天前"', () => {
expect(formatRelativeTime(now - 3 * 86400_000, now)).toBe('3 天前');
expect(formatRelativeTime(now - 6 * 86400_000, now)).toBe('6 天前');
});
it('≥ 7d → 回落到绝对时间', () => {
const old = new Date(2024, 5, 1, 12, 0, 0).getTime();
const got = formatRelativeTime(old, now);
// 应该跟 formatDate(old) 一致
expect(got).toBe(formatDate(old));
expect(got).toBe('2024-06-01 12:00');
});
it('未来时间 → 当成 0 差("刚刚"', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('刚刚');
});
it('非法输入 → 空串', () => {
expect(formatRelativeTime(NaN, now)).toBe('');
expect(formatRelativeTime('xxx', now)).toBe('');
expect(formatRelativeTime(null, now)).toBe('');
expect(formatRelativeTime(undefined, now)).toBe('');
});
it('非法 nowMs → 退回 Date.now() 不报错', () => {
// 不传 nowMsshould 不抛
expect(typeof formatRelativeTime(now - 1000)).toBe('string');
// 显式传非法 nowMs → 内部用 Date.now() 兜底
expect(typeof formatRelativeTime(now - 1000, NaN)).toBe('string');
});
});
describe('escapeHtml / escapeAttr', () => {
it('escapeHtml 转义五个特殊字符', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
expect(escapeHtml('a & b')).toBe('a &amp; b');
expect(escapeHtml('"x"')).toBe('&quot;x&quot;');
expect(escapeHtml("'x'")).toBe('&#39;x&#39;');
expect(escapeHtml('<a href="x">y</a>')).toBe(
'&lt;a href=&quot;x&quot;&gt;y&lt;/a&gt;'
);
});
it('null/undefined → 空串', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
it('escapeAttr 等价 escapeHtml语义标识而非行为差异', () => {
const inputs = ['<x>', 'a"b', "a'b", 'a&b'];
for (const s of inputs) {
expect(escapeAttr(s)).toBe(escapeHtml(s));
}
});
});
describe('dirnameOf / basenameOf', () => {
it('POSIX 路径', () => {
expect(dirnameOf('/data/notes/foo.md')).toBe('/data/notes');
expect(basenameOf('/data/notes/foo.md')).toBe('foo.md');
});
it('Windows 路径', () => {
expect(dirnameOf('C:\\Users\\foo\\bar.md')).toBe('C:\\Users\\foo');
expect(basenameOf('C:\\Users\\foo\\bar.md')).toBe('bar.md');
});
it('根目录下的文件', () => {
expect(dirnameOf('/foo.md')).toBe('/');
expect(basenameOf('/foo.md')).toBe('foo.md');
});
it('无分隔符的文件名', () => {
expect(dirnameOf('foo.md')).toBe('');
expect(basenameOf('foo.md')).toBe('foo.md');
});
it('非法输入 → 空串', () => {
expect(dirnameOf('')).toBe('');
expect(dirnameOf(null)).toBe('');
expect(basenameOf('')).toBe('');
expect(basenameOf(undefined)).toBe('');
});
});
describe('resolvePath', () => {
it('POSIX 相对路径解析', () => {
expect(resolvePath('/data/notes', 'foo.md')).toBe('/data/notes/foo.md');
expect(resolvePath('/data/notes', 'sub/foo.md')).toBe('/data/notes/sub/foo.md');
});
it('POSIX 绝对路径原样规范化', () => {
expect(resolvePath('/data/notes', '/etc/passwd')).toBe('/etc/passwd');
expect(resolvePath('/data/notes', '/a/./b/../c')).toBe('/a/c');
});
it('折叠 "." 和 ".."', () => {
expect(resolvePath('/data', './foo/./bar.md')).toBe('/data/foo/bar.md');
expect(resolvePath('/data/sub', '../foo.md')).toBe('/data/foo.md');
expect(resolvePath('/data/sub/deep', '../../foo.md')).toBe('/data/foo.md');
});
it('越过根目录 → null', () => {
expect(resolvePath('/data', '../../etc/passwd')).toBe(null);
expect(resolvePath('/', '../foo')).toBe(null);
});
it('Windows 盘符路径', () => {
expect(resolvePath('C:\\data', 'foo.md')).toBe('C:\\data\\foo.md');
expect(resolvePath('C:\\data', 'C:\\foo.md')).toBe('C:\\foo.md');
});
it('空 / 非法输入 → null', () => {
expect(resolvePath('/data', '')).toBe(null);
expect(resolvePath('/data', null)).toBe(null);
expect(resolvePath('/data', undefined)).toBe(null);
});
});
describe('toFileUrl', () => {
it('POSIX 绝对路径', () => {
expect(toFileUrl('/data/notes/foo.md')).toBe('file:///data/notes/foo.md');
});
it('Windows 盘符路径', () => {
expect(toFileUrl('C:\\data\\foo.md')).toBe('file:///C:/data/foo.md');
expect(toFileUrl('C:/data/foo.md')).toBe('file:///C:/data/foo.md');
});
it('文件名含 # / ? / 空格 → 编码但保留盘符冒号', () => {
expect(toFileUrl('/data/has space/foo.md')).toBe('file:///data/has%20space/foo.md');
expect(toFileUrl('/data/has#hash/foo.md')).toBe('file:///data/has%23hash/foo.md');
expect(toFileUrl('/data/has?q=1/foo.md')).toBe('file:///data/has%3Fq%3D1/foo.md');
});
it('空 / 非法输入 → 空串', () => {
expect(toFileUrl('')).toBe('');
expect(toFileUrl(null)).toBe('');
expect(toFileUrl(undefined)).toBe('');
});
});
describe('safeRenderMarkdown', () => {
// markdown-viewer / ai-diff-panel 共用入口:失败 / 不可用时必须
// 返回转义后的 <pre>不能泄漏未转义的原文XSS fail-closed
// node 环境无 window —— 这里跑的就是 "window 不可用" 的降级分支。
it('window 不可用 → 降级为 <pre>,转义危险字符', () => {
const html = safeRenderMarkdown('<script>alert(1)</script>');
expect(html).toContain('<pre');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
});
it('window 不可用 → 纯文本透传', () => {
const html = safeRenderMarkdown('safe text');
expect(html).toContain('<pre');
expect(html).toContain('safe text');
});
it('空 / 非法输入 → <pre> 包空内容', () => {
expect(safeRenderMarkdown('')).toContain('<pre');
expect(safeRenderMarkdown(null)).toContain('<pre');
expect(safeRenderMarkdown(undefined)).toContain('<pre');
});
});

19
vitest.config.js Normal file
View File

@@ -0,0 +1,19 @@
// Vitest 配置Stage 7
//
// 设计:
// - 测试目录tests/unit/** 按模块名就近放stats / editor-theme / modal / ...
// - 默认环境node最快config-store / stats / editor-theme 这些纯函数)
// - 标记 // @vitest-environment jsdom 的文件用 jsdommodal / file-ops 涉及 DOM
// - 不跑 tests/_fixtures/ 之类的辅助文件 —— glob 只覆盖 *.test.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/unit/**/*.test.js'],
// 默认 node 环境;需要 DOM 的文件用 /* @vitest-environment jsdom */ 标记
environment: 'node',
// 测试结束清掉临时文件
clearMocks: true,
},
});