update
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Notes
|
||||
|
||||
桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。
|
||||
|
||||
   
|
||||
|
||||
## 简介
|
||||
|
||||
- **文件即真相**:界面显示什么,磁盘上就是什么。
|
||||
- **保存由你决定**:默认 `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 内打开;`#标题` 锚点文内跳转
|
||||
- 相对路径图片(``)直接渲染
|
||||
- 状态栏实时高亮当前阅读章节
|
||||
- 聚焦模式隐藏工具栏 / 侧栏 / 状态栏(`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 URL:OpenAI 兼容需含 `/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
14
data/welcome.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 欢迎使用 Notes
|
||||
|
||||
这是一个简洁的 **Markdown 阅读器**。
|
||||
|
||||
把 `.md` 文件放进数据文件夹,左侧栏就会自动出现 —— 文件怎么写,界面就怎么显示。
|
||||
|
||||
## 快捷键
|
||||
|
||||
- `Ctrl + F` 搜索文件
|
||||
- `Ctrl + S` 保存
|
||||
- `Ctrl + ,` 打开设置
|
||||
- `Ctrl + Shift + T` 切换主题
|
||||
|
||||
开始写笔记吧。
|
||||
184
eslint.config.js
Normal file
184
eslint.config.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// ESLint flat config
|
||||
// 两个 block:
|
||||
// 1. main / preload / scripts/ —— Node CJS 环境(require / module / process / __dirname)
|
||||
// 2. src/ —— 浏览器 ESM 环境(无 Node globals;importmap 暴露的 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 globals(describe/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',
|
||||
// Electron(main/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 API(main/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(...) 调用
|
||||
},
|
||||
},
|
||||
];
|
||||
524
index.html
Normal file
524
index.html
Normal file
@@ -0,0 +1,524 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
lang="zh-CN" + dir="ltr"(Round 12 P2 audit):CJK 默认 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 style(marked 渲染的代码块可能会用到)
|
||||
- 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 style(marked 渲染的代码块可能会用到)
|
||||
- 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 #2(critical):role="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 Browser(Stage 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 5,cursor 被推到 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
|
||||
让给 viewer,app-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>
|
||||
938
main/ai.js
Normal file
938
main/ai.js
Normal 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.aiErrors,main / 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 后被拼了 path,URL 非法)。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] || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤错误回显里的敏感 token(OpenAI/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 风格 key:sk-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 key:AIzaSy 开头 + 33 字符。33 是 Google 当前规范
|
||||
.replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]')
|
||||
// JWT:header.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-authorization(Q3 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 token(OpenAI 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):先剥 userinfo(https://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 / mock:fallback 到 .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 fix:hostname 校验 —— 之前只校验前缀,`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 不接受自定义 temperature(o1 固定为 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-8(16384)
|
||||
* 更低。结果是「越新、输出上限越高的模型,拿到的 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 类型的 Response,status 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 key(fetch 库 / 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 已经走过 sanitizeDetail,key 类 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_tokens(gpt-5 / o-series 只接受后者)。
|
||||
// o-series / gpt-5 也不接受自定义 temperature(o1 强制为 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 即登记(早于 postJson),finally 清理,覆盖整个请求生命周期。
|
||||
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.aiErrors,renderer 直接 window.api?.aiErrors?.AI_ERROR。
|
||||
// main/ai.js 内部仍用 ERR_* 命名别名(line 59-63),纯粹是阅读性,无外部
|
||||
// 调用方 —— 别名不出 module.exports,避免「两个相同字面值漂移」风险。
|
||||
AI_ERROR,
|
||||
};
|
||||
518
main/config-store.js
Normal file
518
main/config-store.js
Normal 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 个 tmp,rename 失败路径会立刻 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 面板分割条
|
||||
* 这类高频触发会卡 UI(settings-store 在 200ms 防抖后多次调用,每次都阻塞
|
||||
* 几 ms~几十 ms;慢盘 / OneDrive 同步冲突会更糟)。
|
||||
* 2. 多调用并发时,appConfig = {...appConfig, ...next} 在内存里已经合并,
|
||||
* 但只有最后一次 writeFile 落盘;如果中间某次失败,前一次的合并内容丢失
|
||||
* 但 appConfig 还显示「成功」(next 返回合并后的状态)。
|
||||
* 3. catch 只 unlink tmp,appConfig 不回滚 —— 调用方以为保存成功,磁盘实际
|
||||
* 是旧值。
|
||||
*
|
||||
* 新实现:
|
||||
* - 每次 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-08:dataDir 改了 → 让 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.json(rename 前断电),要么是完整新文件。
|
||||
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 → 视为不存在,回退到默认
|
||||
* - 其他 errno(EACCES / EBUSY / EPERM / EIO)→ 视为存在(可能是瞬时 ——
|
||||
* U 盘读权限慢 / Windows Defender 持锁等),让上层 scanDir 自然失败
|
||||
* 而不是「看似可用但其实打开就崩」
|
||||
*
|
||||
* @returns {{ dir: string, fellBack: boolean, saved: string }}
|
||||
* dir:实际可用的目录(默认或 custom)
|
||||
* fellBack:true 表示 custom 路径不可用、临时回退到默认
|
||||
* saved:appConfig.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
694
main/file-ops.js
Normal file
@@ -0,0 +1,694 @@
|
||||
// 文件操作 helper(Stage 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 Browser(2026-08):
|
||||
// - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类
|
||||
// - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary'
|
||||
// - EDITABLE_EXTS(来自 shared/extension-lists.js)是「可打开 + 编辑」
|
||||
// 的扩展名白名单;markdown (.md/.markdown) 与常见纯文本均在内
|
||||
//
|
||||
// 测试:tests/unit/file-ops.test.js(jsdom 环境之外;纯 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 打开目录路径得到 EISDIR,UI 弹「无效参数」
|
||||
* 误导信息。改用 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝 symlink:Notes 用户的工作流是「编辑自己数据目录里的 .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.realpath(symlink 不解);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/EBUSY,UI 弹出
|
||||
* 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。
|
||||
*
|
||||
* 用户反馈(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).md;bar.txt → bar (2).txt;baz(无扩展名) → 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).md;bar.txt → bar (2).txt;baz(无扩展名) → 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 Browser(2026-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 fix(C1/C2 file-IO):
|
||||
* 1. 写到 dst.tmp.<pid>.<now>
|
||||
* 2. fsync tmp(让内容确实落盘,再 rename 才不会丢)
|
||||
* 3. renameWithRetry 覆盖 dst(Windows 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 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了
|
||||
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
|
||||
// FlushFileBuffers,rename 之前必须强制落盘。
|
||||
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.friendlyFsError,renderer 两处旧实现也走同一份)。本函数保留壳子
|
||||
* 是为了不重写所有调用点的语义(main 内部仍叫 friendlyWriteError 表达
|
||||
* 「写盘错误翻译」,renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。
|
||||
*
|
||||
* @param {NodeJS.ErrnoException|null|undefined} e
|
||||
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
|
||||
*/
|
||||
function friendlyWriteError(e) {
|
||||
// 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。
|
||||
// - 已知 errno:shared 模块返回固定中文文案,与 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
500
main/fs-watcher.js
Normal file
@@ -0,0 +1,500 @@
|
||||
// 目录监听层(Stage 4b.2 抽离 + Stage 8 扩展)
|
||||
//
|
||||
// 职责:
|
||||
// - fs.watch + readdir 轮询双通道捕获数据目录变化
|
||||
// - 把变化推送给 renderer(IPC '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 可能 > 2s,setInterval 触发新调用与上一轮 await 并发争写
|
||||
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
|
||||
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
|
||||
let pollInFlight = false;
|
||||
|
||||
function isWindowVisible() {
|
||||
const win = getMainWindow();
|
||||
return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目列表的稳定指纹:folder 用 name;file 用 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');
|
||||
}
|
||||
|
||||
/** 把绝对目录转成 relDir(payload 字段);内部统一走 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-M7:teardown 窗口过程抛 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 token,await 结束后
|
||||
// 与当前 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 8:payload 新增 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 fix:fs.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 可能 > 2s,setInterval 触发新一轮与上一轮 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',没人接,
|
||||
// 默认变 unhandledException;poll 循环还在继续扫这个不存在的目录,
|
||||
// 每次 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
6998
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
package.json
Normal file
106
package.json
Normal 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
|
||||
}
|
||||
}
|
||||
13
patches/app-builder-lib+26.15.3.patch
Normal file
13
patches/app-builder-lib+26.15.3.patch
Normal 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
415
preload.js
Normal 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 这个文件 —— 它是 CommonJS(module.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.js;renderer 端通过 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 通道一个 helper,pagehide 未跑时再拆 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" ');
|
||||
};
|
||||
|
||||
// 标题 id:marked 从 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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
|
||||
// style(color / font-size / background:url(https://...) 等),由
|
||||
// isDangerousStyleValue(render-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 Browser(Stage 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 不传 → dataRoot;initialContent 不传 → 主进程默认生成 `# 标题\n\n`。
|
||||
// 向后兼容:第二参仍是字符串时当作 initialContent,dir 走根。
|
||||
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 = Reveal,Win/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 fix:renderer 在 `subscribeIpc()` 里把所有 onXxx 返回的 unsubscribe
|
||||
// 收集到 ipcUnsubscribes,并在 pagehide 时统一拆。
|
||||
// 但 pagehide vs beforeunload 顺序在不同 Chromium / Electron 版本下游移,
|
||||
// 极端情况下(旧 Electron / Ctrl+Shift+R 强制 reload)pagehide 不触发,
|
||||
// 主进程的 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
262
scripts/check-ipc.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
// IPC 通道配对审计(取代 preload.js 旧的那 45 行手工注释)
|
||||
//
|
||||
// 规则:
|
||||
// 1. renderer → main(invoke / 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.js:ipcRenderer.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/**/*.js:ipcMain.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
86
scripts/check-syntax.js
Normal 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/export,node --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
117
scripts/launch.js
Normal 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
42
shared/ai-errors.js
Normal 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.js,jsdom 单测环境走本地副本)
|
||||
//
|
||||
// 命名规则: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
111
shared/extension-lists.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// 文件扩展名白名单(main / preload / renderer 三处共用)
|
||||
//
|
||||
// ============================================================================
|
||||
//
|
||||
// 之前一批可编辑扩展名同时维护在两处(具体数量已不准):
|
||||
// - main/file-ops.js 的 EDITABLE_EXTS Set(classifyEntry 入口决定文件
|
||||
// entryType,关系到 sidebar 展示 + 打开提示)
|
||||
// - src/app.js 的 LOCAL_FILE_EXTS 数组(resolveLocalFile 判断 md 内
|
||||
// 链接是否可以跳转到该文件)
|
||||
//
|
||||
// 两边列表一旦漂移,「侧栏显示可编辑 / 点击却提示不支持」或「侧栏灰掉
|
||||
// 但 md 里能点开」的体验割裂就会出现。本文件是单一事实源:
|
||||
//
|
||||
// • EDITABLE_EXTS — 可打开编辑的文件扩展名(小写、不含点)
|
||||
// • MARKDOWN_EXTS — 支持 Markdown 渲染预览的扩展名(EDITABLE_EXTS 子集)
|
||||
//
|
||||
// 加载方式:
|
||||
// • main / preload(CJS):const 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 / TypeScript(jsx/tsx 让 React 用户也能直接编辑)
|
||||
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx',
|
||||
// Web:HTML / 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 };
|
||||
77
shared/friendly-fs-error.js
Normal file
77
shared/friendly-fs-error.js
Normal 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 - 原始 errno(EACCES / EPERM / ENOSPC ...)
|
||||
* 或业务码字符串(PATH_NOT_ALLOWED 等),业务码一律走 fallback
|
||||
* @param {string|undefined} fallback - errno 未匹配时使用的回退文案
|
||||
* (主进程一般传 e?.message;renderer 一般传 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
604
shared/markdown-diff.js
Normal 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 fix:Windows 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 在这两种输入下会产生
|
||||
// 不同的 ops(added 与 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 后面又粘一个 c),preferredIndex 仍
|
||||
// 命中旧位置,但旧位置的 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 fix:before + 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
239
shared/render-sanitize.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// Markdown → 安全 HTML 的核心规则(Stage 8:与 preload.js 共用)
|
||||
//
|
||||
// 职责:
|
||||
// - 定义 ALLOWED_URI_REGEXP(URI 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 上常写 ``,
|
||||
* 浏览器对 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 标签(<a href> / <form action> / iframe 等)
|
||||
* 上剥,包括 file:(导航/数据外流通道)。IMG/src 例外:file:/// 在 IMG 上是
|
||||
* Notes 数据目录本地图片路径,需要保留。
|
||||
*
|
||||
* audit fix (C1):钩子里先 trim 再匹配。原正则 `^...javascript:...` 锚定首字符,
|
||||
* 若 DOMPurify 未规范化前导空格 / 控制字符(` ` 等实体),
|
||||
* 像 `<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 钩子:
|
||||
* - <img src> 走 ALLOWED_URI_REGEXP 显式校验(统一策略);
|
||||
* file:/// 放行(ALLOWED 已含),javascript:/vbscript:/data:text\/html 等
|
||||
* 未在白名单的 scheme 一律剥
|
||||
* - 其他标签的 href/src/action 见到 javascript:/vbscript:/data:text\/html/file:
|
||||
* → 主动剥
|
||||
*
|
||||
* 之前 <img src> 是 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
566
shared/settings-schema.js
Normal 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.js(CJS):const 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, // 自动保存防抖延迟(ms);0 = 关闭;默认 500ms = 「停打后立刻存」
|
||||
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
|
||||
sidebarWidth: null, // 侧栏拖拽后的宽度(px);null = 使用 CSS 默认 --w-sidebar
|
||||
aiWidth: null, // AI 中间面板拖拽后的宽度(px);null = 使用 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: '', // baseURL:OpenAI 含 /v1(如 https://api.openai.com/v1);Anthropic 不含(如 https://api.anthropic.com)
|
||||
aiApiKey: '', // API Key(敏感数据;只在主进程内存里使用,不写日志)
|
||||
aiModel: '', // 模型名;OpenAI 如 gpt-4o-mini;Anthropic 如 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/completions;anthropic = /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: 'baseURL:OpenAI 含 /v1(如 https://api.openai.com/v1);Anthropic 不含(如 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 → Z,localeCompare(zh-CN)' },
|
||||
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
|
||||
]),
|
||||
// 自动保存不再需要枚举选项:debounce 延迟是连续数值(0..60000 ms),
|
||||
// 由 toolbar 按钮直接 toggle 0 ↔ 500,UI 也不再有「选几秒」的下拉。
|
||||
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 fix:trim 首尾空白 —— 用户复制粘贴 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; // 未知键:丢弃
|
||||
|
||||
// nullable:null 合法
|
||||
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 早就 trim(Round 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。旧版只 validateKey(async 路径)
|
||||
// 校验 format,coerceLoadedSettings 直接放行 → 手改 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 / aiWidth)min 检查会拦下 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
78
shared/slug.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// Heading slug 的唯一算法(preload + renderer 共用)
|
||||
//
|
||||
// 唯一调用点:把 heading 文本转成 [A-Za-z0-9-]+ 形式的 slug id。
|
||||
// 因为 preload(marked 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.js(CJS,require):marked renderer 决定 DOM 上的真实 id
|
||||
// - src/outline.js(renderer 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 id,outline 点击就会跳到错误锚点。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
453
src/ai/ai-chat-panel.js
Normal file
@@ -0,0 +1,453 @@
|
||||
// AI 对话面板(底部 dock)
|
||||
// ============================================================================
|
||||
//
|
||||
// 给一段自然语言修改要求 + 当前文件内容,发给主进程调 AI。
|
||||
// 状态机:idle → submitting → (success | error | cancelled) → idle
|
||||
// 不持有 diff 数据(diff 走 AiDiffPanel / AiController)。
|
||||
//
|
||||
// 职责边界:
|
||||
// - 状态展示(status / error 通过 onNotify 转发到状态栏 #status-ai chip)
|
||||
// - 收集用户输入
|
||||
// - 防 IME(compositionstart/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]
|
||||
* 把过程/错误文案转发到外部 UI(statusbar 上的 #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 = 提交(不 Shift);IME 输入中不发
|
||||
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 fix:hung 请求兜底 —— _triggerCancel 只是把 cancel 发给主进程;如果 AI
|
||||
// 服务永远不回包,_triggerSubmit 的 await 永远不会 resolve,finally 永远跑不到,
|
||||
// _submitting 卡在 true。close 不强制复位,下次 open 时用户按提交会被
|
||||
// `if (this._submitting) return` 挡住、误以为按钮坏了。
|
||||
// audit fix (Phase O-L18):原 `if (this._submitting && this._canceling)` 是
|
||||
// 死代码 —— _canceling 从未置 true(line 376-377 注释明确说不设),整个 if
|
||||
// 分支永远不进。简化:仅看 _submitting;notify 不动(让残留的 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)
|
||||
* 检查时,可能误命中旧 requestId(UUID 冲突概率虽低,但存在);
|
||||
* 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 #1(critical):按钮文字在「生成修改 / 取消」之间切换时,
|
||||
// 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} KB),AI 响应可能变慢`, '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 调:把错误文案写到 chip(danger 色)。
|
||||
* @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
864
src/ai/ai-controller.js
Normal 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 CJS(shared/* 是 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 模式刷新 viewer,AI 改动只能通过
|
||||
* 这个回调让 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 过程/结果文案转发到外部 UI(statusbar 上的 #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;
|
||||
// 防抖 timer:onContentChanged 不再每个 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 都带上 myFile,apply 前再核对一次。
|
||||
* @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,
|
||||
});
|
||||
// 默认无 diff:hidden
|
||||
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 只在
|
||||
// 我们仍是「最新一次提交」时才清掉,否则会让并发的下一次提交找不到自己
|
||||
// 的 requestId(onFileChanged / destroy 看 currentRequestId 决定是否取消
|
||||
// in-flight 请求)。audit M1 fix:currentRequestId 必须在 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.DIFF:chip 用 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 H162:apply 前确认 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 上标 conflict,UI 灰掉"应用此处"按钮
|
||||
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=true(diffPanel 会隐藏
|
||||
// "应用全部" / "应用此处" 按钮),强制用户「逐条 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 也不会再生成对应 region,filter 是 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.DIFF:skip 一处后剩余待确认数要靠 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 可能是 async:catch 同步 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 fix:reset 比单独清 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
268
src/ai/ai-diff-panel.js
Normal 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 diff(equal/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 渲染或纯文本展示。
|
||||
// 这里走 DOMPurify(preload 已暴露 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=true(AI 普通文本回复)时,整块内容由 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
72
src/ai/ai-status.js
Normal 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.js(CJS,main / 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
3959
src/app.js
Normal file
File diff suppressed because it is too large
Load Diff
118
src/component.js
Normal file
118
src/component.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// 可销毁组件的样板(AbortController mixin)
|
||||
//
|
||||
// 之前大部分 class(FileList / MarkdownViewer / Toolbar / EditorToolbar / ContextMenu)
|
||||
// 在构造函数里直接 `element.addEventListener(...)` 挂 listener,却没有统一的
|
||||
// dispose 路径。结果:
|
||||
// - 单例组件销毁时无法 unbind,listener 持有闭包 → 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') {
|
||||
// 旧 API:opts = 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
127
src/confirm-dialog.js
Normal 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-3:modal 已被另一个对话框占用(罕见但会发生,比如用户在设置对话框
|
||||
// 开着时按 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
285
src/context-menu.js
Normal 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 把焦点移出菜单再点别处时不会触发 mousedown(focus 已经离开菜单),
|
||||
// 菜单留在屏幕上"卡死"。挂 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 落到 body;rAF 让出这一帧让用户操作稳定,
|
||||
// 再把焦点给回 prevFocus,避免被后续的 focus() 事件覆盖。
|
||||
if (this._prevFocus) {
|
||||
const target = this._prevFocus;
|
||||
this._prevFocus = null;
|
||||
// 二次校验:节点可能被外部重渲染撕下 DOM(file-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
|
||||
// 与 separator),Home/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
29
src/dirty-sync.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// state.isDirty ↔ editor.isDirty() 单向提升同步 helper(Round 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 合成期间跳过 onChange,state.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
135
src/editor-theme.js
Normal 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 下设 #528bff(oneDark 的 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
82
src/editor-toolbar.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// 视图模式切换控件
|
||||
// 三态:preview(默认)/ edit / split
|
||||
// 状态写 document.body.dataset.editorMode,CSS 据此切布局。
|
||||
|
||||
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
50
src/event-emitter.js
Normal 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
68
src/feedback.js
Normal 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
476
src/file-list.js
Normal file
@@ -0,0 +1,476 @@
|
||||
// 侧边栏文件列表组件
|
||||
// 职责:渲染文件列表 + 处理点击 + 处理搜索过滤 + 排序
|
||||
//
|
||||
// 与 Todo List 的 sidebar.js 区别:移除分类、子分类、右键菜单、拖拽、智能列表。
|
||||
// 只保留 "扁平文件列表 + 点击打开 + 搜索过滤 + 排序" 这四件事。
|
||||
//
|
||||
// Stage 8(Folder 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();
|
||||
|
||||
// 统一 lifecycle:dispose() 一次性 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 返回的磁盘 case),IPC 跨进程
|
||||
// 看到的路径字符串统一。
|
||||
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 fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入
|
||||
// 的 case,fs 返回的是磁盘真实 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 fix:Windows 路径大小写不敏感;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 8:folder / 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 path:folder / 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
481
src/file-ops.js
Normal 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 是 CJS(require('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 = Reveal,Win/Linux = 资源管理器选中)。
|
||||
* - 文件夹:走 `shell:open-dir`,通过 shell.openPath 直接打开目录本身
|
||||
* —— showItemInFolder 在主进程会 stat.isFile() 校验,目录会被拒
|
||||
* (NOT_A_FILE),用户点「在文件夹中显示」反而弹错误 toast。
|
||||
*
|
||||
* Stage 8:folder 条目仍保留该入口,期望是「用系统文件管理器打开这个目录」
|
||||
* (参见 showFileListContextMenu 注释)。
|
||||
*/
|
||||
async function revealFile(file) {
|
||||
if (!file || !file.path) return;
|
||||
// 文件夹条目不能用 showItemInFolder(主进程会 stat.isFile() 校验,
|
||||
// 目录被拒 → 弹「不是一个文件」)。改走 openDir,让 shell.openPath
|
||||
// 打开目录本身(Win/linux 进 Explorer,macOS 进 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 不在 entries),onFilesChanged 的 rescue 分支会把当前 dirty 内容救到
|
||||
* `<A>-rescued-<stamp>.md`(与被删的 A 内容相同),然后 clearCurrentFile —
|
||||
* 这是「删除」操作的孤儿副作用。修复:进 deleteFile 前设 state.deletingPath =
|
||||
* file.path;onFilesChanged 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 已出现」推到
|
||||
// renderer,onFilesChanged 的 rescue 分支会把当前 dirty 内容救到
|
||||
// `<A>-rescued-<stamp>.md`(内容 = editor 内容 = 与重命名后的 B 完全相同)→
|
||||
// 孤儿文件。守卫:进 renameFile 前设 state.renamingPath = file.path,onFilesChanged
|
||||
// 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.api(createFile /
|
||||
// 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/name,state.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-changed,setFiles 之后 onFilesChanged 会比对 state.files 的
|
||||
// mtimeMs 与 lastSavedMtimeMs;如果不写后者,会被误判为「文件被外部修改」弹 rescue 提示。
|
||||
state.lastSavedMtimeMs = result.mtimeMs;
|
||||
// P2 fix:fs-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-5:viewer 也要同步 —— 笔记里的 <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 8:folder 条目上隐藏「重命名」—— 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
173
src/focus-trap.js
Normal 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」,
|
||||
// 返回 null;dispose 会直接跳过 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 rects(filter 里已放过),但 .focus()
|
||||
// 是 silent no-op;Tab 序列走到这里 → 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 时整块被 unmount,prevFocus 已 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
445
src/markdown-editor.js
Normal 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.setScrollProgress;getScrollProgress 读
|
||||
// scrollHeight/clientHeight 强制 layout flush,setScrollProgress 又写
|
||||
// scrollTop 触发再次 layout —— 每个 keystroke + 移动光标都走一遍 N 次
|
||||
// layout 计算,长文档能感知到 jank。用 rAF 把同帧内的多次触发合并到
|
||||
// 一次,rAF 回调里只做一次 getScrollProgress(reader 与 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);
|
||||
// 兜底清理:即便外部忘记调 dispose,rAF 取消 + 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-save(save() 写入 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 成
|
||||
// 独立 transaction(userEvent: '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。
|
||||
// scroll(geometryChanged)不依赖 docChanged,IME 期间用户也可能滚动
|
||||
// 列表,跟之前一样保留。
|
||||
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 flush,10K 行文档下每个 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 走完时被置 null,rAF 回调里再读会抛
|
||||
// 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。
|
||||
// applyUserChange(AI 应用 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 保持 false,save() 走 !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 提交后补发 onChange:doc 已变但 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 前快照当前 selection,dispatch 时回填到
|
||||
* 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.main(AI 应用 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
736
src/markdown-viewer.js
Normal 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.safeRenderMarkdown:marked + 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] - 返回当前数据目录绝对路径
|
||||
* 异步 callback;resolveRelativeImages 用它做安全边界检查(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 : '';
|
||||
|
||||
// 统一 lifecycle:dispose() 一次性 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),同步调不会等
|
||||
// promise,IPC 抛 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.path,rAF 回调里 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 扩展:可编辑文件不一定是 Markdown(txt/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-forget;await getDataDir 期间
|
||||
// currentFile 已被新 loadContent 替换为本文件 B,但 this.bodyElement
|
||||
// 也指向已被替换的 DOM(B 的内容)。若不挡,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 上 —— 之前
|
||||
* 没传 signal,dispose() 时 listener 仍存在,闭包持住 viewer + 回调对象,
|
||||
* 单例 dispose 后再开新组件(旧 cb 在 rAF 里跑 → 抛"viewer disposed"
|
||||
* 类错误)。现在挂 signal,dispose() 一次性 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 fix:loadContentLive 内层
|
||||
// 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: static,offsetParent 一路爬到 <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
55
src/modal-stack.js
Normal 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 fix:idempotent —— 不匹配时也清掉已断连的引用。否则:overlay A 被
|
||||
// 外部 .remove() 后没调 unregister,再 register(B) → currentOverlay=B。
|
||||
// 之后某个调试路径 unregister(A)(按旧引用传)会发现不等、什么都不做,
|
||||
// 但 B 的 unregister(B) 仍能把 currentOverlay 清成 null —— 这里其实
|
||||
// 没问题。真正问题是:A 没调 unregister 又被外部断连,下次 isOpen() 走
|
||||
// isConnected 检查返回 false,UI 显示「无模态」但栈里仍持有 dead ref,
|
||||
// 调试时 dump 出来是误导的。
|
||||
if (currentOverlay === overlay) {
|
||||
currentOverlay = null;
|
||||
return;
|
||||
}
|
||||
// 不匹配:若 currentOverlay 已不连到 DOM(外部 .remove()),顺手清掉。
|
||||
if (currentOverlay && !currentOverlay.isConnected) {
|
||||
currentOverlay = null;
|
||||
}
|
||||
}
|
||||
328
src/modal.js
Normal file
328
src/modal.js
Normal 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 右键菜单 → 焦点会回
|
||||
* 那个 item;search 输入触发的搜索结果 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 继续冒泡到 document,shortcuts.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-level(footer/header),in-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-action;body 内的交给 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.onunhandledrejection,modal 永不关、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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 initialFocus:HTMLElement 直接用;字符串 → querySelector;undefined →
|
||||
* 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
242
src/prompt-dialog.js
Normal 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') {
|
||||
// 返回 undefined(mountModal 不再负责关闭),因为 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
46
src/save-button-ui.js
Normal 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 同步到状态栏「未保存」chip(hidden + 文案)。
|
||||
* 同时把 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)');
|
||||
// 状态栏「未保存」chip:preview / 全屏等场景下,工具栏按钮可能被遮挡,
|
||||
// 状态栏右下角的红点 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
83
src/save-toast.js
Normal 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+S(auto-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 必须弹「文件已切换,未保存到磁盘」
|
||||
//
|
||||
// 错误 toast(FILE_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 已 resolve,data 可能已落 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 静默场景)
|
||||
* - 其它失败 reason(CONFLICT_* / FILE_NOT_FOUND / PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED
|
||||
* / WRITE_FAILED / IPC_ERROR)→ 错误 toast 已在 IIFE 各分支按 result.error 弹过,
|
||||
* 本 helper 不重复弹(避免误导性「保存失败(请重试)」覆盖 IIFE 已弹的具体原因)。
|
||||
*
|
||||
* 「已保存」duration 从 1200ms 提到 2500ms(fix audit 2026-08):1200ms 太快,
|
||||
* 用户实际只看到约半秒淡出动画,根本来不及形成「保存完成」的认知,导致
|
||||
* 「保存按钮失灵」类错觉反复出现。2500ms 是大多数 IDE(VS 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);
|
||||
}
|
||||
// 其它失败 reason:IIFE 内部已按 result.error 弹过原因 toast,这里不重复。
|
||||
}
|
||||
1002
src/settings-dialog.js
Normal file
1002
src/settings-dialog.js
Normal file
File diff suppressed because it is too large
Load Diff
225
src/settings-store.js
Normal file
225
src/settings-store.js
Normal 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
|
||||
// 原生 ESM(nodeIntegration: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
206
src/shortcuts.js
Normal 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 有 overlay),Ctrl+, / 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+A(AI 修改面板)
|
||||
* @param {() => void} [handlers.onReload] - Ctrl/Cmd+R(README 之前提到但未注册——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 是历史 fallback(Firefox / 旧 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 的 keymap(Shift+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
260
src/splitter.js
Normal file
@@ -0,0 +1,260 @@
|
||||
// 通用拖拽分割条工厂
|
||||
// ============================================================================
|
||||
//
|
||||
// 起源(Stage 4d 抽出):src/app.js 里有三段几乎一模一样的 mountSplitter*:
|
||||
// - mountSplitter (editor ↔ viewer,3480-3640)
|
||||
// - mountSidebarSplitter (sidebar,3652-3850)
|
||||
// - mountAiSplitter (AI panel,3865-3984)
|
||||
//
|
||||
// 三段共享的样板:
|
||||
// - 鼠标 + 触屏 drag 状态机(dragging / pointerId / startX / startWidth)
|
||||
// - cursor: col-resize + userSelect: none 切换
|
||||
// - is-dragging class 切换
|
||||
// - 命中区扩展(仅 sidebar / AI:±HIT_RADIUS 像素也算命中)
|
||||
// - document 级 mousemove / touchmove / mouseup / touchend / touchcancel
|
||||
//
|
||||
// 不共享的部分(由调用方注入):
|
||||
// - 边界 clamp:editor 走 frSpace 动态算,sidebar/AI 走静态 [MIN, MAX]
|
||||
// - 持久化:splitRatio / sidebarWidth / aiWidth 三个字段
|
||||
// - 命中区距离计算:sidebar 缓存中线(Phase M-H6),AI 每次重读
|
||||
//
|
||||
// 设计:
|
||||
// - 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 解除挂载的所有 listener(document 级 + 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 一致
|
||||
|
||||
/** 当前挂的所有 listener,dispose 时一次性解除 */
|
||||
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
76
src/stats.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// 文本统计 —— 词数 / 行数 / 字符数 的单一来源
|
||||
//
|
||||
// 单一来源原因:
|
||||
// 之前 editor.getStats() 走 view.state.doc.lines(CM6 逻辑行),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(不含末尾 trimming;CM6 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
18
src/styles.css
Normal 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
105
src/styles/01-tokens.css
Normal 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 → 15:UI 文字比 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
500
src/styles/02-palettes.css
Normal 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 → #d23636(white-on-bg 对比从 2.2:1
|
||||
提到 4.86:1,AA 通过)。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 → #c52424(white-on-bg 对比从
|
||||
3.93:1 提到 5.84:1,AA 通过)。--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 → #4f5ec7(white-on-bg 对比从 3.46:1
|
||||
提到 5.6:1,AA 通过)。其它 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 AA(4.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
159
src/styles/03-base.css
Normal 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;
|
||||
/* 字体平滑策略:默认开启 ClearType(subpixel-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 默认 hidden(auto 高度 = 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-text(65% 装饰色 + 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-selection(alpha 0.28-0.50)而非 --accent-soft(0.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
565
src/styles/04-toolbar.css
Normal 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-text):view 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);
|
||||
}
|
||||
/* a11y:prefers-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;
|
||||
}
|
||||
/* a11y:prefers-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;
|
||||
}
|
||||
|
||||
/* 窄窗口(< 820px):toolbar 文字标签隐藏,只保留图标。
|
||||
与 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 区域扩到 ±6px(HIT_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
872
src/styles/05-sidebar.css
Normal 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:1(default 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
|
||||
撑到 ~60px,50 个文件就要 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 {
|
||||
/* 平时 muted,hover/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-hover:hover 态是临时高亮(更深一档),
|
||||
active 态是稳定选中,两者用不同 token 区分意图 */
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.file-item.is-active:hover {
|
||||
/* 激活态 hover 走更深一档 —— 与 idle 态 hover(bg-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 上偏糊,提到 12px(fs-xs-readable)保持视觉小但不模糊;
|
||||
颜色已用 secondary(2026-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
|
||||
- 标题 → 主 hint:12px,让主信息与提示分隔
|
||||
- 主 hint → 次 hint:6px,形成层次
|
||||
用统一 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 提示文本:用 secondary(2026-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 提到 12px:11px 在低 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 max;margin 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 的 spec(var(--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 Browser(Stage 8):面包屑 + 文件夹 / 二进制条目样式
|
||||
|
||||
面包屑放在 .sidebar-sort 下方、.file-list 上方,贴近它作用的列表。
|
||||
根目录时整个 <nav> hidden(HTML 里默认带 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
295
src/styles/06-markdown.css
Normal 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
446
src/styles/07-statusbar.css
Normal 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 是核心可交互 chip,count 是辅助信息。
|
||||
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 不关 transition(10-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-text(60% 纯色 + 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);
|
||||
}
|
||||
|
||||
/* running:accent-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;
|
||||
}
|
||||
}
|
||||
|
||||
/* error:danger 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
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
251
src/styles/09-misc.css
Normal 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
|
||||
会抢走 row1(52px),把 .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
69
src/styles/10-a11y.css
Normal 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 高于 toolbar(toolbar 顶层 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
714
src/styles/11-ai.css
Normal file
@@ -0,0 +1,714 @@
|
||||
/* ============================================================================
|
||||
AI 修改功能(参考 markdown.guanjihuan.com 的设计思路)
|
||||
- 中间面板 .app-ai:app-shell 的 grid 第 6 列,body[data-ai-diff-open] 控制可见
|
||||
- 底部 dock .ai-chat-dock:body grid 第 3 行(app-shell 与 statusbar 之间),
|
||||
由 grid-row:3 显式钉位;默认 hidden 不占空间,展开时 statusbar 自动让位
|
||||
- Diff 渲染:行级 +/- + 词级 inline diff(equal/removed/added)
|
||||
- 配色与现有 token 一致(accent / success / warning / danger / surface)
|
||||
============================================================================ */
|
||||
|
||||
/* ============================================================
|
||||
Body 网格:插入 AI 中间面板
|
||||
============================================================ */
|
||||
|
||||
/*
|
||||
AI 中间面板在三种模式下的位置(参考 markdown.guanjihuan.com 的
|
||||
「编辑 / 预览中间夹 AI 审查区」设计):
|
||||
|
||||
- preview(只显示 viewer):AI 在 viewer 右边
|
||||
- edit(只显示 editor):AI 在 editor 右边
|
||||
- split(editor + viewer 双栏):AI 在 #splitter 右边、viewer 左边
|
||||
—— 即「编辑和预览中间」的位置,用户对照原文档 / 编辑器的同时看 AI diff
|
||||
|
||||
AI 默认 hidden,由 ai-controller 切到 body[data-ai-diff-open="true"] 时显示。
|
||||
*/
|
||||
|
||||
/*
|
||||
BUG fix:CSS Grid auto-placement 按 DOM 顺序处理 cursor,cursor 越过列尾就
|
||||
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)和 .viewer(auto)就会被
|
||||
全部挤到 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 + 仅 AI:5 列
|
||||
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 + 仅 AI:7 列 —— 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 + AI:5 列(见上) */
|
||||
|
||||
/*
|
||||
聚焦模式(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 + AI:5 列 */
|
||||
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 + AI:AI 嵌在 #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 + AI(split 在 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:1(dark 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 收到 32px:999 行以内(~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 fix:conflict 区域新增的"跳过此处"按钮 —— 比 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 次色 —— 形成视觉权重差,
|
||||
让用户在冲突时优先尝试 apply,skip 才是退路 */
|
||||
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 默认 padding(Chromium 给 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:hover(0,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
185
src/styles/12-print.css
Normal 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,
|
||||
否则长 URL(github 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
44
src/toolbar.js
Normal 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
341
src/utils.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全渲染 markdown —— markdown-viewer / ai-diff-panel 共享的入口。
|
||||
*
|
||||
* 优先走 preload 暴露的 window.api.renderMarkdown(marked + 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 已经覆盖了 `"` 和 `'`(→ " / '),
|
||||
* 直接放进属性上下文(`attr="..."` / `attr='...'`)都是安全的。
|
||||
*
|
||||
* 这里保留独立导出有两个目的:
|
||||
* - 语义标识:调用方看到 escapeAttr 就知道这是放进 HTML 属性的;
|
||||
* review 时一眼能区分「文本节点」与「属性值」两个不同的转义场景
|
||||
* - 未来扩展:若要给属性转义加更激进的规则(如拒绝控制字符),
|
||||
* 改这一处即可,不必把所有 escapeHtml 调用点扫一遍
|
||||
*
|
||||
* @param {string} s
|
||||
*/
|
||||
export function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前是否在 Windows 上 —— 用于大小写不敏感比较。
|
||||
*
|
||||
* 优先用 navigator.userAgentData(Chromium 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+join:POSIX 绝对路径的第一段是空串,
|
||||
* 过滤空串再 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)。
|
||||
* 这里仅做 fallback:preload 失败(极少见)时降级到硬编码 ['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.js(CJK 友好词数 + 行数 + chars 的统一来源)。
|
||||
44
tests/unit/ai-errors.test.js
Normal file
44
tests/unit/ai-errors.test.js
Normal 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 是 CJS(module.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
871
tests/unit/ai.test.js
Normal 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)
|
||||
//
|
||||
// 不依赖 Electron(ai.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 fix:postJson 之前只 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(不需要 resolve,cancelAll 会清掉)
|
||||
proxy.cancelAll();
|
||||
// 等 p1 真的 reject(被 abort 后 fetch promise 不会自己结束)
|
||||
// 加个超时兜底防止测试挂住
|
||||
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
|
||||
expect(() => proxy.cancelAll()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// audit F1:cleanup 必须 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');
|
||||
// 等两个都 settle(aborted)
|
||||
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 fix:fetch 抛错时 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 fix:baseURL 含 userinfo(https://user:pass@host)必须剥掉。
|
||||
// 真实场景:中转服务在 baseURL 里塞 userinfo 简化配置;fetch 会把 userinfo
|
||||
// 当 Basic Auth 自动发出去,凭据泄露到第三方。
|
||||
it('M2:baseURL 含 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-up:URL 里 path 段含 sk-xxx 的极端情况也要在 sanitizeUrl 输出里被遮罩。
|
||||
// 不依赖 joinUrl(防御深度):sanitizeUrl 单独处理原始 URL 时也应当剥 userinfo。
|
||||
it('M2:sanitizeUrl 输出(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 fix:HTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本
|
||||
// (错误信息里把 header 序列化成字符串,AI proxy / 网关常见),旧 regex 遇到 `"`
|
||||
// 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。
|
||||
// 这里使用非 sk- 前缀的 key,确保测试通过 sk- 正则 fail(只覆盖 JSON-wrapped 修复路径)。
|
||||
it('M3:JSON-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 归一化分支', () => {
|
||||
// M11:Anthropic 返回的 content 全部是 tool_use / image(无 text 块)——
|
||||
// 旧版会误报「AI 返回了空内容」让用户重试;新版提示「无法识别」。
|
||||
it('M11:Anthropic 仅返回 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('M11:Anthropic 仅返回 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('M11:Anthropic 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('M11:Anthropic 混合 text + tool_use → 只提取 text,responseFormat=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('M13:OpenAI 返回的 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('M13:OpenAI 返回的 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('M13:OpenAI 返回的 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-13:Anthropic / OpenAI max_tokens 上限路由
|
||||
//
|
||||
// 回归背景:pickAnthropicTokenConfig 的正则要求 `claude-` 后紧跟已知片段,
|
||||
// 于是 claude-opus-5 / claude-sonnet-5 / claude-haiku-4-5 全部漏网 → 回退 8192,
|
||||
// 比 claude-opus-4-8(16384)还低。「越新的模型拿到越小的 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;
|
||||
}
|
||||
|
||||
// ---- Anthropic:5 系与 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);
|
||||
});
|
||||
|
||||
// ---- Anthropic:claude-3 老家族与未知模型保守 8k ----
|
||||
it.each([
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
])('Anthropic %s(claude-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-H3:fetch 必须 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 Response:status=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(/不会自动跟随重定向/);
|
||||
});
|
||||
});
|
||||
735
tests/unit/config-store.test.js
Normal file
735
tests/unit/config-store.test.js
Normal file
@@ -0,0 +1,735 @@
|
||||
// Stage 7 tests: main/config-store.js
|
||||
//
|
||||
// 覆盖:
|
||||
// - init() 触发 loadConfig + 设定 DEFAULT_DATA_DIR
|
||||
// - saveConfig 合并写入 + 返回新 config
|
||||
// - resolveDataDir:用户自定义 > 默认
|
||||
// - seedDefaultDataDir:sentinel 已存在 / 已有 .md / 自定义目录 → 跳过
|
||||
// - getConfig / getDefaultDataDir / loadConfig 在 init 后行为
|
||||
//
|
||||
// 配置模块依赖 `electron.app` —— 通过 _setApp() 注入 fake app(vi.mock('electron')
|
||||
// 拦不住 CJS 的 require 调用,所以走 config-store 自己暴露的 setter)。
|
||||
// 文件系统通过临时目录 + 真实的 fs.promises(mock 太多会失去意义)。
|
||||
//
|
||||
// 注意:模块 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 是直接返回 appConfig,renderer 端 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 后对父目录 fsync(POSIX)', 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();
|
||||
// 旧 case:fire-and-forget saveConfig,p1 任务在 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 跑
|
||||
// task,merge 时 `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('resolveDataDirOrFallback(auto-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 重新 stat,ENOENT → 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=true,dir 是默认。这是「缓存真的清掉
|
||||
// 了」的标志 —— 如果 cache 还指向 exists=true,这里就会返回 realDir。
|
||||
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
|
||||
expect(out.fellBack).toBe(true);
|
||||
expect(out.saved).toBe(realDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureDefaultDataDir(auto-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('ensureDefaultDataDirSync(sync 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 走默认时自动 ensure(fsWatcher 不撞 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('scheduleEnsureDefaultDataDir(fire-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('coerceLoadedSettings(schema 兜底)', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
115
tests/unit/context-menu.test.js
Normal file
115
tests/unit/context-menu.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// src/context-menu.js 的单测
|
||||
//
|
||||
// 主要覆盖 audit #C3(critical 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 #C3:show 后立即 hide,应取消挂起的 rAF', async () => {
|
||||
// 模拟场景:用户打开菜单,立刻关闭(onSelect 路径)。原实现的 rAF 仍会
|
||||
// fire 并挂 5 个 listener —— 现在必须被取消。
|
||||
menu.show({
|
||||
x: 100, y: 100,
|
||||
items: [{ label: '复制', value: 'copy' }],
|
||||
onSelect: () => {},
|
||||
});
|
||||
// rAF 还没 fire(jsdom 默认 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 #C3:show→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);
|
||||
});
|
||||
});
|
||||
219
tests/unit/dirty-sync.test.js
Normal file
219
tests/unit/dirty-sync.test.js
Normal 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 race:editor 有字符 + state 已同步 → state.isDirty=true(save 不早退)', () => {
|
||||
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 仍 false(save 早退 OK)', () => {
|
||||
const state = makeState(false);
|
||||
const editor = makeEditor(() => false);
|
||||
syncDirtyFromEditor(state, editor);
|
||||
expect(!state.isDirty).toBe(true); // save 早退是正确行为
|
||||
});
|
||||
|
||||
it('半步保存:state=true + editor=false → state 仍 true(save 走 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.js(4111 行 + 大量模块级闭包),直接重放守卫的
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
128
tests/unit/editor-theme.test.js
Normal file
128
tests/unit/editor-theme.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Stage 7 tests: editor-theme.js
|
||||
//
|
||||
// 覆盖:
|
||||
// - pickEditorTheme 按 body[data-theme] 返回对应的 extensions 数组
|
||||
// - 暗色叠加 oneDark;亮色只用自己的覆盖
|
||||
// - backgroundColor / color 必须带 !important(oneDark 后续注入的同 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 下设 #528bff(oneDark 的 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/);
|
||||
});
|
||||
});
|
||||
229
tests/unit/editor-toolbar.test.js
Normal file
229
tests/unit/editor-toolbar.test.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// src/editor-toolbar.js
|
||||
//
|
||||
// 三态(preview / edit / split)视图模式控件:图标按钮 + aria-pressed +
|
||||
// body.dataset.editorMode(CSS 据此切布局)。构造函数绑 click → selectMode。
|
||||
//
|
||||
// 测试覆盖:
|
||||
// - selectMode 合法值:触发 onChange + DOM 更新 + aria-pressed 切换
|
||||
// - selectMode 非法值:被静默忽略(不抛、不触发 onChange、不动 DOM)
|
||||
// - selectMode 同模式:no-op(不重复触发 onChange)
|
||||
// - setMode:同步 DOM 但不触发 onChange(用于外部「持久化设置改了」
|
||||
// 反向同步 UI,避免循环触发)
|
||||
// - getMode:从 body.dataset.editorMode 读,默认 'preview'
|
||||
// - cycle:preview → 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();
|
||||
// 只给 btnEdit,btnPreview / 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']);
|
||||
});
|
||||
});
|
||||
145
tests/unit/event-emitter.test.js
Normal file
145
tests/unit/event-emitter.test.js
Normal 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 / event:no-op
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from '../../src/event-emitter.js';
|
||||
|
||||
describe('EventEmitter 基础语义', () => {
|
||||
it('on + emit:handler 被调用,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 不存在的 handler:no-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 多次只占一个 slot,off 一次就够。
|
||||
// 这里验证 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);
|
||||
});
|
||||
});
|
||||
83
tests/unit/extension-lists.test.js
Normal file
83
tests/unit/extension-lists.test.js
Normal 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_EXTS(Markdown 一定可编辑)
|
||||
// - 大小写:所有 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_EXTS(Markdown 一定可编辑)', () => {
|
||||
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
188
tests/unit/feedback.test.js
Normal 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 写入 DOM(role=status + textContent)
|
||||
//
|
||||
// 75+ 个调用点:app.js / file-ops.js / settings-dialog.js 等。补上 guard 守护,
|
||||
// 防止未来重构时无意改坏 eviction / dismiss 语义(数据丢失风险虽低,但 toast
|
||||
// 满天飞也是用户能感知的 bug)。
|
||||
//
|
||||
// 需要 DOM:jsdom 环境。
|
||||
|
||||
// @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 timers(duration 到期)+ 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 写入 textContent,role=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 已经脱离 DOM,dataset 仍可读(dataset 是元素本身的属性)。
|
||||
// 我们拿到的是被踢的节点引用 —— 没法直接从 container 查。
|
||||
// 改为验证:再 push 1 条时,没有 console error(dismiss 回调跑过、被守卫挡住)。
|
||||
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 先触发 dismiss(dataset.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();
|
||||
});
|
||||
});
|
||||
258
tests/unit/file-list-contextmenu.test.js
Normal file
258
tests/unit/file-list-contextmenu.test.js
Normal 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.openDir(shell.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();
|
||||
});
|
||||
});
|
||||
348
tests/unit/file-list.test.js
Normal file
348
tests/unit/file-list.test.js
Normal 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 flush(100ms)
|
||||
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 触发不再 crash(debounce 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
1033
tests/unit/file-ops.test.js
Normal file
File diff suppressed because it is too large
Load Diff
56
tests/unit/focus-trap.test.js
Normal file
56
tests/unit/focus-trap.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/focus-trap.js 单测(Phase O-L14 audit)
|
||||
//
|
||||
// 覆盖:
|
||||
// - IME 合成期间(isComposing=true)按 Tab 不抢焦点
|
||||
// - keyCode=229(Firefox / 旧 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=229(Firefox / 旧 Chromium fallback)时 Tab 不抢焦点', () => {
|
||||
const trap = createFocusTrap(container);
|
||||
const ev = fireKey(middleBtn, { key: 'Tab', keyCode: 229 });
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
trap.dispose();
|
||||
});
|
||||
});
|
||||
111
tests/unit/friendly-fs-error.test.js
Normal file
111
tests/unit/friendly-fs-error.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// shared/friendly-fs-error.js —— 渲染端 + 主进程共用的 errno → 中文提示
|
||||
//
|
||||
// 覆盖 P0:Round 4 收尾前 renderer 端有三份独立 mapping(src/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 是 CJS(module.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('该路径是文件夹');
|
||||
});
|
||||
});
|
||||
364
tests/unit/markdown-diff.test.js
Normal file
364
tests/unit/markdown-diff.test.js
Normal 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 → 空 regions(rows 只剩 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}`);
|
||||
// sanity:1000 * 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 完一轮后内容应当再次能 compute(idempotent 内容一致)', () => {
|
||||
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);
|
||||
// 应用之后再算一次 diff,regions 应为空
|
||||
const secondDiff = computeFullMarkdownDiff(applied.content, newContent);
|
||||
expect(secondDiff.regions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C1 整篇替换 / 无上下文 region(audit 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('局部修改(保留 context):region 不应被标 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...b;index 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.js(handlesApplyRegion
|
||||
// 空内容防线)。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('');
|
||||
});
|
||||
});
|
||||
205
tests/unit/markdown-editor.test.js
Normal file
205
tests/unit/markdown-editor.test.js
Normal 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 保持 false,save() 走 !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 不实现 scrollIntoView;MarkdownEditor 本身不调,但 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#getClientRects;jsdom 没实现
|
||||
// Range#textRange 是 jsdom 假实现但没挂 getClientRects。每次 measure 抛 TypeError
|
||||
// 会被 vitest 写到 stderr(污染测试输出)。返回空数组让 CM6 跳过选中区绘制。
|
||||
if (typeof Range.prototype.getClientRects !== 'function') {
|
||||
Range.prototype.getClientRects = function () {
|
||||
// 真实浏览器返回 DOMRectList,CM6 只迭代 + 读 .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();
|
||||
});
|
||||
});
|
||||
778
tests/unit/markdown-viewer.test.js
Normal file
778
tests/unit/markdown-viewer.test.js
Normal file
@@ -0,0 +1,778 @@
|
||||
// src/markdown-viewer.js 测试
|
||||
//
|
||||
// 覆盖:
|
||||
// - 链接点击委托:内部锚点 / 外部链接 / 空 href 区分
|
||||
// - scrollToAnchor:含百分号编码 + 中文 id
|
||||
// - scrollPositions LRU 淘汰:超出上限时按插入顺序删除最早
|
||||
// - onHeadingChange 订阅 + 立即触发
|
||||
// - showUnsupportedFile:清空 heading observer
|
||||
// - clear:scrollPositions 清空 + 当前文件清空
|
||||
//
|
||||
// 不测试 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 不实现 scrollIntoView;polyfill 成 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 收到 href,preventDefault', () => {
|
||||
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-1:loadContent(纯空白字符串) 同样显示「(空文件)」', () => {
|
||||
// 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-1:loadContent(非空) 走原 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) 已替换 bodyElement,A 的 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 放一个 src,B 不该被 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
321
tests/unit/modal.test.js
Normal 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
|
||||
//
|
||||
// 需要 DOM:jsdom 环境。
|
||||
|
||||
// @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 兜底,错误打 console,modal 保持打开(用户看到具体错误)。
|
||||
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' → 点「显示」就立刻关 modal,UX 坏掉。
|
||||
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);
|
||||
});
|
||||
});
|
||||
422
tests/unit/prompt-dialog.test.js
Normal file
422
tests/unit/prompt-dialog.test.js
Normal 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)(兜底,不挂死)
|
||||
//
|
||||
// 需要 DOM:jsdom 环境。
|
||||
|
||||
// @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 关联到 input(for 属性匹配 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();
|
||||
});
|
||||
});
|
||||
415
tests/unit/render-sanitize.test.js
Normal file
415
tests/unit/render-sanitize.test.js
Normal 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', 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('');
|
||||
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(``);
|
||||
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(')');
|
||||
expect(html).not.toMatch(/src="javascript:/);
|
||||
});
|
||||
|
||||
it('vbscript: 在 <img src> 被剥', () => {
|
||||
// 旧实现对 vbscript: 放行(keepAttr=true)—— DOMPurify v3 不兜底这个。
|
||||
// 现在由 hook 主动剥,与 javascript: 行为一致。
|
||||
const html = render(')');
|
||||
expect(html).not.toMatch(/src="vbscript:/i);
|
||||
});
|
||||
|
||||
it('about:blank 在 <img src> 被剥(policy 统一:未在白名单的 scheme 一律不放)', () => {
|
||||
// 旧实现会放行 about:blank(不是危险协议但也不在白名单);现在统一被剥。
|
||||
const html = render('');
|
||||
expect(html).not.toMatch(/src="about:/i);
|
||||
});
|
||||
|
||||
it('data:text/html 在 <img src> 被剥(统一策略:仅 data:image/* 放行)', () => {
|
||||
// 之前测试断言「保留」(理由是浏览器不把 img 渲染为 HTML)—— 但跨浏览器 / 跨版本
|
||||
// 一致性差,且与 ALLOWED_URI_REGEXP 单点策略不一致。现在统一被剥。
|
||||
const html = render('');
|
||||
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 几乎不会内嵌 SVG,raster 已覆盖 99% 场景。
|
||||
it('data:image/svg+xml 在 <img src> 被剥(栅格白名单显式排除 SVG)', () => {
|
||||
const html = render('');
|
||||
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.com(CVE-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(``);
|
||||
expect(html).not.toMatch(/src="file:/i);
|
||||
});
|
||||
|
||||
it('file:/// 三斜杠(无 host)在 <img src> 仍保留(合法本地图片)', () => {
|
||||
const html = render('');
|
||||
expect(html).toContain('src="file:///C:/Users/me/Notes/x.png"');
|
||||
});
|
||||
|
||||
// audit fix (Round 13 / Sec-M):项目里 markdown 保留合法 inline style(color /
|
||||
// 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:'会话已过期'">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('合法 style(color / 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('');
|
||||
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" 不被误剥', () => {
|
||||
// 之前 bug:non-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('');
|
||||
expect(html).toMatch(/src="data:image\/png;base64,AAAA"/);
|
||||
});
|
||||
|
||||
it('巨大 data:image (>10MB) 被剥 src', () => {
|
||||
// 绕过 marked 直接 sanitize:marked 的 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(``);
|
||||
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(``);
|
||||
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"/);
|
||||
});
|
||||
});
|
||||
50
tests/unit/safe-render-markdown-typeof.test.js
Normal file
50
tests/unit/safe-render-markdown-typeof.test.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
200
tests/unit/save-button-ui.test.js
Normal file
200
tests/unit/save-button-ui.test.js
Normal 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 重读为 false,savingCount 归零
|
||||
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();
|
||||
});
|
||||
});
|
||||
270
tests/unit/save-toast.test.js
Normal file
270
tests/unit/save-toast.test.js
Normal 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 #3:STALE 不再等同「写盘失败」—— writeFile 在 STALE
|
||||
// 检查前已经 resolve,data 可能已落 targetPath。ok=true 让关窗路径能正常退
|
||||
// 出,但 toast 文案不变(用户视角的「文件已切换」提示总是合适)。
|
||||
// 本文件覆盖决策矩阵;bug 3 在调用方 silent=true(onSaveRequest),bug 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=true:reason=STALE 时一律走 warning', () => {
|
||||
// 防呆测试:即使 ok=true,reason=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 + 其它失败 reason(CONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => {
|
||||
// 关键防呆:IIFE 内部已按 result.error 弹过具体错误 toast(FILE_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 调 decideSaveToast,silent=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 #3:writeFile 成功但用户已切走 → 关窗路径用 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 决策,避免双重 toast(force_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 原生框统一负责,避免双重 toast(renderer 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=true(writeFile
|
||||
// 成功但用户已切走),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=true:reason=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-1:force_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);
|
||||
});
|
||||
});
|
||||
447
tests/unit/settings-schema.test.js
Normal file
447
tests/unit/settings-schema.test.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// Stage 7 tests: shared/settings-schema.js
|
||||
//
|
||||
// 覆盖:
|
||||
// - DEFAULT_SETTINGS:导出 + 含必要字段
|
||||
// - coerceLoadedSettings:未知字段剥除 / 已知字段合并默认值
|
||||
// - validateKey:enum / 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 = openai;baseUrl / 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 到 min,sidebarWidth='' 会被静默改成 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 → 0;99999 → 60000(clamp 行为)
|
||||
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 fix:validateKey / 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-1:nullable-path 同步路径空串归一为 null
|
||||
//
|
||||
// 之前 sanitizeSync 直接返回 '',与 validateKey 异步路径归一为 null 不同步。
|
||||
// coerceLoadedSettings 把磁盘上残留的 "dataDir": "" 保留为 '',但 validateKey
|
||||
// 会归一为 null → 任何依赖 dataDir === null 判断的代码失配。
|
||||
// ------------------------------------------------------------------
|
||||
describe('nullable-path 同步路径空串归一为 null(Round 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-3:nullable-number typeof 守卫对称
|
||||
//
|
||||
// 原版无 typeof 守卫,Number(true) === 1 隐式通过 isFinite。
|
||||
// 当前 schema 用 nullable-number 的字段(sidebarWidth / aiWidth)min 检查
|
||||
// 会拦下 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);
|
||||
});
|
||||
});
|
||||
373
tests/unit/shortcuts.test.js
Normal file
373
tests/unit/shortcuts.test.js
Normal file
@@ -0,0 +1,373 @@
|
||||
// src/shortcuts.js 测试
|
||||
//
|
||||
// 覆盖:
|
||||
// - 8 个全局快捷键派发到对应 handler
|
||||
// - 编辑器内(.cm-editor 内部)让位给 CM6(Ctrl+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-H3:README 提到 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+F(macOS) → onFocusSearch(metaKey 视为 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 在编辑器内 → 不触发 onSave(CM6 已 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 不触发 onToggleEditorMode(CJK 拼音中途保护)', () => {
|
||||
fireKey({ key: 'e', ctrlKey: true, isComposing: true });
|
||||
expect(handlers.onToggleEditorMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keyCode=229(Firefox / 旧 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 dispose(Phase 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
125
tests/unit/slug.test.js
Normal 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
125
tests/unit/stats.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// Stage 7 tests: stats.js
|
||||
//
|
||||
// 覆盖:
|
||||
// - countWords:CJK 单字 + 拉丁词 + 混合 + 边界
|
||||
// - getTextStats:chars / 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
366
tests/unit/utils.test.js
Normal 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() 不报错', () => {
|
||||
// 不传 nowMs;should 不抛
|
||||
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('<script>');
|
||||
expect(escapeHtml('a & b')).toBe('a & b');
|
||||
expect(escapeHtml('"x"')).toBe('"x"');
|
||||
expect(escapeHtml("'x'")).toBe(''x'');
|
||||
expect(escapeHtml('<a href="x">y</a>')).toBe(
|
||||
'<a href="x">y</a>'
|
||||
);
|
||||
});
|
||||
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('<script>');
|
||||
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
19
vitest.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Vitest 配置(Stage 7)
|
||||
//
|
||||
// 设计:
|
||||
// - 测试目录:tests/unit/** (按模块名就近放:stats / editor-theme / modal / ...)
|
||||
// - 默认环境:node(最快;config-store / stats / editor-theme 这些纯函数)
|
||||
// - 标记 // @vitest-environment jsdom 的文件用 jsdom(modal / 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user