update
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -35,3 +35,8 @@ tests/manual/
|
|||||||
# 历史一次性脚本,未被 package.json / lint / check 引用 —— 不进版本库
|
# 历史一次性脚本,未被 package.json / lint / check 引用 —— 不进版本库
|
||||||
# 即可,但留在磁盘方便日后查同一类问题的修复路径。
|
# 即可,但留在磁盘方便日后查同一类问题的修复路径。
|
||||||
scripts/_archive/
|
scripts/_archive/
|
||||||
|
|
||||||
|
.audit_profile/
|
||||||
|
.audit_profile2/
|
||||||
|
.audit_profile3/
|
||||||
|
.audit_profile4/
|
||||||
|
|||||||
155
README.md
155
README.md
@@ -1,20 +1,20 @@
|
|||||||
# Notes
|
# Notes
|
||||||
|
|
||||||
本地优先的 Markdown 阅读与编辑工具。界面看到的和磁盘上的 `.md` 文件一模一样 —— 复制走就是备份。
|
本地优先的 Markdown 阅读 / 编辑工具。`.md` 文件即笔记,文件即数据 —— 看到和磁盘上一字不差,复制走就是备份。
|
||||||
|
|
||||||
   
|
   
|
||||||
|
|
||||||
## 它是什么
|
## 特点
|
||||||
|
|
||||||
Notes 把一个文件夹变成笔记本。`.md` 文件就是笔记,**文件即数据**,没有数据库也没有专有格式。内置 CodeMirror 6 编辑器,`Ctrl + S` 落盘,也可开启自动保存。
|
- 所见即所得 —— 保存时原样写回,不会重排 Markdown 格式
|
||||||
|
- 默认离线 —— 不配 AI 就永不联网;API Key 走系统密钥库(Windows DPAPI / macOS Keychain / Linux libsecret)
|
||||||
8 套配色(default / ocean / forest / sunset / rose / amber / slate / mono),暗 / 亮各一版,字号行距可调。不联网也能用 —— AI 是可选插件,**不配置就永不联网**。
|
- 桌面级体验 —— CodeMirror 6 编辑器 + 实时预览双栏,键盘快捷键齐全
|
||||||
|
- 抗意外 —— 原子写盘、外部修改检测、退出保护,断电不留半截
|
||||||
---
|
- 托盘常驻 —— 关窗不等于退出,单击托盘图标恢复
|
||||||
|
|
||||||
## 5 分钟上手
|
## 5 分钟上手
|
||||||
|
|
||||||
需要 [Node.js 20.18 或更高](https://nodejs.org/)。
|
需要 Node.js 20.18 或更高([下载](https://nodejs.org/))。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <仓库地址> notes
|
git clone <仓库地址> notes
|
||||||
@@ -23,49 +23,47 @@ npm install
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动会在用户主目录创建 `Notes` 文件夹(Windows 是 `C:\Users\<你>\Notes`,macOS / Linux 是 `~/Notes`),并把欢迎页等 4 篇引导文档复制进去。把任意 `.md` 文件拖进那个文件夹,左侧栏立刻显示。
|
首次启动会在主目录创建 `Notes` 文件夹(Windows 是 `C:\Users\<你>\Notes`,macOS / Linux 是 `~/Notes`),并把欢迎页复制进去。把任意 `.md` 文件丢进那个文件夹,左侧栏立刻出现。
|
||||||
|
|
||||||
按 `Ctrl + ,` 打开设置。主题 / 置顶 / 视图模式 / 自动保存在工具栏有独立按钮,**设置里不会出现重复入口**。
|
按 `Ctrl + ,` 打开设置。工具栏已经有「主题 / 视图 / 自动保存」等按钮,设置里不会出现重复入口。
|
||||||
|
|
||||||
---
|
## 阅读与编辑
|
||||||
|
|
||||||
## 日常使用
|
- `Ctrl + E` 在「预览 / 编辑 / 双栏」之间循环
|
||||||
|
- `Ctrl + F` 按文件名实时过滤侧栏
|
||||||
|
- `Ctrl + N` 新建笔记,`Ctrl + S` 保存(IME 拼音合成中不会误触发),`Ctrl + W` 关闭当前文件
|
||||||
|
- `Ctrl + Shift + F` 聚焦模式 —— 隐藏工具栏 / 侧栏 / 状态栏,纯净阅读
|
||||||
|
- 状态栏实时显示当前章节标题
|
||||||
|
- 外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转
|
||||||
|
- `` 相对路径图片直接渲染
|
||||||
|
|
||||||
### 阅读与编辑
|
## AI 改稿
|
||||||
|
|
||||||
- `Ctrl + E` 在「预览 → 编辑 → 双栏」之间循环
|
`Ctrl + Shift + A` 打开底部 AI 面板。输入你想怎么改(润色 / 翻译 / 总结 / 续写),AI 给出行级 + 词级 diff,逐段应用或一键应用全部,不满意随时取消。
|
||||||
- `Ctrl + F` 侧栏搜索框按文件名实时过滤
|
|
||||||
- `Ctrl + S` 保存,`Ctrl + N` 新建笔记,`Ctrl + W` 关闭当前文件
|
|
||||||
- `Ctrl + Shift + F` 聚焦模式,隐藏工具栏 / 侧栏 / 状态栏,纯净阅读
|
|
||||||
- 状态栏实时显示当前阅读到的章节标题
|
|
||||||
- 链接分流:外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转
|
|
||||||
- 相对路径图片(``)直接渲染
|
|
||||||
|
|
||||||
### AI 修改
|
| 协议 | 适用服务 | Base URL 要点 |
|
||||||
|
|
||||||
`Ctrl + Shift + A` 打开底部 AI 面板。描述你想怎么改(润色 / 翻译 / 总结 / 续写),AI 给出行级 + 词级 diff,可逐段应用或一键应用全部,不满意随时取消。
|
|
||||||
|
|
||||||
协议选择:
|
|
||||||
|
|
||||||
| 选项 | 适用服务 | Base URL 末尾 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| OpenAI 兼容 | OpenAI / DeepSeek / Moonshot / Azure / 自部署 | 必须含 `/v1` |
|
| OpenAI 兼容 | OpenAI / DeepSeek / Moonshot / 自部署 | 必须含 `/v1` |
|
||||||
| Anthropic 兼容 | Claude 系列 / 第三方中转 | 不含 `/v1` |
|
| Anthropic 兼容 | Claude 系列 / 第三方中转 | 不含 `/v1` |
|
||||||
|
|
||||||
API Key 默认脱敏显示,点眼睛图标露出明文。**不配也不影响其它功能**。
|
API Key 默认脱敏显示,点眼睛图标露出明文。不配 AI 不影响其它功能。
|
||||||
|
|
||||||
---
|
## 整理侧栏
|
||||||
|
|
||||||
|
- **拖到文件夹上 = 移动**。拖拽时按住 `Ctrl`(macOS 是 `Cmd`)= 复制,文件夹会递归复制,软链接会被跳过
|
||||||
|
- 侧栏聚焦时:`Ctrl + X` 剪切,`Ctrl + C` 复制,`Ctrl + V` 粘贴到当前正在浏览的目录
|
||||||
|
- 右键菜单功能同上。**侧栏空白区域右键也能粘贴**到当前目录(剪贴板为空时该菜单项禁用)
|
||||||
|
- 目标文件夹已有同名项时自动加 `(2)` / `(3)` ... 改名避免覆盖
|
||||||
|
- 不能把文件夹拖进自己或子孙目录(会被拦下)
|
||||||
|
|
||||||
## 数据安全
|
## 数据安全
|
||||||
|
|
||||||
|
- **API Key 加密** —— 写盘前加密,运行时主进程栈上才解出明文
|
||||||
- **外部修改检测** —— 其他编辑器改了文件会提示重新加载(正在打字时不打断)
|
- **外部修改检测** —— 其他编辑器改了文件会提示重新加载(正在打字时不打断)
|
||||||
- **退出保护** —— 有未保存改动就关窗 / 退托盘,会先问「保存 / 丢弃 / 取消」
|
- **退出保护** —— 有未保存改动关窗 / 退托盘,先问「保存 / 丢弃 / 取消」
|
||||||
- **原子写盘** —— 断电也不会留下半截文件
|
- **原子写盘** —— 写到临时文件再 rename
|
||||||
- **数据目录自动回退** —— U 盘拔了 / 网盘断连时自动切回默认目录
|
- **数据目录自动回退** —— U 盘拔了 / 网盘断连自动切回默认目录
|
||||||
- **云同步盘友好** —— OneDrive / 坚果云 / iCloud 都做过专门适配
|
- **云盘友好** —— OneDrive / 坚果云 / iCloud 做过专门适配
|
||||||
- **关窗 ≠ 退出** —— 应用缩到系统托盘,单击恢复,右键看菜单
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据文件夹
|
## 数据文件夹
|
||||||
|
|
||||||
@@ -75,43 +73,42 @@ API Key 默认脱敏显示,点眼睛图标露出明文。**不配也不影响
|
|||||||
| macOS | `~/Notes` |
|
| macOS | `~/Notes` |
|
||||||
| Linux | `$HOME/Notes` |
|
| Linux | `$HOME/Notes` |
|
||||||
|
|
||||||
可在设置里改成任意目录。状态栏左段(紧贴当前路径)有三个 icon-only 按钮:打开、切换、回到默认。
|
设置里可以改成任意目录。状态栏最左段(紧贴当前路径)有三个 icon-only 按钮:打开、切换、回到默认。
|
||||||
|
|
||||||
---
|
## 快捷键
|
||||||
|
|
||||||
## 快捷键速查
|
> Mac 把 `Ctrl` 换成 `Cmd`,下表通用。
|
||||||
|
|
||||||
| 快捷键 | 作用 |
|
| 快捷键 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Ctrl/Cmd + F` | 聚焦文件搜索 |
|
| `Ctrl + F` | 聚焦文件搜索 |
|
||||||
| `Ctrl/Cmd + N` | 新建笔记 |
|
| `Ctrl + N` | 新建笔记 |
|
||||||
| `Ctrl/Cmd + S` | 保存(IME 合成中不触发写盘) |
|
| `Ctrl + S` | 保存(IME 合成中不触发写盘)|
|
||||||
| `Ctrl/Cmd + W` | 关闭当前文件 |
|
| `Ctrl + W` | 关闭当前文件 |
|
||||||
| `Ctrl/Cmd + E` | 切换视图模式 |
|
| `Ctrl + E` | 预览 / 编辑 / 双栏切换 |
|
||||||
| `Ctrl/Cmd + ,` | 打开设置 |
|
| `Ctrl + Shift + F` | 切换聚焦模式 |
|
||||||
| `Ctrl/Cmd + R` | 重新加载界面 |
|
| `Ctrl + Shift + T` | 切换主题 |
|
||||||
| `Ctrl/Cmd + Shift + T` | 切换主题 |
|
| `Ctrl + Shift + A` | 切换 AI 面板 |
|
||||||
| `Ctrl/Cmd + Shift + F` | 切换聚焦模式 |
|
| `Ctrl + ,` | 打开设置 |
|
||||||
| `Ctrl/Cmd + Shift + A` | 切换 AI 面板 |
|
| `Ctrl + R` | 重新加载界面 |
|
||||||
| `Esc`(搜索框内) | 清空搜索 |
|
| `Ctrl + X / C / V` | 剪切 / 复制 / 粘贴(侧栏聚焦时)|
|
||||||
|
| `Esc`(搜索框内)| 清空搜索 |
|
||||||
| `F12` | 打开 DevTools |
|
| `F12` | 打开 DevTools |
|
||||||
|
|
||||||
窗口是无边框自绘标题栏,**Windows / Linux 上看不到菜单栏**,但上表所有快捷键全部生效。
|
窗口是无边框自绘标题栏,Windows / Linux 上看不到菜单栏,但上表所有快捷键全部生效。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**搜索文件没反应?**
|
**搜索文件没反应?**
|
||||||
检查设置里的「数据文件夹」是不是对的。某些云盘的实时监听不可靠,按 `Ctrl + R` 手动刷新。
|
看设置里的「数据文件夹」对不对。某些云盘实时监听不可靠,按 `Ctrl + R` 手动刷新。
|
||||||
|
|
||||||
**AI 提示「无法连接到服务」?**
|
**AI 提示「无法连接到服务」?**
|
||||||
核对 Base URL:OpenAI 兼容需含 `/v1`,Anthropic 不含。点眼睛图标露出 API Key 明文,确认没多余空格。按 `F12` 看完整错误码。
|
OpenAI 兼容协议 Base URL 必须含 `/v1`,Anthropic 不含。点眼睛图标露出 API Key 明文,确认没多余空格 / 换行。按 `F12` 看完整错误码。
|
||||||
|
|
||||||
**中文 Windows 启动报错?**
|
**中文 Windows 启动报错?**
|
||||||
Electron 在中文路径下启动会报错。两种办法:
|
Electron 在中文路径下启动会报错。两种办法:
|
||||||
|
|
||||||
1. 把项目挪到纯英文路径(推荐),如 `D:\Projects\notes`
|
1. 推荐:把项目挪到纯英文路径,如 `D:\Projects\notes`
|
||||||
2. 用管理员 cmd 建符号链接:
|
2. 用管理员 cmd 建符号链接:
|
||||||
```cmd
|
```cmd
|
||||||
mklink /D C:\dev\notes "C:\当前\中文路径\Notes"
|
mklink /D C:\dev\notes "C:\当前\中文路径\Notes"
|
||||||
@@ -119,24 +116,30 @@ Electron 在中文路径下启动会报错。两种办法:
|
|||||||
然后在 `C:\dev\notes` 跑 `npm install && npm start`
|
然后在 `C:\dev\notes` 跑 `npm install && npm start`
|
||||||
|
|
||||||
**控制台中文乱码?**
|
**控制台中文乱码?**
|
||||||
用 `npm start`,**不要用 `npm run start:raw`**。前者会自动把控制台切到 UTF-8。
|
用 `npm start`,别用 `npm run start:raw`。前者自动把控制台切到 UTF-8。
|
||||||
|
|
||||||
**笔记文件会被改动吗?**
|
**笔记文件会被改动吗?**
|
||||||
不会。读什么就显示什么,保存时原样写回 —— **不会偷偷重排 Markdown 格式**。
|
不会。读什么就显示什么,保存时原样写回 —— 不会偷偷重排 Markdown 格式。
|
||||||
|
|
||||||
---
|
**怎么更新到新版本?**
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
你的 `~/Notes` 笔记文件夹完全独立,更新代码不影响数据。
|
||||||
|
|
||||||
## 给开发者
|
## 给开发者
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # 启动(推荐,自动处理中文 Windows 编码)
|
npm start # 启动(自动处理中文 Windows 编码)
|
||||||
npm test # 全部单测(947 用例 / 34 文件)
|
npm test # 全部单测(1217 用例 / 59 文件)
|
||||||
npm run test:watch # watch 模式
|
npm run test:watch # watch 模式
|
||||||
npm run check # 语法 + IPC 通道配对
|
npm run check # 语法 + IPC 通道配对
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm run build:win # 打包 Windows 安装包 → dist/
|
npm run build:win # 打包 Windows 安装包 → dist/
|
||||||
npm run build:mac # 打包 macOS → dist/
|
npm run build:mac # 打包 macOS → dist/
|
||||||
npm run build:linux # 打包 Linux AppImage → dist/
|
npm run build:linux # 打包 Linux AppImage → dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -144,18 +147,16 @@ Notes/
|
|||||||
├── main.js # Electron 主进程入口
|
├── main.js # Electron 主进程入口
|
||||||
├── preload.js # IPC 桥(contextBridge)
|
├── preload.js # IPC 桥(contextBridge)
|
||||||
├── index.html # UI 框架 + CSP + importmap
|
├── index.html # UI 框架 + CSP + importmap
|
||||||
├── main/ # 主进程模块:ai / config-store / file-ops / fs-watcher
|
├── main/ # 主进程:ai / config-store / file-ops / fs-watcher / secret-store
|
||||||
├── src/ # 渲染端:UI 组件 + CSS 分片 + AI 面板
|
├── src/ # 渲染端:UI 组件 + CSS 分片(01-12)+ AI 面板
|
||||||
├── shared/ # 主 / preload / 渲染端共享:schema / diff / 错误码
|
├── shared/ # 主 / preload / 渲染端共享:schema / diff / sanitize
|
||||||
├── data/ # 首次启动种子化的引导文档(4 篇)
|
├── data/ # 首次启动种子化的欢迎页(1 篇)
|
||||||
├── scripts/ # 启动包装 + 静态检查
|
├── scripts/ # 启动包装 / 语法检查 / IPC 校验
|
||||||
└── tests/
|
└── tests/
|
||||||
├── unit/ # vitest 单测(34 个文件,947 用例)
|
├── unit/ # vitest 单测(59 文件 / 1217 用例)
|
||||||
└── manual/ # 手工视觉验证产物(不进版本库)
|
└── manual/ # 手工视觉验证产物(不进版本库)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ module.exports = [
|
|||||||
MutationObserver: 'readonly',
|
MutationObserver: 'readonly',
|
||||||
crypto: 'readonly',
|
crypto: 'readonly',
|
||||||
Element: 'readonly',
|
Element: 'readonly',
|
||||||
|
Node: 'readonly',
|
||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
HTMLInputElement: 'readonly',
|
HTMLInputElement: 'readonly',
|
||||||
HTMLTextAreaElement: 'readonly',
|
HTMLTextAreaElement: 'readonly',
|
||||||
|
|||||||
159
index.html
159
index.html
@@ -10,21 +10,32 @@
|
|||||||
<!-- viewport meta (Round 12 P1 audit):桌面 Electron 也有 webview / web mode 兼容场景 -->
|
<!-- viewport meta (Round 12 P1 audit):桌面 Electron 也有 webview / web mode 兼容场景 -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<!--
|
<!--
|
||||||
Content Security Policy (加固版):
|
Content Security Policy (加固版 · audit P0-2):
|
||||||
- default-src 'self' 只允许加载同源资源
|
- default-src 'self' 只允许加载同源资源
|
||||||
- style-src 'self' 'unsafe-inline' 允许 inline style(marked 渲染的代码块可能会用到)
|
- style-src 'self' 'unsafe-inline' 允许 inline style(marked 渲染的代码块可能会用到)
|
||||||
- script-src 'self' 'unsafe-inline' 允许同源 + 内联脚本(用于 importmap)
|
- script-src 'self' 'sha256-XXX=' 仅同源 + importmap 内联块(hash 锁死)
|
||||||
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64)
|
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64)
|
||||||
- object-src 'none' 禁止 <object>/<embed>/<applet>(插件 XSS 通道)
|
- object-src 'none' 禁止 <object>/<embed>/<applet>(插件 XSS 通道)
|
||||||
- base-uri 'none' 禁止 <base> 标签劫持相对 URL
|
- base-uri 'none' 禁止 <base> 标签劫持相对 URL
|
||||||
- form-action 'none' 禁止 form 提交外发(即使有 <form> 也是死链)
|
- form-action 'none' 禁止 form 提交外发(即使有 <form> 也是死链)
|
||||||
|
|
||||||
关于 'unsafe-inline':本应用唯一的内联脚本是 importmap(用于映射 CodeMirror 包)。
|
关于 script-src:原 'unsafe-inline' 留下 XSS 后续路径(任何位置注入的
|
||||||
渲染端的实际代码全部走 .js 文件加载。Markdown 内容经 DOMPurify 清洗,<script> 会被剥离。
|
inline script 都被放行)。本应用唯一的内联脚本是 importmap(用于映射
|
||||||
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
|
CodeMirror 包),所以给 importmap inner content 算 SHA-256,写到 CSP
|
||||||
未来若改用 nonce 或 hash,可去除 'unsafe-inline'。
|
里精确放行它一个。改 importmap 必须重算 hash 并同步本行 —— 否则 renderer
|
||||||
|
启动会「Refused to execute inline script」/ importmap 失效 → 大量
|
||||||
|
「Failed to resolve module specifier」。测试 csp-importmap-hash.test.js
|
||||||
|
锁死这条契约。
|
||||||
|
|
||||||
|
⚠️ CRLF 坑(2026-10-16 实踩):本仓库所有源文件是 CRLF 换行,但浏览器
|
||||||
|
HTML tokenizer 在算 CSP hash 前会把脚本内容里的 \r\n / \r 归一化成 \n。
|
||||||
|
所以 CSP 里的 hash 必须是「CRLF→LF 归一化后」的 importmap 内容 SHA-256,
|
||||||
|
而不是 Node 直接读原始 CRLF 字节算出的 hash——两者不同(旧值 7pe/... 是
|
||||||
|
CRLF 字节 hash,浏览器实际算出来是 Fmcb...)。csp-importmap-hash.test.js
|
||||||
|
已改为先归一化换行再算 hash,与浏览器行为一致,避免再次静默漂移。
|
||||||
|
结论:永远用浏览器归一化后的 hash;改 importmap 后必须重算并同步本行。
|
||||||
-->
|
-->
|
||||||
<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:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'sha256-FmcbAaTwesLByPTe0spZSvAlhuyc5I6BR4OJt6o0fB0='; img-src 'self' data: file:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';">
|
||||||
<title>Notes</title>
|
<title>Notes</title>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -79,7 +90,27 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="src/styles.css">
|
<!-- P2-8(audit):12 个分片直接 <link> 并列,替代 styles.css 单入口 + 12 条 @import。
|
||||||
|
@import 是「解析到才发现」的串行瀑布(styles.css 回来 → 才发现 01 → …);
|
||||||
|
12 个 <link> 在 HTML parse 时一次性全部发现,并行 fetch。
|
||||||
|
不用 audit 建议的 preload + onload=this.rel='stylesheet' 异步 hack ——
|
||||||
|
那是「不阻塞渲染」换 FOUC(无样式闪烁),应用外壳首屏白一下比 12 个
|
||||||
|
本地文件并行 fetch 的 ~10ms 更难看。link 仍阻塞渲染,但并行后总耗时
|
||||||
|
≈ 最慢一片,而非 12 片串行之和。
|
||||||
|
级联顺序 = 文档顺序,与原 @import 顺序严格一致(01 tokens 最先 … 12 print 最后)。
|
||||||
|
新增分片时:这里 + src/styles.css(tests/manual 页面仍走单入口)两处同步。 -->
|
||||||
|
<link rel="stylesheet" href="src/styles/01-tokens.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/02-palettes.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/03-base.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/04-toolbar.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/05-sidebar.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/06-markdown.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/07-statusbar.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/08-modal.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/09-misc.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/10-a11y.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/11-ai.css">
|
||||||
|
<link rel="stylesheet" href="src/styles/12-print.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-theme="dark" data-theme-palette="default" data-focus-mode="false">
|
<body data-theme="dark" data-theme-palette="default" data-focus-mode="false">
|
||||||
|
|
||||||
@@ -133,12 +164,16 @@
|
|||||||
<circle cx="11" cy="11" r="7"></circle>
|
<circle cx="11" cy="11" r="7"></circle>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
</svg>
|
</svg>
|
||||||
|
<!-- aria-controls="file-list":让屏幕阅读器在输入时宣告"过滤文件列表",
|
||||||
|
也让用户知道输入框控制的列表元素 id(audit P2-22)。
|
||||||
|
仅声明关系,不改变视觉/键盘行为。 -->
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
id="search-input"
|
id="search-input"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="搜索文件..."
|
placeholder="搜索文件..."
|
||||||
aria-label="搜索文件"
|
aria-label="搜索文件"
|
||||||
|
aria-controls="file-list"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="toolbar-divider" aria-hidden="true"></span>
|
<span class="toolbar-divider" aria-hidden="true"></span>
|
||||||
@@ -146,7 +181,7 @@
|
|||||||
<!-- 应用组:主题 + 置顶 -->
|
<!-- 应用组:主题 + 置顶 -->
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<div class="btn-group" role="group" aria-label="外观">
|
<div class="btn-group" role="group" aria-label="外观">
|
||||||
<button class="btn-icon btn-group-item" id="btn-theme" title="切换主题 (当前: 暗色主题)" aria-label="切换主题">
|
<button class="btn-icon btn-group-item" id="btn-theme" title="切换主题 (当前: 暗色主题)" aria-label="切换主题" data-shortcut="Ctrl+Shift+T">
|
||||||
<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">
|
<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>
|
<circle cx="12" cy="12" r="5"></circle>
|
||||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||||
@@ -176,21 +211,21 @@
|
|||||||
<!-- 视图组:预览 / 编辑 / 双栏(图标 + 文字按钮,比纯图标更易识别) -->
|
<!-- 视图组:预览 / 编辑 / 双栏(图标 + 文字按钮,比纯图标更易识别) -->
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<div class="btn-group btn-group-text" role="group" aria-label="视图模式">
|
<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">
|
<button class="btn-icon-text btn-group-item" id="btn-mode-preview" title="预览模式 (Ctrl+E 切换)" aria-label="预览模式" aria-pressed="true" data-shortcut="Ctrl+E">
|
||||||
<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">
|
<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>
|
<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>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="btn-label">预览</span>
|
<span class="btn-label">预览</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-text btn-group-item" id="btn-mode-edit" title="编辑模式 (Ctrl+E 切换)" aria-label="编辑模式" aria-pressed="false">
|
<button class="btn-icon-text btn-group-item" id="btn-mode-edit" title="编辑模式 (Ctrl+E 切换)" aria-label="编辑模式" aria-pressed="false" data-shortcut="Ctrl+E">
|
||||||
<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">
|
<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="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>
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="btn-label">编辑</span>
|
<span class="btn-label">编辑</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-text btn-group-item" id="btn-mode-split" title="双栏模式 (Ctrl+E 切换)" aria-label="双栏模式" aria-pressed="false">
|
<button class="btn-icon-text btn-group-item" id="btn-mode-split" title="双栏模式 (Ctrl+E 切换)" aria-label="双栏模式" aria-pressed="false" data-shortcut="Ctrl+E">
|
||||||
<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">
|
<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>
|
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||||
<line x1="12" y1="3" x2="12" y2="21"></line>
|
<line x1="12" y1="3" x2="12" y2="21"></line>
|
||||||
@@ -203,7 +238,7 @@
|
|||||||
<span class="toolbar-divider" aria-hidden="true"></span>
|
<span class="toolbar-divider" aria-hidden="true"></span>
|
||||||
|
|
||||||
<!-- 布局组:恢复默认侧栏宽度 + 双栏比例 -->
|
<!-- 布局组:恢复默认侧栏宽度 + 双栏比例 -->
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group" id="tg-reset-layout">
|
||||||
<button class="btn-icon" id="btn-reset-layout" title="重置布局(恢复默认侧栏宽度 + 双栏比例)" aria-label="重置布局">
|
<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">
|
<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">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||||
@@ -245,7 +280,7 @@
|
|||||||
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态(warning 色 + 脉动)。
|
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态(warning 色 + 脉动)。
|
||||||
见 [[feedback-save-button-always-visible]]。
|
见 [[feedback-save-button-always-visible]]。
|
||||||
-->
|
-->
|
||||||
<button class="btn-icon" id="btn-save" title="保存 (Ctrl+S)" aria-label="保存">
|
<button class="btn-icon" id="btn-save" title="保存 (Ctrl+S)" aria-label="保存" data-shortcut="Ctrl+S">
|
||||||
<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">
|
<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>
|
<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="17 21 17 13 7 13 7 21"></polyline>
|
||||||
@@ -267,7 +302,7 @@
|
|||||||
<line x1="14.5" y1="13" x2="9.5" y2="18"></line>
|
<line x1="14.5" y1="13" x2="9.5" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置">
|
<button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置" data-shortcut="Ctrl+,">
|
||||||
<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">
|
<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>
|
<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>
|
<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>
|
||||||
@@ -306,32 +341,58 @@
|
|||||||
<div class="app-shell" id="app">
|
<div class="app-shell" id="app">
|
||||||
<aside class="sidebar" id="sidebar" aria-label="文件与目录">
|
<aside class="sidebar" id="sidebar" aria-label="文件与目录">
|
||||||
<!-- 搜索框已上移到工具栏(见 .toolbar-search),侧栏不再保留 header。
|
<!-- 搜索框已上移到工具栏(见 .toolbar-search),侧栏不再保留 header。
|
||||||
排序工具条 + 新建笔记合并行作为首屏唯一入口,垂直空间专注于文件列表 -->
|
排序工具条作为首屏唯一入口,垂直空间专注于文件列表 -->
|
||||||
<!-- 排序工具条 + 新建笔记:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]])。
|
<!-- 排序工具条:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]])。
|
||||||
新建笔记从独立行合并到排序条右端:节省垂直空间,让侧栏首屏只剩「工具条」一行,
|
新建文件 / 新建文件夹已挪到右键菜单(侧栏空白右键、文件夹条目右键),
|
||||||
主操作 + 列表工具就近成组,视觉密度更紧凑 -->
|
侧栏首屏只剩「排序工具条」一行,主操作 + 列表工具就近成组,视觉密度更紧凑。
|
||||||
<div class="sidebar-sort" role="group" aria-label="文件列表排序与新建">
|
排序模式改用下拉:触发器显示当前排序 + 向下箭头,点击展开 4 项菜单
|
||||||
<button id="btn-sort-name" type="button" class="sidebar-sort-btn is-active" aria-pressed="true" title="按名称 A → Z 排序">
|
(名称 A→Z / Z→A、修改时间 新→旧 / 旧→新)。多选项用下拉比并排按钮更紧凑,
|
||||||
<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">
|
加新排序时只要往 SETTINGS_UI_OPTIONS.fileListSort 加一项。 -->
|
||||||
<path d="M3 6h13M3 12h9M3 18h5"></path>
|
<div class="sidebar-sort" role="group" aria-label="文件列表排序">
|
||||||
<path d="M19 4v16M16 17l3 3 3-3"></path>
|
<div class="sidebar-sort-dropdown" id="sidebar-sort-dropdown">
|
||||||
</svg>
|
<button id="btn-sort-trigger" type="button" class="sidebar-sort-trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="sidebar-sort-menu" title="点击切换排序方式">
|
||||||
<span>名称</span>
|
<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">
|
||||||
</button>
|
<path d="M3 6h13M3 12h9M3 18h5"></path>
|
||||||
<button id="btn-sort-mtime" type="button" class="sidebar-sort-btn" aria-pressed="false" title="按修改时间排序(最近修改在前)">
|
<path d="M19 4v16M16 17l3 3 3-3"></path>
|
||||||
<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">
|
</svg>
|
||||||
<circle cx="12" cy="12" r="9"></circle>
|
<span class="sidebar-sort-trigger-label" id="sidebar-sort-trigger-label">排序:按名称</span>
|
||||||
<polyline points="12 7 12 12 15 14"></polyline>
|
<svg class="sidebar-sort-trigger-chevron" 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">
|
||||||
</svg>
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
<span>修改时间</span>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-new-file" class="sidebar-new-btn sidebar-new-btn--inline" type="button" title="新建笔记 (Ctrl+N)" aria-label="新建笔记">
|
<ul class="sidebar-sort-menu" id="sidebar-sort-menu" role="menu" hidden>
|
||||||
<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">
|
<!-- 4 个选项互斥单选(同一时刻只有一种 sort),
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
role="menuitemradio" + aria-checked 是 ARIA 1.1 标准模式,
|
||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
比 menuitem 更准(menuitem 用于「执行动作」的项) -->
|
||||||
</svg>
|
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="name" tabindex="-1" aria-checked="true">
|
||||||
<span>新建笔记</span>
|
<span class="sidebar-sort-menu-check" aria-hidden="true">
|
||||||
</button>
|
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-sort-menu-text">按名称<span class="sidebar-sort-menu-hint">A → Z</span></span>
|
||||||
|
</li>
|
||||||
|
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="name-desc" tabindex="-1" aria-checked="false">
|
||||||
|
<span class="sidebar-sort-menu-check" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-sort-menu-text">按名称<span class="sidebar-sort-menu-hint">Z → A</span></span>
|
||||||
|
</li>
|
||||||
|
<!-- 分组分隔:「名称」与「修改时间」两类排序之间加 1px line —— 选项一多时
|
||||||
|
视觉分组让扫读速度显著提升;separator role 给读屏「这两组」语义 -->
|
||||||
|
<li role="separator" class="sidebar-sort-menu-separator" aria-hidden="true"></li>
|
||||||
|
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="mtime-desc" tabindex="-1" aria-checked="false">
|
||||||
|
<span class="sidebar-sort-menu-check" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-sort-menu-text">按修改时间<span class="sidebar-sort-menu-hint">新 → 旧</span></span>
|
||||||
|
</li>
|
||||||
|
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="mtime-asc" tabindex="-1" aria-checked="false">
|
||||||
|
<span class="sidebar-sort-menu-check" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-sort-menu-text">按修改时间<span class="sidebar-sort-menu-hint">旧 → 新</span></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Folder Browser(Stage 8):面包屑 + 「回到根」按钮,贴近它作用的文件列表。
|
<!-- Folder Browser(Stage 8):面包屑 + 「回到根」按钮,贴近它作用的文件列表。
|
||||||
@@ -424,6 +485,10 @@
|
|||||||
<div id="ai-splitter" class="ai-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整 AI 面板宽度" aria-controls="app-ai" title="拖动调整 AI 面板宽度" hidden></div>
|
<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">
|
<main class="viewer" id="viewer-main" tabindex="-1">
|
||||||
|
<!-- 阅读进度条:紧贴阅读区顶部,随滚动从左向右填充。
|
||||||
|
视觉上把「当前读到哪」从状态栏的百分比文本升级成一条细线,
|
||||||
|
比数字更直觉;stored 在 .viewer 滚动容器里,滚动时钉在顶部不随内容走。 -->
|
||||||
|
<div class="reading-progress" id="reading-progress" aria-hidden="true"><span class="reading-progress-bar" id="reading-progress-bar"></span></div>
|
||||||
<article id="markdown-body" class="markdown-body" hidden>
|
<article id="markdown-body" class="markdown-body" hidden>
|
||||||
<!-- markdown 渲染输出 -->
|
<!-- markdown 渲染输出 -->
|
||||||
</article>
|
</article>
|
||||||
@@ -441,6 +506,16 @@
|
|||||||
把 <code>.md</code> 文件放入数据文件夹后,<br>
|
把 <code>.md</code> 文件放入数据文件夹后,<br>
|
||||||
会自动出现在左侧列表。
|
会自动出现在左侧列表。
|
||||||
</p>
|
</p>
|
||||||
|
<!-- 空状态 CTA:让"还没动手"的用户有一个最直接的下一步。
|
||||||
|
与 .sidebar-new-btn 共享配色 token,但不复用 width:100% —— 这是居中布局,
|
||||||
|
紧凑的「auto width」按钮视觉重心更聚焦;hover/active 反馈继承全局 .btn 语义。 -->
|
||||||
|
<button id="empty-state-new-btn" class="empty-state-cta" type="button" aria-label="新建笔记 (Ctrl+N)">
|
||||||
|
<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">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
<span>新建笔记</span>
|
||||||
|
</button>
|
||||||
<p class="hint hint-secondary">
|
<p class="hint hint-secondary">
|
||||||
<kbd>Ctrl</kbd>+<kbd>N</kbd> 新建笔记 · <kbd>Ctrl</kbd>+<kbd>F</kbd> 搜索<br>
|
<kbd>Ctrl</kbd>+<kbd>N</kbd> 新建笔记 · <kbd>Ctrl</kbd>+<kbd>F</kbd> 搜索<br>
|
||||||
底部状态栏可切换数据文件夹
|
底部状态栏可切换数据文件夹
|
||||||
@@ -470,6 +545,8 @@
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
|
<!-- P2-23:未配置时显示,点击直接打开设置对话框(onOpenSettings 回调) -->
|
||||||
|
<button class="ai-chat-configure-link" type="button" hidden>⚙ 设置</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
|
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
98
main/ai.js
98
main/ai.js
@@ -60,6 +60,12 @@ const ANTHROPIC_API_VERSION = '2023-06-01';
|
|||||||
// 不再靠注释提醒同步。
|
// 不再靠注释提醒同步。
|
||||||
const { AI_ERROR } = require('../shared/ai-errors.js');
|
const { AI_ERROR } = require('../shared/ai-errors.js');
|
||||||
const nodeCrypto = require('node:crypto');
|
const nodeCrypto = require('node:crypto');
|
||||||
|
// R3 P0-1:aiApiKey 走 safeStorage 加密后,磁盘上是 enc:v1: 包络;config
|
||||||
|
// 加载到 appConfig 时已经 decrypt 一次(见 main/config-store.js loadConfig),
|
||||||
|
// 但这里是直接接 getConfig() 的兜底路径,防御性再 decrypt 一次(passthrough
|
||||||
|
// 明文,包络也安全),避免未来调用方改动时漏掉解密。
|
||||||
|
const secretStore = require('./secret-store.js');
|
||||||
|
const { makeCleanupEpoch } = require('./cleanup-epoch.js');
|
||||||
const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED;
|
const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED;
|
||||||
const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT;
|
const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT;
|
||||||
const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER;
|
const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER;
|
||||||
@@ -201,7 +207,23 @@ function extractRawReply(parsed) {
|
|||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
|
// P2-6 (audit):joinUrl / pickOpenAITokenConfig 每次 AI 请求都重复跑
|
||||||
|
// regex + new URL 解析;同 baseURL / 同 model 在一次会话里反复命中,
|
||||||
|
// LRU 缓存结果。maxSize 64 覆盖「用户试过的 URL + model」组合,无界增长免疫。
|
||||||
|
const { createLruCache } = require('./lru-cache.js');
|
||||||
|
const _joinUrlCache = createLruCache({ maxSize: 64 });
|
||||||
|
const _tokenConfigCache = createLruCache({ maxSize: 64 });
|
||||||
|
|
||||||
function joinUrl(base, path) {
|
function joinUrl(base, path) {
|
||||||
|
const cacheKey = `${base}|${path}`;
|
||||||
|
const hit = _joinUrlCache.get(cacheKey);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const joined = _joinUrlUncached(base, path);
|
||||||
|
_joinUrlCache.set(cacheKey, joined);
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _joinUrlUncached(base, path) {
|
||||||
// P3-3 fix (audit):拆分 query / fragment 后再拼。
|
// P3-3 fix (audit):拆分 query / fragment 后再拼。
|
||||||
// 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages`
|
// 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages`
|
||||||
// (query 后被拼了 path,URL 非法)。query / fragment 也可能在错误回显时
|
// (query 后被拼了 path,URL 非法)。query / fragment 也可能在错误回显时
|
||||||
@@ -367,6 +389,11 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
/** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */
|
/** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */
|
||||||
const timers = new Map();
|
const timers = new Map();
|
||||||
|
// audit fix (P1-5):cancelAll 顺序竞争 epoch 闸。cancelAll 三步非原子
|
||||||
|
// (abort → clear → clear),与新 runEdit 同步阶段加 entry 存在竞态窗口。
|
||||||
|
// cleanupEpoch.invalidate() 让所有「更早 snapshot」的 in-flight runEdit
|
||||||
|
// 自行 bail(isCurrent → false),避免「同 requestId 重试找不到 controller」。
|
||||||
|
const cleanupEpoch = makeCleanupEpoch();
|
||||||
/** audit fix (C4):请求生命周期登记集合(runEdit 入口 → finally),用于
|
/** audit fix (C4):请求生命周期登记集合(runEdit 入口 → finally),用于
|
||||||
* 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的,
|
* 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的,
|
||||||
* runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */
|
* runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */
|
||||||
@@ -386,6 +413,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelAll() {
|
function cancelAll() {
|
||||||
|
// audit fix (P1-5):入口先 invalidate,挡掉「cancelAll 期间新 runEdit
|
||||||
|
// 同步阶段已加入 entry」竞态。新 runEdit 入口会 snapshot 当前 epoch,
|
||||||
|
// await 后 isCurrent → false 时 bail,不污染主流程返回。
|
||||||
|
cleanupEpoch.invalidate();
|
||||||
for (const controller of pending.values()) controller.abort();
|
for (const controller of pending.values()) controller.abort();
|
||||||
pending.clear();
|
pending.clear();
|
||||||
// C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转
|
// C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转
|
||||||
@@ -471,6 +502,15 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
* @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }}
|
* @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }}
|
||||||
*/
|
*/
|
||||||
function pickOpenAITokenConfig(model) {
|
function pickOpenAITokenConfig(model) {
|
||||||
|
const key = String(model || '');
|
||||||
|
const hit = _tokenConfigCache.get(key);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const cfg = _pickOpenAITokenConfigUncached(key);
|
||||||
|
_tokenConfigCache.set(key, cfg);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pickOpenAITokenConfigUncached(model) {
|
||||||
const m = String(model || '').toLowerCase();
|
const m = String(model || '').toLowerCase();
|
||||||
// gpt-5 / o-series → 必须用 max_completion_tokens;上限通常 ≥ 128k;不传 temperature
|
// gpt-5 / o-series → 必须用 max_completion_tokens;上限通常 ≥ 128k;不传 temperature
|
||||||
if (/^(gpt-5|o1|o3|o4)/.test(m)) {
|
if (/^(gpt-5|o1|o3|o4)/.test(m)) {
|
||||||
@@ -605,8 +645,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
// 会回显 URL 或 header),统一走 sanitizeDetail 防止泄露到 toast / 日志
|
// 会回显 URL 或 header),统一走 sanitizeDetail 防止泄露到 toast / 日志
|
||||||
return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` };
|
return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` };
|
||||||
}
|
}
|
||||||
cleanup();
|
// 不再此处 cleanup:fetch 已 resolve 但 body 仍在流式读取,取消窗口内
|
||||||
|
// (响应头到达 → body 读完前)cancel 需要 pending 中的 controller 才能 abort。
|
||||||
|
// 清理统一收口到下方 try/finally(覆盖所有 early-return / 错误路径)。
|
||||||
|
try {
|
||||||
// audit fix (Round 13 / Sec-H3):3xx 重定向在 manual 模式下表现为 status=0
|
// audit fix (Round 13 / Sec-H3):3xx 重定向在 manual 模式下表现为 status=0
|
||||||
// 且 type='opaqueredirect'。明示用户配置错了 Base URL,不要走"读 body 取错误信息"
|
// 且 type='opaqueredirect'。明示用户配置错了 Base URL,不要走"读 body 取错误信息"
|
||||||
// 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。
|
// 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。
|
||||||
@@ -714,6 +756,9 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' };
|
return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' };
|
||||||
}
|
}
|
||||||
return { ok: true, json };
|
return { ok: true, json };
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -752,7 +797,8 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) {
|
async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) {
|
||||||
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
||||||
const baseURL = String(config.aiBaseUrl || '').trim();
|
const baseURL = String(config.aiBaseUrl || '').trim();
|
||||||
const apiKey = String(config.aiApiKey || '').trim();
|
// R3 P0-1:解密 aiApiKey 包络。secretStore.decrypt 对明文 / 空 / 损坏都有兜底。
|
||||||
|
const apiKey = secretStore.decrypt(config.aiApiKey).trim();
|
||||||
const model = String(config.aiModel || '').trim();
|
const model = String(config.aiModel || '').trim();
|
||||||
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
|
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
|
||||||
|
|
||||||
@@ -830,7 +876,8 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) {
|
async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) {
|
||||||
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
||||||
const baseURL = String(config.aiBaseUrl || '').trim();
|
const baseURL = String(config.aiBaseUrl || '').trim();
|
||||||
const apiKey = String(config.aiApiKey || '').trim();
|
// R3 P0-1:解密 aiApiKey 包络。secretStore.decrypt 对明文 / 空 / 损坏都有兜底。
|
||||||
|
const apiKey = secretStore.decrypt(config.aiApiKey).trim();
|
||||||
const model = String(config.aiModel || '').trim();
|
const model = String(config.aiModel || '').trim();
|
||||||
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
|
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
|
||||||
|
|
||||||
@@ -974,23 +1021,46 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
|||||||
cancel(requestId);
|
cancel(requestId);
|
||||||
}
|
}
|
||||||
inFlightRequestIds.add(requestId);
|
inFlightRequestIds.add(requestId);
|
||||||
|
// audit fix (P1-5):拍下 cancelAll epoch 快照。本 runEdit 期间若发生
|
||||||
|
// cancelAll(用户点取消、will-quit 触发),await 之后 isCurrent → false
|
||||||
|
// → 返 ERR_CANCELLED 不应用任何副作用。snapshot 必须在 inFlightRequestIds
|
||||||
|
// 之后但 await 之前 —— 在 inFlightRequestIds.add 之后确保「新请求已
|
||||||
|
// 登记」语义在 cancelAll 之前;invalidate 会同时清 Set 的 entry,但本请求
|
||||||
|
// 已登记的「我自己负责清除」语义不变(finally 仍会 delete,但 Set 已空,
|
||||||
|
// delete 一次 noop)。
|
||||||
|
const myEpoch = cleanupEpoch.snapshot();
|
||||||
|
|
||||||
// 注:token 上限不再用入参 maxTokens(已被 pick*TokenConfig 按模型路由取代)
|
// 注:token 上限不再用入参 maxTokens(已被 pick*TokenConfig 按模型路由取代)
|
||||||
const baseArgs = { prompt, content, filename, requestId, timeoutMs };
|
const baseArgs = { prompt, content, filename, requestId, timeoutMs };
|
||||||
try {
|
try {
|
||||||
|
let result;
|
||||||
if (provider === 'anthropic') {
|
if (provider === 'anthropic') {
|
||||||
return await runAnthropicEdit(baseArgs);
|
result = await runAnthropicEdit(baseArgs);
|
||||||
|
} else if (provider === 'openai') {
|
||||||
|
result = await runOpenAIEdit(baseArgs);
|
||||||
|
} else {
|
||||||
|
// audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。
|
||||||
|
// schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: ERR_NOT_CONFIGURED,
|
||||||
|
message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (provider === 'openai') {
|
// audit fix (P1-5):await 之后,cancelAll 已 invalidate epoch → 本次
|
||||||
return await runOpenAIEdit(baseArgs);
|
// 任务期间发生过 cancel,请求结果不再应用(即使 provider 返了 200,
|
||||||
|
// 也可能是 cancelAll 触发 abort 之前最后一段响应;语义上「用户已取消」
|
||||||
|
// 优先于「网络层成功」)。同时这一步拦截「cancelAll 三步非原子期间
|
||||||
|
// 新请求被误 abort 之后」场景 —— 新 runEdit 拿不到 response,但 abort
|
||||||
|
// 信号让它提前抛 AbortError;这里统一以 ERR_CANCELLED 兜底。
|
||||||
|
if (!cleanupEpoch.isCurrent(myEpoch)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: ERR_CANCELLED,
|
||||||
|
message: '请求已被取消',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
// audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。
|
return result;
|
||||||
// schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: ERR_NOT_CONFIGURED,
|
|
||||||
message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`,
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
// audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId
|
// audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId
|
||||||
// 再来能正常进入;防 Set 缓慢增长。
|
// 再来能正常进入;防 Set 缓慢增长。
|
||||||
|
|||||||
40
main/audit-rate.js
Normal file
40
main/audit-rate.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// P2-7(audit):IPC 高频调用检测 —— 滑动窗口按 key 计数。
|
||||||
|
//
|
||||||
|
// 背景:app:open-config-dir 这类「打开系统目录」的 handler 被 renderer 任意
|
||||||
|
// JS 调到(XSS / devtools 注入)。单次调用无害,但脚本化高频调用能弹出一串
|
||||||
|
// 文件管理器窗口骚扰用户。主进程记一笔滑动窗口计数,超阈值 console.warn
|
||||||
|
// (会同步进 notes-main.log,事后可查)。
|
||||||
|
//
|
||||||
|
// 只检测、不拦截 —— 拦截合法双击/连点会误伤,先只留审计痕迹。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} opts.windowMs 滑动窗口毫秒数
|
||||||
|
* @param {number} opts.threshold 窗口内允许的最大次数;第 threshold+1 次起 exceeded=true
|
||||||
|
* @param {() => number} [opts.now] 时钟注入,默认 Date.now(单测推进时间用)
|
||||||
|
* @returns {{ hit: (key: string) => { count: number, exceeded: boolean } }}
|
||||||
|
*/
|
||||||
|
function createAuditRateTracker({ windowMs, threshold, now } = {}) {
|
||||||
|
const clock = typeof now === 'function' ? now : Date.now;
|
||||||
|
/** @type {Map<string, number[]>} key → 窗口内各次调用的时间戳(升序) */
|
||||||
|
const buckets = new Map();
|
||||||
|
return {
|
||||||
|
hit(key) {
|
||||||
|
const t = clock();
|
||||||
|
const cutoff = t - windowMs;
|
||||||
|
let arr = buckets.get(key);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
buckets.set(key, arr);
|
||||||
|
}
|
||||||
|
// 过期剔除:[cutoff, t) 语义 —— 恰好 windowMs 前的记录视为过期
|
||||||
|
while (arr.length > 0 && arr[0] <= cutoff) arr.shift();
|
||||||
|
arr.push(t);
|
||||||
|
return { count: arr.length, exceeded: arr.length > threshold };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createAuditRateTracker };
|
||||||
70
main/cleanup-epoch.js
Normal file
70
main/cleanup-epoch.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// main/ai.js cancelAll —— 顺序竞争 epoch 闸(audit P1-5)
|
||||||
|
//
|
||||||
|
// 背景:cancelAll 内部三步
|
||||||
|
// 1) for (controller of pending.values()) controller.abort();
|
||||||
|
// 2) pending.clear();
|
||||||
|
// 3) inFlightRequestIds.clear();
|
||||||
|
// 三步非原子。竞态:cancelAll 走到 step 1 刚 abort 完,runEdit 同步阶段
|
||||||
|
// 已经把「新 requestId + 新 controller」加进 pending + inFlightRequestIds →
|
||||||
|
// step 2/3 把这条新 entry 也清掉,runEdit 自以为还在跑,await 不到结果,
|
||||||
|
// 任何同 requestId 重试都找不到 controller。
|
||||||
|
//
|
||||||
|
// 修复:makeCleanupEpoch() 工厂 → { current, snapshot, invalidate, isCurrent }。
|
||||||
|
// - cancelAll 入口 invalidate() → 序号 + 1(任何已 snapshot 的 in-flight 任务
|
||||||
|
// 之后 isCurrent() === false → 自行 bail)
|
||||||
|
// - runEdit 入口 snapshot() 拿当前序号;await 后 isCurrent() === false
|
||||||
|
// (说明本任务期间发生过 cancelAll)→ 不再 apply 任何副作用、直接 return
|
||||||
|
//
|
||||||
|
// 序号从 1 开始自增(0 留给「初始」/「无 epoch」语义)—— isCurrent 比较时
|
||||||
|
// 0 vs 0 也算成立(测试覆盖),但 cancelAll invalidate 之后序号一定 > 0,
|
||||||
|
// 真实 runEdit 拿的 snapshot 至少 1。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个 epoch 计数器对象。cancelAll.invalidate / runEdit.snapshot / runEdit.isCurrent
|
||||||
|
* 配合使用,挡住「cancelAll 中途新请求进入」的竞态。
|
||||||
|
* @returns {{
|
||||||
|
* current: () => number,
|
||||||
|
* snapshot: () => number,
|
||||||
|
* invalidate: () => void,
|
||||||
|
* isCurrent: (snap: number) => boolean,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function makeCleanupEpoch() {
|
||||||
|
let epoch = 0;
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* 当前 epoch 序号。0 = 初始(无 cancelAll 发生过)。
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
current() {
|
||||||
|
return epoch;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 拍下当前 epoch 的快照,用于稍后 isCurrent() 校验。
|
||||||
|
* runEdit 入口调用,await 后比对。
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
snapshot() {
|
||||||
|
return epoch;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 自增 epoch,使所有「更早的 snapshot」isCurrent 失效。
|
||||||
|
* cancelAll 入口调用。
|
||||||
|
*/
|
||||||
|
invalidate() {
|
||||||
|
epoch += 1;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 校验「snap 是否仍是当前 epoch」。
|
||||||
|
* @param {number} snap snapshot() 返回的序号
|
||||||
|
* @returns {boolean} snap === current
|
||||||
|
*/
|
||||||
|
isCurrent(snap) {
|
||||||
|
return epoch === snap;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { makeCleanupEpoch };
|
||||||
@@ -29,6 +29,13 @@ const {
|
|||||||
// 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到
|
// 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到
|
||||||
// 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。
|
// 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。
|
||||||
const { friendlyFsError } = require('../shared/friendly-fs-error.js');
|
const { friendlyFsError } = require('../shared/friendly-fs-error.js');
|
||||||
|
// audit fix (R3 P0-1):aiApiKey 走 safeStorage 加密层。saveConfig 在写盘前
|
||||||
|
// 调 encrypt,loadConfig 在读到包络后调 decrypt —— 上层(ai.js / renderer /
|
||||||
|
// settingsStore)拿到的 appConfig.aiApiKey 始终是明文。详细迁移 / 降级策略
|
||||||
|
// 见 main/secret-store.js 顶部注释。
|
||||||
|
const secretStore = require('./secret-store.js');
|
||||||
|
// P2-1:tmp 路径生成走加密安全随机(与 main/file-ops.js 共享同一工厂)
|
||||||
|
const { generateTmpPath } = require('./generate-tmp-path.js');
|
||||||
|
|
||||||
// 测试注入点:默认走真实 electron.app,单测可以换成 mock app。
|
// 测试注入点:默认走真实 electron.app,单测可以换成 mock app。
|
||||||
// 下划线前缀表示「仅测试用」—— 生产代码不应调用。
|
// 下划线前缀表示「仅测试用」—— 生产代码不应调用。
|
||||||
@@ -75,6 +82,19 @@ function loadConfig() {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
appConfig = coerceLoadedSettings(parsed);
|
appConfig = coerceLoadedSettings(parsed);
|
||||||
|
// R3 P0-1:读盘后立刻把 aiApiKey 包络解回明文。saveConfig 会在写盘前
|
||||||
|
// 重新加密;这里只负责「内存里 appConfig.aiApiKey 始终是明文」——
|
||||||
|
// 上层(ai.js / settingsStore / renderer)零感知。懒迁移:旧版明文
|
||||||
|
// 没前缀,decrypt 原样返回,下一次 saveConfig 自动重新加密。
|
||||||
|
if (appConfig.aiApiKey) {
|
||||||
|
const decrypted = secretStore.decrypt(appConfig.aiApiKey);
|
||||||
|
// 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt 返回 '',
|
||||||
|
// 保留磁盘上的原始 enc:v1:... 包络,避免后续 saveConfig(如改主题)因内存值为 ''
|
||||||
|
// 而把仍有效的加密 Key 覆盖成空串、永久丢失。
|
||||||
|
if (decrypted) {
|
||||||
|
appConfig.aiApiKey = decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json
|
// 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json
|
||||||
// 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir
|
// 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir
|
||||||
@@ -173,7 +193,24 @@ function cleanupStaleTmpFiles() {
|
|||||||
* - 写入失败:appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error }
|
* - 写入失败:appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error }
|
||||||
*/
|
*/
|
||||||
let saveQueue = Promise.resolve();
|
let saveQueue = Promise.resolve();
|
||||||
|
// audit fix (P1-2):isQuitting 闸。before-quit 触发时置 true,新 saveConfig
|
||||||
|
// 立即返一个已 resolve 的 Promise { ok:false, error:'APP_QUITTING' }(与 queue 路径
|
||||||
|
// 返回类型一致,避免调用方对「同步值 vs Promise」分叉处理)——
|
||||||
|
// 否则 renderer 错误风暴期间 IPC `await saveConfig(...)` 会挂在链尾,
|
||||||
|
// before-quit 等不到 save 完成 → quit 阻塞。in-flight 之前的 save 照常
|
||||||
|
// 完成(已 fsync 的文件 rename 完才返回 ok:true;强制 abort 会丢正在写的
|
||||||
|
// tmp)。主进程退出时「最新一个 save 完成」即可接受。
|
||||||
|
let _quitting = false;
|
||||||
|
function _isQuitting() {
|
||||||
|
return _quitting;
|
||||||
|
}
|
||||||
|
function _setIsQuitting(v) {
|
||||||
|
_quitting = !!v;
|
||||||
|
}
|
||||||
function saveConfig(next) {
|
function saveConfig(next) {
|
||||||
|
// 闸开 → 立即返同步值(不挂 await)。注意:不入 queue,避免 in-flight 任务
|
||||||
|
// resolve 后又被这个新任务接走(保持 queue 长度 = 在跑 + 排队中闸前任务数)。
|
||||||
|
if (_quitting) return Promise.resolve({ ok: false, error: 'APP_QUITTING' });
|
||||||
// audit fix (H1):merged 不能在 enqueue 时计算,否则两个并发调用
|
// audit fix (H1):merged 不能在 enqueue 时计算,否则两个并发调用
|
||||||
// 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动
|
// 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动
|
||||||
// (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。
|
// (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。
|
||||||
@@ -194,7 +231,9 @@ function saveConfig(next) {
|
|||||||
// 在 await 后进入 fs.open)会算出相同 tmpPath,后到的 rename 因源文件已被消费
|
// 在 await 后进入 fs.open)会算出相同 tmpPath,后到的 rename 因源文件已被消费
|
||||||
// 而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 亿组合,
|
// 而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 亿组合,
|
||||||
// 同毫秒冲突概率可忽略。main/file-ops.js#atomicWriteFile 也做了对称修复。
|
// 同毫秒冲突概率可忽略。main/file-ops.js#atomicWriteFile 也做了对称修复。
|
||||||
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
// P2-1:tmp 路径走加密安全随机。复用 main/generate-tmp-path.js 工厂
|
||||||
|
// (同 main/file-ops.js 的 atomicWriteFile 路径生成逻辑)。
|
||||||
|
const tmpPath = generateTmpPath(cfgPath);
|
||||||
let fh = null;
|
let fh = null;
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(cfgPath), { recursive: true });
|
await fs.mkdir(path.dirname(cfgPath), { recursive: true });
|
||||||
@@ -204,8 +243,21 @@ function saveConfig(next) {
|
|||||||
// 原子性,但 fsync 缺失让断电后实际文件可能不是新文件)。AI key / 自定义 dataDir
|
// 原子性,但 fsync 缺失让断电后实际文件可能不是新文件)。AI key / 自定义 dataDir
|
||||||
// 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能
|
// 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能
|
||||||
// 保证断电后磁盘上要么是旧 config.json(rename 前断电),要么是完整新文件。
|
// 保证断电后磁盘上要么是旧 config.json(rename 前断电),要么是完整新文件。
|
||||||
fh = await fs.open(tmpPath, 'w');
|
// R5 P1-13:tmp 模式 0o600 (rw owner only)。多用户 Linux/macOS 上其他用户
|
||||||
await fh.writeFile(JSON.stringify(merged, null, 2), 'utf-8');
|
// 能读父目录 → 也能读默认 0o644 的 tmp,里面就是包络后的 AI Key / 整份
|
||||||
|
// 配置(最坏情况 decrypt 失败前的瞬态)。Windows 不强制 mode(用 ACL),
|
||||||
|
// 但设了不冲突。详见 main/file-ops.js 同号修复。
|
||||||
|
fh = await fs.open(tmpPath, 'w', 0o600);
|
||||||
|
// R3 P0-1:写盘前把 aiApiKey 转成包络。**用 toWrite 浅拷贝**,不污染
|
||||||
|
// merged —— merged 还要 return 给 IPC 调用方(renderer settingsStore),
|
||||||
|
// UI 看到的是明文(masked display)而不是 enc:v1:...。appConfig 也保持
|
||||||
|
// 明文,loadConfig 那边解密一次后整轮生命周期对称。
|
||||||
|
// secretStore.encrypt 对空串 / 已是包络 / safeStorage 不可用都有兜底。
|
||||||
|
const toWrite = { ...merged };
|
||||||
|
if (toWrite.aiApiKey) {
|
||||||
|
toWrite.aiApiKey = secretStore.encrypt(toWrite.aiApiKey);
|
||||||
|
}
|
||||||
|
await fh.writeFile(JSON.stringify(toWrite, null, 2), 'utf-8');
|
||||||
await fh.sync();
|
await fh.sync();
|
||||||
await fh.close();
|
await fh.close();
|
||||||
fh = null;
|
fh = null;
|
||||||
@@ -397,8 +449,11 @@ function _invalidateResolveCache() {
|
|||||||
|
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
if (!configLoaded) {
|
if (!configLoaded) {
|
||||||
// 防御:调用方忘了 load() —— 隐式初始化一次
|
// 防御:调用方忘了 init() —— 隐式初始化一次(与 init() 对齐:既 loadConfig
|
||||||
loadConfig();
|
// 也设定 DEFAULT_DATA_DIR,否则 resolveDataDirOrFallback 因 DEFAULT_DATA_DIR 为
|
||||||
|
// null 返回 dir:null,fs-watcher 拿 null 去 watch 会抛错进入重连死循环)。
|
||||||
|
appConfig = loadConfig();
|
||||||
|
if (!DEFAULT_DATA_DIR) DEFAULT_DATA_DIR = getDefaultDataDir();
|
||||||
}
|
}
|
||||||
return appConfig;
|
return appConfig;
|
||||||
}
|
}
|
||||||
@@ -535,6 +590,8 @@ function _reset() {
|
|||||||
// 跨 case 串扰(renderer 端 / 用户态不会触发,但单测 _reset 后再 save
|
// 跨 case 串扰(renderer 端 / 用户态不会触发,但单测 _reset 后再 save
|
||||||
// 必须从干净 queue 开始)。
|
// 必须从干净 queue 开始)。
|
||||||
saveQueue = Promise.resolve();
|
saveQueue = Promise.resolve();
|
||||||
|
// P1-2:闸也要重置,避免一个 case 置 true 后污染下一个 case。
|
||||||
|
_quitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -560,4 +617,6 @@ module.exports = {
|
|||||||
seedDefaultDataDir,
|
seedDefaultDataDir,
|
||||||
_reset,
|
_reset,
|
||||||
_setApp,
|
_setApp,
|
||||||
|
_isQuitting,
|
||||||
|
_setIsQuitting,
|
||||||
};
|
};
|
||||||
|
|||||||
69
main/file-list-cache.js
Normal file
69
main/file-list-cache.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// file:list 分页缓存(audit R7a P1-20)
|
||||||
|
//
|
||||||
|
// 旧版 file:list 一次性返回全部 entries + files,10000 文件 ≈ 1.5-2MB IPC
|
||||||
|
// payload,结构化克隆 + 渲染端 JSON.parse 都要 30-100ms,首次启动 cold paint
|
||||||
|
// 明显卡顿。改成「首次 200 + 分页」+ 模块级 cache:scan 一次切片多次返回,
|
||||||
|
// 避免 loadMore 反复触发全量 scanDir。
|
||||||
|
//
|
||||||
|
// 缓存失效:调用方在 file:write / file:create / file:delete / file:rename 成功
|
||||||
|
// 后调 invalidate(),下一次 file:list / file:list:more 重扫。简化策略:不做
|
||||||
|
// 精细 dirty entry 合并,整条缓存丢掉。scanDir 1-3s 是「极端大目录(OneDrive
|
||||||
|
// 1w 文件)」的最坏值,普通 < 100ms,重扫成本可控。
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// - cache 严格按 dir 区分(Folder Browser 走 file:scan-dir 用 absDir,与根
|
||||||
|
// file:list 的 dataRoot 是不同 dir,互不污染)。
|
||||||
|
// - 不做 TTL:写操作主动失效是单一来源;fs-watcher 的 emitFilesChanged 不
|
||||||
|
// 影响 file:list cache(cache 只服务「分页拉取」,fs-watcher 走的是另
|
||||||
|
// 一条 IPC 通道)。
|
||||||
|
// - slice 边界由调用方保证(offset / limit 已经在 main.js file:list handler
|
||||||
|
// 里做过 safe* 夹紧);本模块只做"拿/建缓存 → 切片"。
|
||||||
|
|
||||||
|
/** @typedef {{ ok:true, dir:string, entries:Array } | { ok:false, error:string, code?:string, message?:string }} ScanResult */
|
||||||
|
|
||||||
|
/** 缓存项 */
|
||||||
|
let _cache = null; // { dir, entries, files, total, at }
|
||||||
|
|
||||||
|
/** 强制失效(写操作成功后调用) */
|
||||||
|
function invalidate() {
|
||||||
|
_cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉或建 file:list 缓存。dir 变化或缓存被显式失效时重扫。
|
||||||
|
* @param {string} dir - 绝对路径
|
||||||
|
* @param {(dir: string) => Promise<ScanResult>} scanFn - scanDir 注入(测试可换 mock)
|
||||||
|
* @returns {Promise<
|
||||||
|
* { dir:string, entries:Array, files:Array, total:number, at:number }
|
||||||
|
* | { error: ScanResult }
|
||||||
|
* >}
|
||||||
|
*/
|
||||||
|
async function getOrBuild(dir, scanFn) {
|
||||||
|
if (_cache && _cache.dir === dir) return _cache;
|
||||||
|
const result = await scanFn(dir);
|
||||||
|
if (!result.ok) return { error: result };
|
||||||
|
const entries = result.entries || [];
|
||||||
|
// files = 非文件夹条目(侧栏历史逻辑兼容:state.files 仅含文件)
|
||||||
|
const files = entries
|
||||||
|
.filter((e) => !e.isFolder)
|
||||||
|
.map((e) => ({ name: e.name, path: e.path, size: e.size, mtimeMs: e.mtimeMs, entryType: e.entryType }));
|
||||||
|
_cache = { dir, entries, files, total: entries.length, at: Date.now() };
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拿一份当前缓存的快照(不改)。test hook:单测断言缓存是否被命中/失效。 */
|
||||||
|
function peek() {
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** test hook:强制设缓存(不调 scanFn)。 */
|
||||||
|
function _setForTest(cache) {
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrBuild,
|
||||||
|
invalidate,
|
||||||
|
peek,
|
||||||
|
_setForTest,
|
||||||
|
};
|
||||||
321
main/file-ops.js
321
main/file-ops.js
@@ -25,6 +25,9 @@ const path = require('path');
|
|||||||
const { EDITABLE_EXTS } = require('../shared/extension-lists.js');
|
const { EDITABLE_EXTS } = require('../shared/extension-lists.js');
|
||||||
// 单一事实源:errno → 中文提示(main + renderer 共享,preload 过桥)
|
// 单一事实源:errno → 中文提示(main + renderer 共享,preload 过桥)
|
||||||
const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js');
|
const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js');
|
||||||
|
// P2-1:tmp 路径生成走加密安全随机(crypto.randomUUID),不再用 Math.random
|
||||||
|
const { generateTmpPath } = require('./generate-tmp-path.js');
|
||||||
|
const { fsyncParentDir } = require('./fsync-parent-dir.js');
|
||||||
|
|
||||||
/** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */
|
/** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
@@ -32,15 +35,17 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|||||||
/**
|
/**
|
||||||
* atomicWriteFile 产生的 tmp 文件名匹配规则:`<name>.<editableExt>.tmp.<pid>.<ts>.<rand>`。
|
* atomicWriteFile 产生的 tmp 文件名匹配规则:`<name>.<editableExt>.tmp.<pid>.<ts>.<rand>`。
|
||||||
* cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。
|
* cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。
|
||||||
* 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 6 字符小写 base36。
|
* 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 generateTmpPath 生成的
|
||||||
|
* 36 位带连字符 UUID(见 main/generate-tmp-path.js,`crypto.randomUUID()`,P2-1 升级而来,
|
||||||
|
* 旧版是 6 字符小写 base36)。
|
||||||
*
|
*
|
||||||
* 旧版 `\.tmp\.\d+\.\d+\.[a-z0-9]+$` 太宽 —— 把 `report.tmp.2024.1234567.abcdef` 这种
|
* 旧版 `\.tmp\.\d+\.\d+\.[a-z0-9]+$` 太宽 —— 把 `report.tmp.2024.1234567.abcdef` 这种
|
||||||
* 用户合法文件也匹配上,24h 跨进程阈值后会被静默 unlink。要求 .tmp. 前一段是
|
* 用户合法文件也匹配上,24h 跨进程阈值后会被静默 unlink。要求 .tmp. 前一段是
|
||||||
* EDITABLE_EXTS 之一(白名单扩展名)把误伤清零。
|
* EDITABLE_EXTS 之一(白名单扩展名)把误伤清零;rand 段允许连字符以匹配 UUID。
|
||||||
*/
|
*/
|
||||||
const ATOMIC_TMP_RE = new RegExp(
|
const ATOMIC_TMP_RE = new RegExp(
|
||||||
'\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') +
|
'\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') +
|
||||||
')\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]{1,8}$',
|
')\\.tmp\\.\\d+\\.\\d+\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
'i',
|
'i',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -141,6 +146,33 @@ async function assertNoSymlinkAncestor(filePath, stopAt) {
|
|||||||
// dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。
|
// dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。
|
||||||
// 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。
|
// 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
|
|
||||||
|
// 边界情形(filePath === stopAt,例如 file:list 以 (dataRoot, dataRoot) 校验
|
||||||
|
// 数据根自身):只查 filePath 自身是否 symlink,不再向上遍历祖先。
|
||||||
|
// 旧逻辑在边界相等时跳过自检查、却从 dirname(filePath) 一路 lstat 到根 ——
|
||||||
|
// 既漏掉对 dataRoot 自身的检查(审计威胁模型的核心场景),又会把合法的
|
||||||
|
// symlinked /home 等祖先误判成 SYMLINK_NOT_ALLOWED,导致根目录列表永久失败。
|
||||||
|
const isBoundary = !!stopResolved && resolvedPath === stopResolved;
|
||||||
|
if (isBoundary) {
|
||||||
|
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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (!stopResolved || resolvedPath !== stopResolved) {
|
if (!stopResolved || resolvedPath !== stopResolved) {
|
||||||
try {
|
try {
|
||||||
const stSelf = await fs.lstat(resolvedPath);
|
const stSelf = await fs.lstat(resolvedPath);
|
||||||
@@ -429,6 +461,53 @@ async function scanFiles(dir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易并发限流器:把 N 个 promise 包成「最多 concurrency 个同时在飞」的执行器。
|
||||||
|
* 不引第三方依赖(p-limit / p-queue 等)—— 全文只有 scanDir 一个调用点,
|
||||||
|
* 20 行内联比 1 个新 dep 更可控。
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 入队 → 立即调度,若活跃数 < concurrency 则启动执行
|
||||||
|
* - 任意 fn resolve / reject → 拉下一个
|
||||||
|
* - fn 异常会传给调用方(rethrow),但「下一个」仍会被调度(finally 语义)
|
||||||
|
*
|
||||||
|
* @param {number} concurrency - 同时在飞的最大任务数(必须 ≥ 1)
|
||||||
|
* @returns {(fn: () => Promise<T>) => Promise<T>}
|
||||||
|
*/
|
||||||
|
function pLimit(concurrency) {
|
||||||
|
if (!Number.isFinite(concurrency) || concurrency < 1) {
|
||||||
|
throw new Error('pLimit: concurrency 必须 ≥ 1');
|
||||||
|
}
|
||||||
|
const queue = [];
|
||||||
|
let active = 0;
|
||||||
|
const pump = () => {
|
||||||
|
while (active < concurrency && queue.length > 0) {
|
||||||
|
const job = queue.shift();
|
||||||
|
active += 1;
|
||||||
|
// resolve / reject 都触发 pump;catch 防止 fn 抛错后 active 永远不归零
|
||||||
|
job.fn().then(
|
||||||
|
(v) => { active -= 1; job.resolve(v); pump(); },
|
||||||
|
(e) => { active -= 1; job.reject(e); pump(); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (fn) => new Promise((resolve, reject) => {
|
||||||
|
queue.push({ fn, resolve, reject });
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** scanDir 单批并发上限(audit R7a P1-17)。
|
||||||
|
*
|
||||||
|
* 背景:libuv 默认 4 worker threads,scanDir 同时发 N 个 fs.stat 会被 libuv
|
||||||
|
* 串行排队 —— SMB / OneDrive 单次 stat 50-200ms 时 10000 文件 → 30-90s。
|
||||||
|
* 并发 32 让网络盘上 stat 仍能流水线(多网卡 in-flight),又不至于打满
|
||||||
|
* libuv 池 / fd 上限。32 是个保守值:实测 64 在 4 核机器仍 OK,128 在 Windows
|
||||||
|
* 上偶发 ETIMEDOUT(WinHTTP max connections per server 默认 6 / process 翻倍
|
||||||
|
* 也只有 12),32 是「在所有平台稳定」的最大公约数。
|
||||||
|
*/
|
||||||
|
const SCAN_DIR_CONCURRENCY = 32;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。
|
* 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。
|
||||||
* Folder Browser(2026-08)的核心入口。
|
* Folder Browser(2026-08)的核心入口。
|
||||||
@@ -445,6 +524,9 @@ async function scanFiles(dir) {
|
|||||||
* - 排序:文件夹在前(按名称),文件在后(按名称);
|
* - 排序:文件夹在前(按名称),文件在后(按名称);
|
||||||
* 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉
|
* 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉
|
||||||
*
|
*
|
||||||
|
* 性能(audit R7a P1-17):fs.stat 用 pLimit(32) 限流,避免 libuv 4 worker
|
||||||
|
* 排队把 SMB / OneDrive 30-90s 拖到 1-3s。
|
||||||
|
*
|
||||||
* @param {string} absDir - 数据目录下某一层的绝对路径
|
* @param {string} absDir - 数据目录下某一层的绝对路径
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* ok: true,
|
* ok: true,
|
||||||
@@ -491,7 +573,14 @@ async function scanDir(absDir) {
|
|||||||
safeDirents.push(d);
|
safeDirents.push(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = await Promise.all(safeDirents.map(async (d) => {
|
// audit fix (R7a P1-17):fs.stat 走 pLimit(SCAN_DIR_CONCURRENCY) + allSettled。
|
||||||
|
// 旧版 Promise.all(N) 在 SMB / OneDrive 上 N 个 stat 串行排队(libuv 4 worker
|
||||||
|
// 默认),10000 文件 30-90s;改 32 并发后实测 1-3s。
|
||||||
|
// allSettled:单个 stat 抛错(ENOENT 经典 readdir 与 stat 之间被删)不影响
|
||||||
|
// 整批,外层 .flatMap 过滤 fulfilled + truthy 即可,与原 .filter(Boolean) 语义
|
||||||
|
// 一致(fulfilled=null → drop;rejected → drop;fulfilled=object → keep)。
|
||||||
|
const limit = pLimit(SCAN_DIR_CONCURRENCY);
|
||||||
|
const tasks = safeDirents.map((d) => limit(async () => {
|
||||||
const fullPath = path.join(absDir, d.name);
|
const fullPath = path.join(absDir, d.name);
|
||||||
if (d.isDirectory()) {
|
if (d.isDirectory()) {
|
||||||
return {
|
return {
|
||||||
@@ -520,8 +609,8 @@ async function scanDir(absDir) {
|
|||||||
// 其它(socket / fifo / block device 等)—— 不展示
|
// 其它(socket / fifo / block device 等)—— 不展示
|
||||||
return null;
|
return null;
|
||||||
}));
|
}));
|
||||||
|
const settled = await Promise.allSettled(tasks);
|
||||||
const list = entries.filter(Boolean);
|
const list = settled.flatMap((r) => (r.status === 'fulfilled' && r.value) ? [r.value] : []);
|
||||||
// 排序:folder 在前、file 在后,同组内按 zh-CN locale
|
// 排序:folder 在前、file 在后,同组内按 zh-CN locale
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||||
@@ -572,6 +661,12 @@ function resolveDirRelative(relDir, dataRoot) {
|
|||||||
* 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。
|
* 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。
|
||||||
* 渲染端用于比对 fs-watcher 推送的 relDir 字段。
|
* 渲染端用于比对 fs-watcher 推送的 relDir 字段。
|
||||||
*
|
*
|
||||||
|
* Windows case-insensitive:dataRoot 存盘时可能被大小写规范化(NTFS 实际路径
|
||||||
|
* 大小写取决于创建时),与 absDir 比对时若不做大小写无关比较,会把同盘同一目录
|
||||||
|
* 误判成"不在 dataRoot 下"、返回 '' —— renderer 后续 onFilesChanged 拿到空
|
||||||
|
* relDir 会把「旧目录的文件被移除」误判为「当前文件被外部删除」而清空编辑器。
|
||||||
|
* 比较走 toLowerCase,结果保留原大小写(POSIX 风格 + 可读性)。
|
||||||
|
*
|
||||||
* @param {string} absDir
|
* @param {string} absDir
|
||||||
* @param {string} dataRoot
|
* @param {string} dataRoot
|
||||||
* @returns {string} POSIX 相对路径,根目录时为 ''
|
* @returns {string} POSIX 相对路径,根目录时为 ''
|
||||||
@@ -580,9 +675,14 @@ function toRelativeDir(absDir, dataRoot) {
|
|||||||
if (!absDir || !dataRoot) return '';
|
if (!absDir || !dataRoot) return '';
|
||||||
const a = absDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
const a = absDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
if (a === r) return '';
|
// Windows 大小写无关比较:dataRoot 与 absDir 指向同一盘同一目录但大小写不同时
|
||||||
|
// (NTFS 用户主目录就常见),不能误判成"不在 dataRoot 下"。
|
||||||
|
const icmp = process.platform === 'win32'
|
||||||
|
? (x, y) => x.toLowerCase() === y.toLowerCase()
|
||||||
|
: (x, y) => x === y;
|
||||||
|
if (icmp(a, r)) return '';
|
||||||
const prefix = r + '/';
|
const prefix = r + '/';
|
||||||
if (a.startsWith(prefix)) return a.slice(prefix.length);
|
if (icmp(a.slice(0, prefix.length), prefix)) return a.slice(r.length + 1);
|
||||||
// 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支
|
// 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -605,15 +705,20 @@ function toRelativeDir(absDir, dataRoot) {
|
|||||||
* @param {string} content UTF-8 文本
|
* @param {string} content UTF-8 文本
|
||||||
*/
|
*/
|
||||||
async function atomicWriteFile(dst, content) {
|
async function atomicWriteFile(dst, content) {
|
||||||
// audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证
|
// P2-1:tmp 路径走加密安全随机。抽 main/generate-tmp-path.js 工厂,
|
||||||
// 跨进程 / 跨毫秒唯一,同进程同毫秒并发(如自动保存触发的 saveConfig 与
|
// 内部用 crypto.randomUUID()(128-bit 加密安全,8-4-4-4-12 hex)替代
|
||||||
// 用户手动 Ctrl+S 撞在同一毫秒)会算出相同 tmpPath,后到的 rename 因源文件
|
// 旧的 Math.random().toString(36).slice(2, 8) 6 位 base36 后缀。pid +
|
||||||
// 已被消费而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21
|
// Date.now() 跨进程/跨毫秒去重,UUID 兜住同进程同毫秒并发。路径格式
|
||||||
// 亿组合,同毫秒冲突概率可忽略。main/config-store.js#saveConfig 也做了对称修复。
|
// `${dst}.tmp.${pid}.${ts}.${randomId}` 保持不变,fs-watcher 旧逻辑
|
||||||
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
// 只匹配 `.tmp.*` 前缀不受影响。
|
||||||
|
const tmpPath = generateTmpPath(dst);
|
||||||
let fh;
|
let fh;
|
||||||
try {
|
try {
|
||||||
fh = await fs.open(tmpPath, 'w');
|
// R5 P1-13:tmp 文件 mode 0o600 (rw owner only)。多用户 Linux/macOS
|
||||||
|
// 上其他用户能读父目录 → 也能读默认 0o644 的 tmp,里面可能含 AI Key
|
||||||
|
// 包络 / 用户笔记内容。Windows 不强制 mode(用 ACL),但设了不冲突;
|
||||||
|
// 跨平台语义一致。
|
||||||
|
fh = await fs.open(tmpPath, 'w', 0o600);
|
||||||
await fh.writeFile(content, 'utf-8');
|
await fh.writeFile(content, 'utf-8');
|
||||||
// fsync 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了
|
// fsync 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了
|
||||||
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
|
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
|
||||||
@@ -629,28 +734,20 @@ async function atomicWriteFile(dst, content) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await renameWithRetry(tmpPath, dst);
|
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') {
|
|
||||||
// Round 13 fix (H5):dirFh.sync() 抛错时 close() 不会执行 → fd 泄漏。
|
|
||||||
// 长跑保存下磁盘压力大 / USB 抖动会让 sync 间歇失败,多次累加触发
|
|
||||||
// EMFILE;统一走 try/finally 兜底 close。
|
|
||||||
const dirFh = await fs.open(path.dirname(dst), 'r');
|
|
||||||
try {
|
|
||||||
await dirFh.sync();
|
|
||||||
} finally {
|
|
||||||
try { await dirFh.close(); } catch { /* close 失败也无能为力 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// rename 失败:清理残余 tmp(不影响下次保存)
|
// rename 失败:清理残余 tmp(不影响下次保存)
|
||||||
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
|
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
// 父目录 fsync(audit Round 4 P0-1):rename 已在 try/catch 之外,父目录
|
||||||
|
// fsync 失败不向上抛 —— 文件已成功 rename 落盘,仅「新名可能不持久化」,
|
||||||
|
// 走 main/fsync-parent-dir.js#fsyncParentDir(与 main.js file:rename /
|
||||||
|
// file:move / file:copy 同一份语义:win32 skip、open→sync→close、close
|
||||||
|
// 失败静默、open/sync 失败返 {ok:false, error})。
|
||||||
|
const fsyncResult = await fsyncParentDir(dst);
|
||||||
|
if (!fsyncResult.ok) {
|
||||||
|
console.warn('[file-ops] 父目录 fsync 失败(不影响写入成功):', fsyncResult.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,6 +773,51 @@ async function renameWithRetry(src, dst) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* audit fix (Folder move/copy):递归复制一个目录到目标目录。
|
||||||
|
* 用于 file:copy 复制整个文件夹(含子目录)的场景。
|
||||||
|
*
|
||||||
|
* 安全:
|
||||||
|
* - 拒绝跟随符号链接 —— 既跳过 src 树里的 symlink 文件/目录(不 follow 到
|
||||||
|
* dataDir 外部),也拒绝 symlink 目录作为复制目的地(由调用方在入口已校验,
|
||||||
|
* 这里再防御一层:递归途中遇到 symlink 直接跳过,绝不 follow)。
|
||||||
|
* - 调用方必须保证 srcAbs / destAbs 都在数据目录内、且 srcAbs 本身不是 symlink。
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 普通文件 → fs.copyFile
|
||||||
|
* - 普通目录 → mkdir(recursive:false,父已被建好)+ 递归
|
||||||
|
* - 其它(socket / fifo / device / symlink)→ 跳过,不复制
|
||||||
|
*
|
||||||
|
* @param {string} srcAbs - 源目录绝对路径(已确认是真实目录)
|
||||||
|
* @param {string} destAbs - 目标目录绝对路径(已确认不存在,由调用方 mkdir 好父级)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function recursiveCopy(srcAbs, destAbs) {
|
||||||
|
const dirents = await fs.readdir(srcAbs, { withFileTypes: true });
|
||||||
|
// 父目录已存在,确保目标目录本体存在(destAbs 是全新的空目录名)
|
||||||
|
await fs.mkdir(destAbs, { recursive: false });
|
||||||
|
for (const d of dirents) {
|
||||||
|
const srcChild = path.join(srcAbs, d.name);
|
||||||
|
const destChild = path.join(destAbs, d.name);
|
||||||
|
// symlink 一律跳过(lstat 探真实类型,不 follow)
|
||||||
|
if (d.isSymbolicLink()) continue;
|
||||||
|
if (d.isDirectory()) {
|
||||||
|
// 递归前再确认不是 symlink 目录(withFileTypes 的 isDirectory 已排除 symlink,
|
||||||
|
// 但防御性 stat 一层更稳)
|
||||||
|
try {
|
||||||
|
const st = await fs.lstat(srcChild);
|
||||||
|
if (st.isSymbolicLink()) continue;
|
||||||
|
} catch {
|
||||||
|
continue; // 读不到就跳过,不阻断整棵复制
|
||||||
|
}
|
||||||
|
await recursiveCopy(srcChild, destChild);
|
||||||
|
} else if (d.isFile()) {
|
||||||
|
await fs.copyFile(srcChild, destChild);
|
||||||
|
}
|
||||||
|
// 其它类型(FIFO / 设备 / socket)→ 不复制
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除数据目录里残留的 `*.tmp.<pid>.<ts>.<rand>` 文件。
|
* 删除数据目录里残留的 `*.tmp.<pid>.<ts>.<rand>` 文件。
|
||||||
* 由 main.js 在启动时 / 切换数据目录时调用一次。
|
* 由 main.js 在启动时 / 切换数据目录时调用一次。
|
||||||
@@ -701,11 +843,12 @@ async function cleanupStaleFileTmpFiles(dataDir) {
|
|||||||
const STALE_THRESHOLD_MS = 60_000;
|
const STALE_THRESHOLD_MS = 60_000;
|
||||||
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// 只匹配 atomicWriteFile 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`。
|
// 只匹配 atomicWriteFile 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`,
|
||||||
|
// rand 为 generateTmpPath 生成的 36 位带连字符 UUID(P2-1 起,非旧版 base36)。
|
||||||
// audit fix (Tmp-L2):旧版 `/\.tmp\.\d+\.\d+\.[a-z0-9]+$/i` 太宽,
|
// audit fix (Tmp-L2):旧版 `/\.tmp\.\d+\.\d+\.[a-z0-9]+$/i` 太宽,
|
||||||
// 会把用户自己命名 / 同步盘生成 / 备份工具遗留的 `report.tmp.2024.1234567.abcdef`
|
// 会把用户自己命名 / 同步盘生成 / 备份工具遗留的 `report.tmp.2024.1234567.abcdef`
|
||||||
// 这种正常文件当成孤儿静默 unlink。必须要求 .tmp. 前面是 EDITABLE_EXTS
|
// 这种正常文件当成孤儿静默 unlink。必须要求 .tmp. 前面是 EDITABLE_EXTS
|
||||||
// 之一(白名单扩展名),且 tmp 段是 pid.ts.rand 严格三段数字 + 小写 base36。
|
// 之一(白名单扩展名)。
|
||||||
// config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向
|
// config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向
|
||||||
// shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。
|
// shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。
|
||||||
if (!ATOMIC_TMP_RE.test(f)) continue;
|
if (!ATOMIC_TMP_RE.test(f)) continue;
|
||||||
@@ -753,6 +896,113 @@ function friendlyWriteError(e) {
|
|||||||
return sharedFriendlyFsError(e && e.code, e && e.message);
|
return sharedFriendlyFsError(e && e.code, e && e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件夹名避让(重命名时同分支),与 resolveFolderName 完全对称。
|
||||||
|
*
|
||||||
|
* 文件夹的「合法名」语义比 file 简单:用户输入什么就用什么,没有强制扩展名;
|
||||||
|
* Windows 保留字符 / 保留设备名 / 控制字符 / 路径分隔符沿用 file 同一套校验。
|
||||||
|
*
|
||||||
|
* 重名避让时整段 name 加 (2)(3)...,没有 "基础名 + 扩展名" 的两段切割。
|
||||||
|
*
|
||||||
|
* @param {string} raw
|
||||||
|
* @param {string} parentDir - 父目录绝对路径
|
||||||
|
* @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>}
|
||||||
|
*/
|
||||||
|
async function resolveRenameFolderName(raw, parentDir) {
|
||||||
|
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: '文件夹名包含非法字符' };
|
||||||
|
}
|
||||||
|
if (WIN_RESERVED_DEVICE_NAMES.has(name.toUpperCase())) {
|
||||||
|
return { ok: false, error: `"${name}" 是 Windows 保留设备名,不允许作为文件夹名` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = name;
|
||||||
|
let counter = 2;
|
||||||
|
for (;;) {
|
||||||
|
const fullPath = path.join(parentDir, candidate);
|
||||||
|
if (!isWithinDataDir(fullPath, parentDir)) {
|
||||||
|
return { ok: false, error: '非法路径' };
|
||||||
|
}
|
||||||
|
const occupancy = await pathOccupancy(fullPath);
|
||||||
|
if (occupancy === 'free') {
|
||||||
|
return { ok: true, path: fullPath, name: candidate };
|
||||||
|
}
|
||||||
|
candidate = `${name} (${counter})`;
|
||||||
|
counter += 1;
|
||||||
|
if (counter > 1000) return { ok: false, error: '重名次数过多' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件夹名清洗 + 重名避让。
|
||||||
|
*
|
||||||
|
* 与 file version 的 resolveFileName 完全同源:路径分隔符 / Windows 保留字符 /
|
||||||
|
* 保留设备名 / 控制字符 / 非空 一律预先拒绝。
|
||||||
|
*
|
||||||
|
* 与 file version 的关键差别:
|
||||||
|
* - 不强制补扩展名:用户输入 "projects" / "PROJECTS" / "2026.drafts" 一律原样保留
|
||||||
|
* - 重名避让只加 (2)/(3)...,不分基础名 / 扩展名切割(文件夹名不切)
|
||||||
|
* - 占用检测 pathOccupancy 同样适用:父目录里已有同名 file 也会触发避让
|
||||||
|
*
|
||||||
|
* 用途:folder:create IPC handler 调它来拿最终路径 + name,主进程据此 mkdir。
|
||||||
|
*
|
||||||
|
* @param {string} raw
|
||||||
|
* @param {string} parentDir - 父目录绝对路径(新建文件夹的落点)
|
||||||
|
* @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>}
|
||||||
|
*/
|
||||||
|
async function resolveFolderName(raw, parentDir) {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) {
|
||||||
|
return { ok: false, error: '文件夹名不能为空' };
|
||||||
|
}
|
||||||
|
const name = raw.trim();
|
||||||
|
// 拒绝路径分隔符与 .. 段
|
||||||
|
if (/[/\\]/.test(name) || name.includes('..')) {
|
||||||
|
return { ok: false, error: '文件夹名不能包含路径分隔符或 ..' };
|
||||||
|
}
|
||||||
|
// 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 保留设备名:文件夹不带扩展名分支,直接整段判断;带点(DRAFT.2026)也按整段判断
|
||||||
|
//(device name 不带点的语义由 NTFS 决定 —— 但 Notes 里不刻意区分,统按整段拒保守)
|
||||||
|
if (WIN_RESERVED_DEVICE_NAMES.has(name.toUpperCase())) {
|
||||||
|
return { ok: false, error: `"${name}" 是 Windows 保留设备名,不允许作为文件夹名` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = name;
|
||||||
|
let counter = 2;
|
||||||
|
for (;;) {
|
||||||
|
const fullPath = path.join(parentDir, candidate);
|
||||||
|
if (!isWithinDataDir(fullPath, parentDir)) {
|
||||||
|
return { ok: false, error: '非法路径' };
|
||||||
|
}
|
||||||
|
const occupancy = await pathOccupancy(fullPath);
|
||||||
|
if (occupancy === 'free') {
|
||||||
|
return { ok: true, path: fullPath, name: candidate };
|
||||||
|
}
|
||||||
|
// file / directory 都视为占用
|
||||||
|
candidate = `${name} (${counter})`;
|
||||||
|
counter += 1;
|
||||||
|
if (counter > 1000) return { ok: false, error: '重名次数过多' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MAX_FILE_SIZE,
|
MAX_FILE_SIZE,
|
||||||
EDITABLE_EXTS,
|
EDITABLE_EXTS,
|
||||||
@@ -763,12 +1013,15 @@ module.exports = {
|
|||||||
isWithinDataDir,
|
isWithinDataDir,
|
||||||
resolveFileName,
|
resolveFileName,
|
||||||
resolveRenameName,
|
resolveRenameName,
|
||||||
|
resolveFolderName,
|
||||||
|
resolveRenameFolderName,
|
||||||
scanFiles,
|
scanFiles,
|
||||||
scanDir,
|
scanDir,
|
||||||
resolveDirRelative,
|
resolveDirRelative,
|
||||||
toRelativeDir,
|
toRelativeDir,
|
||||||
atomicWriteFile,
|
atomicWriteFile,
|
||||||
renameWithRetry,
|
renameWithRetry,
|
||||||
|
recursiveCopy,
|
||||||
cleanupStaleFileTmpFiles,
|
cleanupStaleFileTmpFiles,
|
||||||
friendlyWriteError,
|
friendlyWriteError,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,17 @@ const fsSync = require('fs');
|
|||||||
// 把登记 key 和 dirWatcher 上报的 absPath 都归一化到同一形态。
|
// 把登记 key 和 dirWatcher 上报的 absPath 都归一化到同一形态。
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const DIR_POLL_INTERVAL_MS = 2000;
|
// audit fix (R7a P1-19):自适应轮询间隔。活动期间(最近 POLL_ACTIVE_WINDOW_MS
|
||||||
|
// 内有 fs.watch 事件 / 探测到列表变化)用 500ms 高频轮询,静止期 5s 一次。
|
||||||
|
// 旧版固定 2s 在 SMB / OneDrive 上常因 scanDir 自身 > 2s + 重入保护 drop 5-15 轮
|
||||||
|
// 兜底信号(fs.watch 不可靠环境的唯一变化源),侧栏 stale 数分钟。自适应后:
|
||||||
|
// - 文件刚改完时高频扫,能在 1s 内反映
|
||||||
|
// - 静止后回到 5s 节流,省 SMB 流量 / IO
|
||||||
|
// - 关键:「不 drop」—— in-flight 时挂 pending flag,完成后立即补一次,
|
||||||
|
// 上一轮被跳过的探测不会丢失(旧版在 pollInFlight 守卫下直接吞掉)
|
||||||
|
const POLL_INTERVAL_ACTIVE_MS = 500;
|
||||||
|
const POLL_INTERVAL_QUIET_MS = 5000;
|
||||||
|
const POLL_ACTIVE_WINDOW_MS = 2000;
|
||||||
const EMIT_DEBOUNCE_MS = 300;
|
const EMIT_DEBOUNCE_MS = 300;
|
||||||
// audit fix (Round 7 FS-F8):debounce maxWait —— 持续事件流(git checkout /
|
// audit fix (Round 7 FS-F8):debounce maxWait —— 持续事件流(git checkout /
|
||||||
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush,
|
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush,
|
||||||
@@ -94,6 +104,22 @@ function createFsWatcher(deps) {
|
|||||||
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
|
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
|
||||||
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
|
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
|
||||||
let pollInFlight = false;
|
let pollInFlight = false;
|
||||||
|
// audit fix (R7a P1-19):「不 drop」pending。in-flight 期间又触发的 tick
|
||||||
|
// 不再被吞,挂 pendingPoll = true;in-flight 完成后立即补一次(delay=0),
|
||||||
|
// 紧跟的那次 tick 间隔回到 adaptive 值。被跳过的探测不再丢。
|
||||||
|
let pendingPoll = false;
|
||||||
|
// audit fix (R7a P1-19 bugfix):「暂停期间不 re-schedule」。pauseDirPolling
|
||||||
|
// 通过 stopDirPoll 清掉 dirPollTimer + 把 pendingPoll 重置为 false,但
|
||||||
|
// 已 in-flight 的 runPollTick 仍在 await scanDir;scanDir 返回后 finally
|
||||||
|
// 走「无 pending → scheduleNextPoll(getPollInterval())」,等于暂停期间
|
||||||
|
// 重新挂了一个 5s 后才退到的轮询定时器,窗口隐藏时白白浪费 SMB / OneDrive
|
||||||
|
// 流量。pollPaused 标志让 finally 跳过 scheduleNextPoll,真正停止轮询;
|
||||||
|
// resumeDirPolling 清标志 + startDirPoll() 立即重启。
|
||||||
|
let pollPaused = false;
|
||||||
|
// audit fix (R7a P1-19):自适应轮询的「活动窗口」基线。fs.watch 事件 /
|
||||||
|
// emitFilesChanged 实际探测到变化时刷新;poll tick 取间隔时比 POLL_ACTIVE_WINDOW_MS,
|
||||||
|
// 在窗口内 → ACTIVE 500ms,否则 QUIET 5000ms。
|
||||||
|
let lastChangeAt = 0;
|
||||||
// audit fix (Round 16 M1):self-write filter。file:write / file:create 成功后
|
// audit fix (Round 16 M1):self-write filter。file:write / file:create 成功后
|
||||||
// 通过 markExpectedWrite(absPath, mtimeMs) 登记;dirWatcher callback 看到这个
|
// 通过 markExpectedWrite(absPath, mtimeMs) 登记;dirWatcher callback 看到这个
|
||||||
// key 命中 + 未过期 → 跳过 scheduleEmit,避免「renderer 把自己的写盘当外部
|
// key 命中 + 未过期 → 跳过 scheduleEmit,避免「renderer 把自己的写盘当外部
|
||||||
@@ -227,6 +253,26 @@ function createFsWatcher(deps) {
|
|||||||
lastErrorAt = 0;
|
lastErrorAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* audit fix (R7a P1-19):标记「刚刚有活动」。被 fs.watch callback / scheduleEmit
|
||||||
|
* / 探测到列表变化时调用。poll tick 读 lastChangeAt 决定下次间隔是 ACTIVE
|
||||||
|
* (500ms) 还是 QUIET (5000ms)。
|
||||||
|
*/
|
||||||
|
function markActivity() {
|
||||||
|
lastChangeAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自适应 poll 间隔:距 lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s。
|
||||||
|
* 仅「是否在活动窗口」的二元判定 —— 不做指数退避,简单的「5s 节流」就够
|
||||||
|
* 应付 SMB 流量压力(5s 一次 readdir 1w 文件 ≈ 200KB 网络 IO,可忽略)。
|
||||||
|
*/
|
||||||
|
function getPollInterval() {
|
||||||
|
return (Date.now() - lastChangeAt) < POLL_ACTIVE_WINDOW_MS
|
||||||
|
? POLL_INTERVAL_ACTIVE_MS
|
||||||
|
: POLL_INTERVAL_QUIET_MS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安全发送(main-M7:teardown 窗口过程抛 throw → unhandledException)。
|
* 安全发送(main-M7:teardown 窗口过程抛 throw → unhandledException)。
|
||||||
* @param {object} payload
|
* @param {object} payload
|
||||||
@@ -391,6 +437,10 @@ function createFsWatcher(deps) {
|
|||||||
// 不向 renderer 推送,避免「刚加载就 emitFilesChanged 又被 renderer 当
|
// 不向 renderer 推送,避免「刚加载就 emitFilesChanged 又被 renderer 当
|
||||||
// fs:changed 事件 → 触发 scanDir 重复请求」循环。
|
// fs:changed 事件 → 触发 scanDir 重复请求」循环。
|
||||||
if (snapshot !== lastFilesSnapshot) {
|
if (snapshot !== lastFilesSnapshot) {
|
||||||
|
// audit fix (R7a P1-19):真正探测到列表变化时也算「活动」,把 poll
|
||||||
|
// 切到 ACTIVE 500ms 高频,确保连续改动(git checkout / AI 批量写)
|
||||||
|
// 期间每 500ms 兜底一次,即便 fs.watch 漏报。
|
||||||
|
markActivity();
|
||||||
lastFilesSnapshot = snapshot;
|
lastFilesSnapshot = snapshot;
|
||||||
if (!skipInitialEmit) {
|
if (!skipInitialEmit) {
|
||||||
// 成功路径同样补 relDir 字段(FS-F11);移除绝对 dir 字段(M12)。
|
// 成功路径同样补 relDir 字段(FS-F11);移除绝对 dir 字段(M12)。
|
||||||
@@ -402,6 +452,10 @@ function createFsWatcher(deps) {
|
|||||||
|
|
||||||
function scheduleEmit() {
|
function scheduleEmit() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// audit fix (R7a P1-19):fs.watch 事件流持续期间把 poll 切到 ACTIVE 500ms
|
||||||
|
// 高频。markActivity 写 lastChangeAt,下一次 poll tick 拿到的就是 active
|
||||||
|
// 间隔;事件流停 2s 后回 QUIET 5s。无需显式定时器,纯「窗口判定」。
|
||||||
|
markActivity();
|
||||||
// audit fix (Round 7 FS-F8):maxWait —— 持续事件流(git checkout /
|
// audit fix (Round 7 FS-F8):maxWait —— 持续事件流(git checkout /
|
||||||
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。
|
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。
|
||||||
// burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。
|
// burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。
|
||||||
@@ -417,7 +471,13 @@ function createFsWatcher(deps) {
|
|||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
emitDebounceTimer = null;
|
emitDebounceTimer = null;
|
||||||
burstStartedAt = 0;
|
burstStartedAt = 0;
|
||||||
emitFilesChanged(undefined, dirAtSchedule);
|
// 防御性兜底:emitFilesChanged 是 async,其 scanDir 之后的 entriesSnapshot /
|
||||||
|
// safeSend 路径(try 之外)若抛错会变成未捕获拒绝,触发全局
|
||||||
|
// unhandledRejection → app.exit(1) 杀进程。这里 .catch 吞掉,避免一次
|
||||||
|
// 畸形 scan 结果拖垮整个应用。
|
||||||
|
emitFilesChanged(undefined, dirAtSchedule).catch((e) => {
|
||||||
|
console.warn('[fs-watcher] emitFilesChanged 失败:', e && e.message);
|
||||||
|
});
|
||||||
}, delay);
|
}, delay);
|
||||||
// audit fix (main-M8):unref —— 否则 fs-watcher 还在挂定时器时 Electron
|
// audit fix (main-M8):unref —— 否则 fs-watcher 还在挂定时器时 Electron
|
||||||
// 主进程不会自然退出(before-quit 取消 close path 后这 300ms 会再卡一下)。
|
// 主进程不会自然退出(before-quit 取消 close path 后这 300ms 会再卡一下)。
|
||||||
@@ -456,33 +516,83 @@ function createFsWatcher(deps) {
|
|||||||
reattachTimer = t;
|
reattachTimer = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDirPoll() {
|
/**
|
||||||
if (dirPollTimer || !watchedDir) return;
|
* audit fix (R7a P1-19):重构 setInterval → setTimeout 链。每次 tick 后
|
||||||
|
* 重新决策下次间隔(active / quiet 二元),用 setTimeout 而不是 setInterval
|
||||||
|
* 才能让间隔真正自适应。
|
||||||
|
*
|
||||||
|
* 「不 drop」语义:in-flight 期间触发的 tick 不再被 pollInFlight 守卫吞掉,
|
||||||
|
* 挂 pendingPoll = true;in-flight 完成后 nextTick 立即补一次(delay=0),
|
||||||
|
* 之后再回到 adaptive 间隔。被跳过的探测不会丢。
|
||||||
|
*
|
||||||
|
* @param {number} [delay] - 距上次结束的延迟(ms),默认 = getPollInterval()
|
||||||
|
*/
|
||||||
|
function scheduleNextPoll(delay) {
|
||||||
|
if (!watchedDir) return;
|
||||||
|
if (dirPollTimer) {
|
||||||
|
clearTimeout(dirPollTimer);
|
||||||
|
dirPollTimer = null;
|
||||||
|
}
|
||||||
|
const wait = Number.isFinite(delay) ? delay : getPollInterval();
|
||||||
// audit fix (Round 7 FS-F14):unref —— 这个 timer 是长期存活的,叠加
|
// audit fix (Round 7 FS-F14):unref —— 这个 timer 是长期存活的,叠加
|
||||||
// will-quit 不清 fsWatcher 会在退出路径上拖住主进程。
|
// will-quit 不清 fsWatcher 会在退出路径上拖住主进程。
|
||||||
dirPollTimer = setInterval(() => {
|
const t = setTimeout(() => {
|
||||||
// audit fix (Round 7 FS-F7):重入保护。慢盘(OneDrive / SMB)上
|
dirPollTimer = null;
|
||||||
// scanDir 可能 > 2s,setInterval 触发新一轮与上一轮 await 并发,
|
runPollTick();
|
||||||
// 争写 lastFilesSnapshot → 后完成的可能是先发起的(旧覆盖新)。
|
}, wait);
|
||||||
if (pollInFlight) return;
|
if (typeof t.unref === 'function') t.unref();
|
||||||
pollInFlight = true;
|
dirPollTimer = t;
|
||||||
updateSnapshot(watchedDir)
|
}
|
||||||
.catch((e) => {
|
|
||||||
// audit fix (Round 7 FS-F16):floating promise 兜底。
|
async function runPollTick() {
|
||||||
console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message);
|
if (!watchedDir) return;
|
||||||
})
|
// audit fix (R7a P1-19):「不 drop」pending。in-flight 时挂 pending,
|
||||||
.finally(() => {
|
// 完成后立即补一次(delay=0),再回 adaptive 间隔。
|
||||||
pollInFlight = false;
|
if (pollInFlight) {
|
||||||
});
|
pendingPoll = true;
|
||||||
}, DIR_POLL_INTERVAL_MS);
|
scheduleNextPoll(getPollInterval());
|
||||||
if (typeof dirPollTimer.unref === 'function') dirPollTimer.unref();
|
return;
|
||||||
|
}
|
||||||
|
pollInFlight = true;
|
||||||
|
try {
|
||||||
|
await updateSnapshot(watchedDir);
|
||||||
|
} catch (e) {
|
||||||
|
// audit fix (Round 7 FS-F16):floating promise 兜底。
|
||||||
|
console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message);
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false;
|
||||||
|
}
|
||||||
|
// 探测完成后再决定下次:
|
||||||
|
// - pending 标记被 set 过 → 立即(delay=0)补一次
|
||||||
|
// - 否则按 adaptive 间隔
|
||||||
|
// audit fix (R7a P1-19 bugfix):暂停期间 → 不挂新 timer。pauseDirPolling
|
||||||
|
// 已经清掉了 dirPollTimer + pendingPoll,但已 in-flight 的 scanDir 在
|
||||||
|
// await 上挂了几秒,恢复时如果不查 pollPaused 就走 scheduleNextPoll,
|
||||||
|
// 等于窗口隐藏期间还有 5s 一次的后台 readdir,违背「暂停省 SMB 流量」
|
||||||
|
// 的设计意图(main.js#707-710 的 minimize/hide handler 假设轮询真的停了)。
|
||||||
|
if (pollPaused) return;
|
||||||
|
if (pendingPoll) {
|
||||||
|
pendingPoll = false;
|
||||||
|
scheduleNextPoll(0);
|
||||||
|
} else {
|
||||||
|
scheduleNextPoll(getPollInterval());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDirPoll() {
|
||||||
|
if (dirPollTimer || !watchedDir) return;
|
||||||
|
// 启动时算 active:第一次 tick 立即跑(delay=0),给用户即时反馈
|
||||||
|
markActivity();
|
||||||
|
scheduleNextPoll(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopDirPoll() {
|
function stopDirPoll() {
|
||||||
if (dirPollTimer) {
|
if (dirPollTimer) {
|
||||||
clearInterval(dirPollTimer);
|
clearTimeout(dirPollTimer);
|
||||||
dirPollTimer = null;
|
dirPollTimer = null;
|
||||||
}
|
}
|
||||||
|
pollInFlight = false;
|
||||||
|
pendingPoll = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,11 +697,16 @@ function createFsWatcher(deps) {
|
|||||||
*/
|
*/
|
||||||
function pauseDirPolling() {
|
function pauseDirPolling() {
|
||||||
stopDirPoll();
|
stopDirPoll();
|
||||||
|
// audit fix (R7a P1-19 bugfix):挂「不 re-schedule」闸,让已 in-flight 的
|
||||||
|
// runPollTick finally 跳过 scheduleNextPoll;否则 5s 一次的轮询还在悄悄跑,
|
||||||
|
// 违反 pause 的省 SMB 流量语义。
|
||||||
|
pollPaused = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */
|
/** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */
|
||||||
function resumeDirPolling() {
|
function resumeDirPolling() {
|
||||||
if (!watchedDir) return;
|
if (!watchedDir) return;
|
||||||
|
pollPaused = false;
|
||||||
startDirPoll();
|
startDirPoll();
|
||||||
updateSnapshot(watchedDir);
|
updateSnapshot(watchedDir);
|
||||||
}
|
}
|
||||||
@@ -652,6 +767,24 @@ function createFsWatcher(deps) {
|
|||||||
_test_updateSnapshot: updateSnapshot,
|
_test_updateSnapshot: updateSnapshot,
|
||||||
/** 测试 / 调试用:当前是否在监听某个目录 */
|
/** 测试 / 调试用:当前是否在监听某个目录 */
|
||||||
get watchedDir() { return watchedDir; },
|
get watchedDir() { return watchedDir; },
|
||||||
|
// audit fix (R7a P1-19) 测试钩子:暴露 getPollInterval / markActivity /
|
||||||
|
// scheduleNextPoll / runPollTick 给单测,验证自适应间隔 + 「不 drop」pending
|
||||||
|
// 语义。生产路径不暴露 —— 上层不应该直接调轮询内部。
|
||||||
|
_test_getPollInterval: getPollInterval,
|
||||||
|
_test_markActivity: markActivity,
|
||||||
|
_test_scheduleNextPoll: scheduleNextPoll,
|
||||||
|
_test_runPollTick: runPollTick,
|
||||||
|
// audit fix (R7a P1-19) 测试钩子:直接设 watchedDir 跳过 fs.watch 订阅,
|
||||||
|
// 让单测可以纯驱动 runPollTick 验证 pending 链,不污染临时目录。
|
||||||
|
_test_setWatchedDir: (dir) => { watchedDir = dir; },
|
||||||
|
// audit fix (R7a P1-19 bugfix) 测试钩子:暴露 pause/resume,让单测验证
|
||||||
|
// 暂停期间 in-flight runPollTick finally 不会 re-schedule(否则窗口
|
||||||
|
// 隐藏时还在偷偷 readdir)。
|
||||||
|
_test_pauseDirPolling: pauseDirPolling,
|
||||||
|
_test_resumeDirPolling: resumeDirPolling,
|
||||||
|
// audit fix (R7a P1-19 bugfix) 测试钩子:当前是否挂有 poll timer。pause
|
||||||
|
// bugfix 测试用它确认 in-flight runPollTick finally 不会偷偷挂新 timer。
|
||||||
|
_test_hasPollTimer: () => dirPollTimer !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
main/fsync-parent-dir.js
Normal file
64
main/fsync-parent-dir.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// main.js file:rename / folder:rename —— rename 后父目录 fsync(audit P1-6)
|
||||||
|
//
|
||||||
|
// 背景:fs.rename 在 POSIX 下只 fsync 自己的 inode,不 fsync 父目录的 directory entry。
|
||||||
|
// 崩溃时新名可能没落盘,恢复后看到的是「旧名 inode + 文件内容」。audit 要求
|
||||||
|
// 与 atomicWriteFile(main.js:1801-1809 模式)一致:rename 成功后对父目录做 fsync。
|
||||||
|
//
|
||||||
|
// 现状:folder:create 已经在 mkdir 后做了父目录 fsync(main.js:1854-1864),
|
||||||
|
// 但 file:rename(main.js:1986)和 folder:rename(main.js:2085)都没有。
|
||||||
|
//
|
||||||
|
// 设计:
|
||||||
|
// - 抽 fsyncParentDir(filePath, opts?) helper
|
||||||
|
// - win32 上 NTFS journal 自带元数据持久化,直接 noop(与 folder:create 一致)
|
||||||
|
// - fsImpl 注入:生产用 Node 内置 fs;单测用 mock 验证 open→sync→close 顺序
|
||||||
|
// - close 失败静默吞(与 folder:create 行为一致)
|
||||||
|
// - open/sync 失败透传错误(不静默 —— rename 已成功,父目录 fsync 失败要可观测)
|
||||||
|
//
|
||||||
|
// 调用方语义:rename 成功后调用。失败不抛、返 {ok:false, error, code} 让调用方
|
||||||
|
// 决定是否吞(推荐:log warn,不回滚 rename —— 文件已改名成功,父目录 fsync
|
||||||
|
// 失败只是「崩溃时不保证新名持久化」,回滚成本高、收益低)。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 filePath 的父目录做 fsync,确保 directory entry 落盘。
|
||||||
|
* @param {string} filePath 文件/文件夹的完整路径
|
||||||
|
* @param {{
|
||||||
|
* fsImpl?: { open: (p: string, m: string) => Promise<{ sync: () => Promise<void>, close: () => Promise<void> }> },
|
||||||
|
* platform?: NodeJS.Platform,
|
||||||
|
* }} [opts]
|
||||||
|
* @returns {Promise<{ ok: boolean, error?: string, code?: string, skipped?: string }>}
|
||||||
|
*/
|
||||||
|
async function fsyncParentDir(filePath, opts) {
|
||||||
|
const fsImpl = (opts && opts.fsImpl) || require('fs').promises;
|
||||||
|
const platform = (opts && opts.platform) || process.platform;
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
// NTFS journal 自带元数据持久化,无需 fsync
|
||||||
|
return { ok: true, skipped: 'win32' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(filePath);
|
||||||
|
let fh;
|
||||||
|
try {
|
||||||
|
fh = await fsImpl.open(parentDir, 'r');
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message, code: e.code };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fh.sync();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message, code: e.code };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fh.close();
|
||||||
|
} catch {
|
||||||
|
// close 失败静默吞(与 folder:create 行为一致 —— 防止 fh 泄漏已尽力)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fsyncParentDir };
|
||||||
38
main/generate-tmp-path.js
Normal file
38
main/generate-tmp-path.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// main/file-ops.js atomicWriteFile —— tmp 路径生成(audit P2-1)
|
||||||
|
//
|
||||||
|
// 背景:原 inline 实现 `Math.random().toString(36).slice(2, 8)`
|
||||||
|
// - Math.random() 不加密安全,并发同毫秒 + 同 pid + 同 dst 可能撞名
|
||||||
|
// (虽然 36^6 ≈ 21 亿,实际不撞,但语义上 audit 要求与 crypto.randomUUID 对齐)
|
||||||
|
// - 仓库其它路径已用 crypto.randomUUID(),统一风格
|
||||||
|
//
|
||||||
|
// 设计:
|
||||||
|
// - 抽 generateTmpPath(dst, { crypto, pid, now }) 工厂
|
||||||
|
// - 注入 crypto / pid / now 便于单测验证随机性来源
|
||||||
|
// - 默认走 node:crypto.randomUUID()(128-bit 加密安全)
|
||||||
|
// - 路径格式:${dst}.tmp.${pid}.${ts}.${randomId}
|
||||||
|
// 保持现有格式不破坏 fs-watcher(fs-watcher 旧逻辑只匹配 .tmp.* 前缀,randomId
|
||||||
|
// 改成 UUID 不影响匹配)
|
||||||
|
//
|
||||||
|
// 调用方:atomicWriteFile 在 fs.open 之前构造 tmpPath。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 atomicWriteFile 用的 tmp 路径。
|
||||||
|
* @param {string} dst 目标文件完整路径
|
||||||
|
* @param {{
|
||||||
|
* crypto?: { randomUUID: () => string },
|
||||||
|
* pid?: number,
|
||||||
|
* now?: () => number,
|
||||||
|
* }} [opts] 注入依赖,便于单测
|
||||||
|
* @returns {string} `${dst}.tmp.${pid}.${ts}.${randomId}`
|
||||||
|
*/
|
||||||
|
function generateTmpPath(dst, opts) {
|
||||||
|
const cryptoImpl = (opts && opts.crypto) || require('node:crypto');
|
||||||
|
const pid = (opts && typeof opts.pid === 'number') ? opts.pid : process.pid;
|
||||||
|
const now = (opts && typeof opts.now === 'function') ? opts.now : Date.now;
|
||||||
|
const randomId = cryptoImpl.randomUUID();
|
||||||
|
return `${dst}.tmp.${pid}.${now()}.${randomId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateTmpPath };
|
||||||
111
main/ipc-sender.js
Normal file
111
main/ipc-sender.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// IPC handler 入口 sender / frame 守卫(audit R5 P1-10 / P1-11 / P1-14)
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 背景:渲染端的 window.api.* 是 contextBridge 暴露的,渲染端任意 JS(XSS、
|
||||||
|
// devtools console 注入、扩展、未来的 webview)都能调。`renderer:set-dirty`
|
||||||
|
// 单独加了 sender 校验(main.js:952),其它 20+ file/folder/shell/window/ai
|
||||||
|
// handler 全裸奔 —— 攻击面包括:
|
||||||
|
// - 读 / 写 / 删 用户笔记(file:read/write/delete)
|
||||||
|
// - 改设置 / dataDir / 主题(app:*, window:*)
|
||||||
|
// - 取消别人正在跑的 AI 请求(ai:cancel —— 跨窗口 race)
|
||||||
|
// - 弹出 arm 窗口让 reveal-ai-key 流程推进(B-10,未来 webview 嵌入)
|
||||||
|
//
|
||||||
|
// 修复:抽 assertMainSender(event, getMainWindow) → { ok:true } | { ok:false,
|
||||||
|
// error:SENDER_NOT_ALLOWED }。所有 IPC handler 顶部一行 if 早退。
|
||||||
|
// senderFrame 守卫(assertTopFrame)单独抽,对子 frame 嵌入场景生效。
|
||||||
|
//
|
||||||
|
// 测试注入:getMainWindow 走参数注入,不直接读 main.js 闭包 —— 单测里
|
||||||
|
// 传 mock 即可。vi.mock('electron') 拦不住 CJS require,所以走参数。
|
||||||
|
//
|
||||||
|
// 用法示例(main.js):
|
||||||
|
// const { assertMainSender, SENDER_NOT_ALLOWED } = require('./ipc-sender.js');
|
||||||
|
// ipcMain.handle('file:read', async (event, filePath) => {
|
||||||
|
// const guard = assertMainSender(event, getMainWindow);
|
||||||
|
// if (!guard.ok) return { ok: false, error: guard.error };
|
||||||
|
// // ... 原有逻辑
|
||||||
|
// });
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC handler 调用方来源校验。返回 ok=true 放行,ok=false 拒绝并附错误码。
|
||||||
|
*
|
||||||
|
* @param {Electron.IpcMainInvokeEvent | Electron.IpcMainMessageEvent} event
|
||||||
|
* IPC 事件对象;读取 event.sender(webContents 引用)。
|
||||||
|
* @param {() => (Electron.BrowserWindow | null)} getMainWindow
|
||||||
|
* 返回当前主窗口的 getter —— 走参数注入避免循环依赖 + 单测里 mock 方便。
|
||||||
|
* 主窗口未创建(早期启动 race)或已销毁 → 拒绝。
|
||||||
|
* @returns {{ ok: true } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function assertMainSender(event, getMainWindow) {
|
||||||
|
if (!event || !event.sender) {
|
||||||
|
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||||
|
}
|
||||||
|
const win = typeof getMainWindow === 'function' ? getMainWindow() : null;
|
||||||
|
if (!win || typeof win.isDestroyed !== 'function' || win.isDestroyed()) {
|
||||||
|
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||||
|
}
|
||||||
|
if (event.sender !== win.webContents) {
|
||||||
|
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶层 frame 守卫:拒绝来自 iframe / webview 子 frame 的调用。
|
||||||
|
* 用途:未来给主窗口加 webview 嵌入时(如设置面板内嵌文档预览),防止子
|
||||||
|
* frame 里的脚本通过 ipcRenderer 直接调主进程敏感接口。
|
||||||
|
*
|
||||||
|
* @param {Electron.IpcMainInvokeEvent | Electron.IpcMainMessageEvent} event
|
||||||
|
* @returns {{ ok: true } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function assertTopFrame(event) {
|
||||||
|
// senderFrame 缺失(极少数场景)= 拒绝保守
|
||||||
|
if (!event || !event.senderFrame) {
|
||||||
|
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||||
|
}
|
||||||
|
// parent 真值 = 子 frame(iframe / webview / 子 BrowserWindow);null = 顶层
|
||||||
|
if (event.senderFrame.parent) {
|
||||||
|
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI cancel requestId 两种合法格式(main/ai.js:968 / src/ai/ai-chat-panel.js:21):
|
||||||
|
// - 'ai-<timestamp>-<UUIDv4>'(main 侧 fallback 生成)
|
||||||
|
// - '<UUIDv4>'(renderer 侧 crypto.randomUUID())
|
||||||
|
// 这条正则同时拒掉任意垃圾字符串、过长 payload(防内存放大)、非 hex 字符。
|
||||||
|
// 编译期一次,运行时零开销。
|
||||||
|
const RE_AI_REQUEST_ID = /^(?:ai-\d{1,18}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ai:cancel IPC 的 requestId 格式校验。配合 assertMainSender 做纵深防御:
|
||||||
|
* - assertMainSender 拒掉恶意 webContents
|
||||||
|
* - assertAiRequestId 拒掉畸形 requestId(防 typo / 攻击者探测 Map 结构)
|
||||||
|
*
|
||||||
|
* @param {unknown} requestId
|
||||||
|
* @returns {{ ok: true, requestId: string } | { ok: false, error: string }}
|
||||||
|
*/
|
||||||
|
function assertAiRequestId(requestId) {
|
||||||
|
if (typeof requestId !== 'string') {
|
||||||
|
return { ok: false, error: 'INVALID_REQUEST_ID' };
|
||||||
|
}
|
||||||
|
if (requestId.length > 200) {
|
||||||
|
return { ok: false, error: 'INVALID_REQUEST_ID' };
|
||||||
|
}
|
||||||
|
if (!RE_AI_REQUEST_ID.test(requestId)) {
|
||||||
|
return { ok: false, error: 'INVALID_REQUEST_ID' };
|
||||||
|
}
|
||||||
|
return { ok: true, requestId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENDER_NOT_ALLOWED = 'SENDER_NOT_ALLOWED';
|
||||||
|
const INVALID_REQUEST_ID = 'INVALID_REQUEST_ID';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assertMainSender,
|
||||||
|
assertTopFrame,
|
||||||
|
assertAiRequestId,
|
||||||
|
SENDER_NOT_ALLOWED,
|
||||||
|
INVALID_REQUEST_ID,
|
||||||
|
};
|
||||||
42
main/lru-cache.js
Normal file
42
main/lru-cache.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// P2-6(audit):通用 LRU 缓存 —— Map 迭代序即插入序,get/set 命中时
|
||||||
|
// delete+set 把键提到最新;set 超 maxSize 删最老(第一个键)。
|
||||||
|
//
|
||||||
|
// 用途:main/ai.js 的 pickOpenAITokenConfig / joinUrl 每次调用重复跑
|
||||||
|
// 正则 + new URL 解析;同 model / 同 baseURL 在一次会话里反复命中,
|
||||||
|
// 缓存结果省 CPU。maxSize 64 足够覆盖「用户试过的模型 + URL」组合,
|
||||||
|
// 不会无界增长。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.maxSize=64]
|
||||||
|
* @returns {{ get: (k: any) => any, set: (k: any, v: any) => void,
|
||||||
|
* has: (k: any) => boolean, size: number }}
|
||||||
|
*/
|
||||||
|
function createLruCache({ maxSize = 64 } = {}) {
|
||||||
|
const map = new Map();
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
if (!map.has(key)) return undefined;
|
||||||
|
const v = map.get(key);
|
||||||
|
// LRU:提到最新
|
||||||
|
map.delete(key);
|
||||||
|
map.set(key, v);
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
// 覆盖同键 = 使用一次,提到最新
|
||||||
|
if (map.has(key)) map.delete(key);
|
||||||
|
map.set(key, value);
|
||||||
|
if (map.size > maxSize) {
|
||||||
|
// Map 第一个键 = 最老
|
||||||
|
map.delete(map.keys().next().value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
has(key) { return map.has(key); },
|
||||||
|
get size() { return map.size; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLruCache };
|
||||||
105
main/main-log-writer.js
Normal file
105
main/main-log-writer.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// P2-10 / P2-12(audit):主进程日志异步缓冲写出。
|
||||||
|
//
|
||||||
|
// 旧实现(main.js 顶部 console 重写层):每行 mkdirSync + appendFileSync ——
|
||||||
|
// 同步 IO 直接阻塞 main 事件循环。AI 流式 / fsWatcher 高频日志期间,
|
||||||
|
// 每次 console.* 都是一次磁盘往返;顶层同步 fs 也拖慢启动路径。
|
||||||
|
//
|
||||||
|
// 新设计:write(line) 只进内存缓冲并调度一次 flush 定时器(默认 250ms,
|
||||||
|
// .unref() 不拖进程退出);flush 把整段缓冲拼成一次 mkdir(recursive) +
|
||||||
|
// appendFile 异步写出。崩溃窗口 = 最多 flushMs 毫秒的日志丢失 ——
|
||||||
|
// 对诊断日志可接受(崩溃路径另有 stderr 兜底)。
|
||||||
|
//
|
||||||
|
// 失败语义与旧版一致:写盘失败静默丢弃(不应影响主流程)。
|
||||||
|
// 缓冲上限 maxBuffered 行,超出丢最老,防 AI 刷屏撑爆内存。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.logDir
|
||||||
|
* @param {string} opts.logFile
|
||||||
|
* @param {{ mkdir: Function, appendFile: Function }} [opts.fsImpl] 默认 require('fs').promises
|
||||||
|
* @param {number} [opts.flushMs=250]
|
||||||
|
* @param {number} [opts.maxBuffered=1000]
|
||||||
|
* @param {Function} [opts.setTimeoutImpl]
|
||||||
|
* @param {Function} [opts.clearTimeoutImpl]
|
||||||
|
*/
|
||||||
|
function createMainLogWriter({
|
||||||
|
logDir,
|
||||||
|
logFile,
|
||||||
|
fsImpl,
|
||||||
|
flushMs = 250,
|
||||||
|
maxBuffered = 1000,
|
||||||
|
setTimeoutImpl,
|
||||||
|
clearTimeoutImpl,
|
||||||
|
}) {
|
||||||
|
const fs = fsImpl || require('fs').promises;
|
||||||
|
const _setTimeout = setTimeoutImpl || setTimeout;
|
||||||
|
const _clearTimeout = clearTimeoutImpl || clearTimeout;
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
let buffer = [];
|
||||||
|
/** @type {Promise<void>|null} mkdir 只做一次,缓存 promise */
|
||||||
|
let mkdirPromise = null;
|
||||||
|
/** @type {Promise<void>|null} in-flight flush,dispose/重复 flush 复用 */
|
||||||
|
let flushing = null;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
if (!mkdirPromise) {
|
||||||
|
mkdirPromise = fs.mkdir(logDir, { recursive: true }).catch(() => {
|
||||||
|
// mkdir 失败:下次 flush 重试(不缓存失败的 promise)
|
||||||
|
mkdirPromise = null;
|
||||||
|
throw new Error('mkdir failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mkdirPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
if (flushing) return flushing;
|
||||||
|
if (buffer.length === 0) return;
|
||||||
|
const lines = buffer;
|
||||||
|
buffer = [];
|
||||||
|
flushing = (async () => {
|
||||||
|
try {
|
||||||
|
await ensureDir();
|
||||||
|
await fs.appendFile(logFile, lines.join(''));
|
||||||
|
} catch {
|
||||||
|
// 写日志失败静默丢弃 —— 与旧同步实现「catch 忽略」语义一致
|
||||||
|
} finally {
|
||||||
|
flushing = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return flushing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush() {
|
||||||
|
if (timer) return;
|
||||||
|
timer = _setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
flush();
|
||||||
|
}, flushMs);
|
||||||
|
// 日志定时器不应单独吊住进程退出
|
||||||
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
write(line) {
|
||||||
|
buffer.push(line);
|
||||||
|
if (buffer.length > maxBuffered) {
|
||||||
|
buffer.splice(0, buffer.length - maxBuffered);
|
||||||
|
}
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
flush,
|
||||||
|
dispose() {
|
||||||
|
if (timer) {
|
||||||
|
_clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMainLogWriter };
|
||||||
32
main/save-settings-validate.js
Normal file
32
main/save-settings-validate.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// main.js app:save-settings —— partial payload 校验(audit P1-1)
|
||||||
|
//
|
||||||
|
// 背景:原 handler `if (!partial || typeof partial !== 'object') return
|
||||||
|
// { ok: true, settings: configStore.getConfig() };` 把 null / undefined /
|
||||||
|
// string / array 都当成「无更新」静默放过,renderer 缓存与磁盘脱钩
|
||||||
|
// (settingsStore.update({...}) 拿不到 error 也不会重试,直到下次合法保存)。
|
||||||
|
//
|
||||||
|
// 修复:抽 isValidPartialSettings(partial) 纯函数;handler 顶部调用,
|
||||||
|
// 拒收集合 → 返回 { ok: false, error: 'INVALID_PAYLOAD' }。
|
||||||
|
//
|
||||||
|
// 为什么纯函数 + 抽出:
|
||||||
|
// 1. 单测无需 mock ipcMain / Electron 运行时;
|
||||||
|
// 2. 日后若 renderer 端要前置校验(避免 round-trip),同一函数可直接 import。
|
||||||
|
//
|
||||||
|
// 拒收集合:null / undefined / 任何 primitive / Array / Function。
|
||||||
|
// 接收:plain object(typeof === 'object' && !Array.isArray)。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} partial
|
||||||
|
* @returns {boolean} true = 合法 partial(plain object),false = 应拒
|
||||||
|
*/
|
||||||
|
function isValidPartialSettings(partial) {
|
||||||
|
return (
|
||||||
|
partial !== null &&
|
||||||
|
typeof partial === 'object' &&
|
||||||
|
!Array.isArray(partial)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isValidPartialSettings };
|
||||||
167
main/secret-store.js
Normal file
167
main/secret-store.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// API Key 等敏感字段的加密存储层(audit P0-1)
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 背景:原来 aiApiKey 直接以明文写入 userData/config.json。任何能拿到
|
||||||
|
// userData 目录副本的人 / 备份脚本 / 同步盘历史版本都拿得到明文 Key。
|
||||||
|
//
|
||||||
|
// 修复:用 electron.safeStorage(DPAPI / Keychain / libsecret)做对称加密,
|
||||||
|
// 磁盘仅留包络(enc:v1:<base64 ciphertext>)。运行时内存需要时再解密
|
||||||
|
// 给 fetch 用 —— 主进程栈上才有明文窗口。
|
||||||
|
//
|
||||||
|
// 包络格式:
|
||||||
|
// - 'enc:v1:' 前缀 + base64 编码的密文 Buffer
|
||||||
|
// - 没有前缀 = 旧版明文(升级前配置),按明文处理(懒迁移见下)
|
||||||
|
// - 前缀之后是 v1 版本号:未来升级算法 / 改用非对称 / 换 key 时 bump 到 v2
|
||||||
|
//
|
||||||
|
// 降级(isEncryptionAvailable() === false,典型 Linux 无 gnome-keyring/kwallet):
|
||||||
|
// - encrypt 返回明文 + console.warn 一次(每进程)
|
||||||
|
// - decrypt 遇到包络但 keyring 不可用 → 返回 ''(AI 配置临时不可用,UI 提示重输)
|
||||||
|
//
|
||||||
|
// 迁移(懒迁移,audit P0-1 要求"启动时一次性加密" → 这里更轻量):
|
||||||
|
// - 老配置 plaintext 在 loadConfig 时进内存仍是明文
|
||||||
|
// - 任意 settingsStore.update 触发 saveConfig → 写入前自动加密
|
||||||
|
// - 不在 loadConfig 里主动写盘:避免「启动 + 配置没改也要做一次 IO」
|
||||||
|
// - 透明:上层读 appConfig.aiApiKey 永远拿到明文(loadConfig decrypt 一次,
|
||||||
|
// saveConfig 只在写盘路径加密,merged 内存对象保持明文)
|
||||||
|
//
|
||||||
|
// 测试注入:_setSafeStorage(mock) 替换底层 safeStorage,_reset() 还原。
|
||||||
|
// 单测里 vi.mock('electron') 拦不住 CJS require(与 main/config-store.js
|
||||||
|
// 同模式),用本模块自己的 setter。
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ENVELOPE_PREFIX = 'enc:v1:';
|
||||||
|
let _warnedUnavailable = false;
|
||||||
|
let _safeStorageOverride = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取当前生效的 safeStorage(生产 = electron.safeStorage,单测 = 注入的 mock)。
|
||||||
|
* 模块未在 Electron 内运行时(极少见,e.g. 脚本化测试)返回 null。
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
function getSafeStorage() {
|
||||||
|
if (_safeStorageOverride !== null) return _safeStorageOverride;
|
||||||
|
try {
|
||||||
|
const electron = require('electron');
|
||||||
|
return electron && electron.safeStorage ? electron.safeStorage : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统密钥库是否可用。不可用时(典型 Linux 无 keyring)→ 降级到明文存储。
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isAvailable() {
|
||||||
|
const ss = getSafeStorage();
|
||||||
|
if (!ss || typeof ss.isEncryptionAvailable !== 'function') return false;
|
||||||
|
try {
|
||||||
|
return !!ss.isEncryptionAvailable();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「不可用」时一次性 warn。每个进程只 warn 一次,避免设置面板来回点
|
||||||
|
* 反复触发;用户看到的也是控制台一次性提示。
|
||||||
|
* @param {string} reason
|
||||||
|
*/
|
||||||
|
function warnOnceDegraded(reason) {
|
||||||
|
if (_warnedUnavailable) return;
|
||||||
|
_warnedUnavailable = true;
|
||||||
|
console.warn(
|
||||||
|
`[secret-store] safeStorage 不可用 (${reason}); API Key 以明文写入 config.json(降级安全)。` +
|
||||||
|
'Linux 上安装 gnome-keyring / kwallet 后重启即可恢复加密。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密一段明文 → 包络字符串(可写盘)。
|
||||||
|
* 输入:任意 string(空串、null、undefined 都安全处理)。
|
||||||
|
* 输出:
|
||||||
|
* - 空 / null / undefined → ''(不写盘也无所谓;saveConfig 会跳过空 aiApiKey)
|
||||||
|
* - 已是包络(enc:v1:...)→ 原样返回(幂等,防止重复加密变套娃)
|
||||||
|
* - 明文 + safeStorage 可用 → 'enc:v1:' + base64(ciphertext)
|
||||||
|
* - 明文 + safeStorage 不可用 → 原明文 + warn 一次(降级)
|
||||||
|
* @param {string|null|undefined} plain
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function encrypt(plain) {
|
||||||
|
if (plain == null) return '';
|
||||||
|
const s = String(plain);
|
||||||
|
if (s === '') return '';
|
||||||
|
if (s.startsWith(ENVELOPE_PREFIX)) return s;
|
||||||
|
const ss = getSafeStorage();
|
||||||
|
if (!ss || !isAvailable()) {
|
||||||
|
warnOnceDegraded('isEncryptionAvailable() === false');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
let buf;
|
||||||
|
try {
|
||||||
|
buf = ss.encryptString(s);
|
||||||
|
} catch (e) {
|
||||||
|
warnOnceDegraded(`encryptString 抛错: ${e && e.message ? e.message : 'unknown'}`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return ENVELOPE_PREFIX + buf.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密包络 → 明文。
|
||||||
|
* 输入:任意 string(空 / null / undefined 安全)。
|
||||||
|
* 输出:
|
||||||
|
* - 空 / null / undefined → ''
|
||||||
|
* - 没有 enc:v1: 前缀 → 原样返回(旧版明文 passthrough;懒迁移关键)
|
||||||
|
* - 包络 + safeStorage 可用 → 解密后的明文
|
||||||
|
* - 包络 + safeStorage 不可用 → ''(AI 临时不可用,UI 应引导重输)
|
||||||
|
* - 包络 + decryptString 抛错(损坏 / 跨用户 / 改 key 后)→ ''(同不可用)
|
||||||
|
* @param {string|null|undefined} stored
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function decrypt(stored) {
|
||||||
|
if (stored == null) return '';
|
||||||
|
const s = String(stored);
|
||||||
|
if (s === '') return '';
|
||||||
|
if (!s.startsWith(ENVELOPE_PREFIX)) return s;
|
||||||
|
const ss = getSafeStorage();
|
||||||
|
if (!ss || !isAvailable()) return '';
|
||||||
|
try {
|
||||||
|
const b64 = s.slice(ENVELOPE_PREFIX.length);
|
||||||
|
const buf = Buffer.from(b64, 'base64');
|
||||||
|
return ss.decryptString(buf);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 测试注入点(生产代码不应调用) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替换底层 safeStorage。传 null 还原到 require('electron') 路径。
|
||||||
|
* @param {object|null} mock
|
||||||
|
*/
|
||||||
|
function _setSafeStorage(mock) {
|
||||||
|
_safeStorageOverride = mock;
|
||||||
|
// 重置 warn 标志 —— 测试之间不串
|
||||||
|
_warnedUnavailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置模块状态(safeStorage 注入 + warn 标志)回默认。
|
||||||
|
* 测试 beforeEach 用。
|
||||||
|
*/
|
||||||
|
function _reset() {
|
||||||
|
_safeStorageOverride = null;
|
||||||
|
_warnedUnavailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
isAvailable,
|
||||||
|
ENVELOPE_PREFIX,
|
||||||
|
_setSafeStorage,
|
||||||
|
_reset,
|
||||||
|
};
|
||||||
53
preload.js
53
preload.js
@@ -19,7 +19,6 @@
|
|||||||
const { contextBridge, ipcRenderer, nativeImage } = require('electron');
|
const { contextBridge, ipcRenderer, nativeImage } = require('electron');
|
||||||
const { marked } = require('marked');
|
const { marked } = require('marked');
|
||||||
const nodePath = require('path');
|
const nodePath = require('path');
|
||||||
const nodeFs = require('fs');
|
|
||||||
|
|
||||||
// 把 icon.ico 在 preload 启动时一次性转成 PNG data URL,渲染端作为 <img src> 用。
|
// 把 icon.ico 在 preload 启动时一次性转成 PNG data URL,渲染端作为 <img src> 用。
|
||||||
// nativeImage 直接读 .ico 会拿到 Windows 编码,<img> 不一定能渲染;toDataURL()
|
// nativeImage 直接读 .ico 会拿到 Windows 编码,<img> 不一定能渲染;toDataURL()
|
||||||
@@ -27,12 +26,14 @@ const nodeFs = require('fs');
|
|||||||
// 打包后路径仍指向资源目录。读不到 → 返回 null,渲染端继续走 .app-title::before 占位。
|
// 打包后路径仍指向资源目录。读不到 → 返回 null,渲染端继续走 .app-title::before 占位。
|
||||||
let APP_ICON_DATA_URL = null;
|
let APP_ICON_DATA_URL = null;
|
||||||
try {
|
try {
|
||||||
|
// P2-11:去掉 nodeFs.existsSync(iconPath) 同步 syscall。
|
||||||
|
// nativeImage.createFromPath 本身就安全 —— 不存在的路径返 empty image 不抛,
|
||||||
|
// 下面 isEmpty() 守卫拦下空 image。existsSync 一次 preload 启动只跑一次,但
|
||||||
|
// 在冷启动 path 上属于可省的 IO;删了不改变行为,启动期少一次 stat。
|
||||||
const iconPath = nodePath.join(__dirname, 'icon.ico');
|
const iconPath = nodePath.join(__dirname, 'icon.ico');
|
||||||
if (nodeFs.existsSync(iconPath)) {
|
const img = nativeImage.createFromPath(iconPath);
|
||||||
const img = nativeImage.createFromPath(iconPath);
|
if (img && !img.isEmpty()) {
|
||||||
if (img && !img.isEmpty()) {
|
APP_ICON_DATA_URL = img.toDataURL();
|
||||||
APP_ICON_DATA_URL = img.toDataURL();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// nativeImage 在某些 headless 测试 / 非 Electron 上下文里会失败;吞掉走 null 路径
|
// nativeImage 在某些 headless 测试 / 非 Electron 上下文里会失败;吞掉走 null 路径
|
||||||
@@ -251,6 +252,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
// 文件操作(只读 + 写入 + 文件管理)
|
// 文件操作(只读 + 写入 + 文件管理)
|
||||||
listFiles: () => ipcRenderer.invoke('file:list'),
|
listFiles: () => ipcRenderer.invoke('file:list'),
|
||||||
|
// R7a P1-20:分页后续页。offset 必填(首屏由 listFiles 拿),limit 默认 200。
|
||||||
|
listFilesMore: (offset, limit) => ipcRenderer.invoke('file:list:more', { offset, limit }),
|
||||||
// Folder Browser(Stage 8):扫一个相对目录的一层条目(含文件夹 + 文件 + binary)。
|
// Folder Browser(Stage 8):扫一个相对目录的一层条目(含文件夹 + 文件 + binary)。
|
||||||
// relDir 是 dataRoot 下的 POSIX 相对路径(如 'notes/2026'),空串代表根。
|
// relDir 是 dataRoot 下的 POSIX 相对路径(如 'notes/2026'),空串代表根。
|
||||||
// 返回 { ok, dir, relDir, entries } 或 { ok:false, error, message? }。
|
// 返回 { ok, dir, relDir, entries } 或 { ok:false, error, message? }。
|
||||||
@@ -277,6 +280,18 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
renameFile: (oldPath, newName) => ipcRenderer.invoke('file:rename', oldPath, newName),
|
renameFile: (oldPath, newName) => ipcRenderer.invoke('file:rename', oldPath, newName),
|
||||||
deleteFile: (filePath) => ipcRenderer.invoke('file:delete', filePath),
|
deleteFile: (filePath) => ipcRenderer.invoke('file:delete', filePath),
|
||||||
|
// 拖拽到文件夹 / 右键「剪切 → 粘贴」用:把 srcPath(文件或文件夹)移动到 destDir。
|
||||||
|
moveFile: (srcPath, destDir) => ipcRenderer.invoke('file:move', srcPath, destDir),
|
||||||
|
// 拖拽(按住 Ctrl/Cmd)/ 右键「复制 → 粘贴」用:把 srcPath 复制到 destDir。
|
||||||
|
copyFile: (srcPath, destDir) => ipcRenderer.invoke('file:copy', srcPath, destDir),
|
||||||
|
|
||||||
|
// 文件夹管理(folder: 系列):
|
||||||
|
// folder:create — 在 opts.dir(或 dataRoot)下新建文件夹,自动避让重名
|
||||||
|
// folder:rename — 在父目录下重命名(不跨父目录),校验 + 避让同 folder:create
|
||||||
|
// 删除复用 file:delete(主进程已支持 folder 分支:trashItem + rmdir 兜底),
|
||||||
|
// 通过 result.isFolder 让 renderer 区分后续行为(导航到父级)
|
||||||
|
createFolder: (name, opts) => ipcRenderer.invoke('folder:create', name, opts || {}),
|
||||||
|
renameFolder: (oldPath, newName) => ipcRenderer.invoke('folder:rename', oldPath, newName),
|
||||||
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
|
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
|
||||||
// 打开任意路径(不修改设置)—— 设置对话框的"打开预览"按钮使用
|
// 打开任意路径(不修改设置)—— 设置对话框的"打开预览"按钮使用
|
||||||
openPath: (target) => ipcRenderer.invoke('app:open-path', target),
|
openPath: (target) => ipcRenderer.invoke('app:open-path', target),
|
||||||
@@ -329,15 +344,23 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// - 兜底路径:pagehide 没跑 → beforeunload 触发,主进程侧 listener 被清
|
// - 兜底路径:pagehide 没跑 → beforeunload 触发,主进程侧 listener 被清
|
||||||
// 每个 on* 单独挂 listener + beforeunload(共享 cleanupOn helper,见顶部)。
|
// 每个 on* 单独挂 listener + beforeunload(共享 cleanupOn helper,见顶部)。
|
||||||
|
|
||||||
onMenuCommand: (callback) => {
|
onThemeToggle: (callback) => {
|
||||||
// menu 事件是「无 payload 只有 channel 名」语义
|
// P2-3:拆 onMenuCommand → 各事件单独 API,每个 API 只挂 1 个 listener。
|
||||||
const events = ['menu:toggle-theme', 'menu:settings'];
|
// 旧版 onMenuCommand 一次性挂 2 个 listener + 字符串分发,订阅 N 次就 2N
|
||||||
const handlers = events.map(name => {
|
// 个 listener;拆分后每个 on* 严格 1 个 listener,与「一个 on* 一个 channel」
|
||||||
const handler = () => callback(name);
|
// 1:1 对应,pagehide 清理路径可点对点核对。
|
||||||
ipcRenderer.on(name, handler);
|
const handler = () => callback();
|
||||||
return { name, handler, cleanup: cleanupOn(name, handler) };
|
ipcRenderer.on('menu:toggle-theme', handler);
|
||||||
});
|
const cleanup = cleanupOn('menu:toggle-theme', handler);
|
||||||
return () => handlers.forEach(({ cleanup }) => cleanup());
|
return cleanup;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettings: (callback) => {
|
||||||
|
// P2-3:同上,拆 onMenuCommand。
|
||||||
|
const handler = () => callback();
|
||||||
|
ipcRenderer.on('menu:settings', handler);
|
||||||
|
const cleanup = cleanupOn('menu:settings', handler);
|
||||||
|
return cleanup;
|
||||||
},
|
},
|
||||||
|
|
||||||
onFilesChanged: (callback) => {
|
onFilesChanged: (callback) => {
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ const targets = [
|
|||||||
'main.js',
|
'main.js',
|
||||||
'preload.js',
|
'preload.js',
|
||||||
'scripts/launch.js',
|
'scripts/launch.js',
|
||||||
// src/ 下用 ESM import/export,node --check 会按 ESM 解析(package.json 无 "type" 字段 → CJS),
|
];
|
||||||
// 因此 src/**/*.js 必须以 .mjs 单独跑。
|
|
||||||
|
// 额外需要语法检查的 CJS 目录(递归扫描其中的 .js)。main/ 与 shared/ 都是 CJS
|
||||||
|
// (module.exports),此前被漏检;ESLint 能抓到语法错误,但原生 node --check 覆盖不到。
|
||||||
|
// 注意:walk 仅收集 .js(CJS);若某目录下出现 ESM 文件需另行处理。
|
||||||
|
const cjsDirs = [
|
||||||
|
'shared',
|
||||||
|
'main',
|
||||||
];
|
];
|
||||||
|
|
||||||
const esmTargets = [
|
const esmTargets = [
|
||||||
@@ -41,6 +47,10 @@ function walk(dir, out) {
|
|||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const allCjs = targets.map((p) => path.join(ROOT, p)).filter(fs.existsSync);
|
const allCjs = targets.map((p) => path.join(ROOT, p)).filter(fs.existsSync);
|
||||||
|
for (const d of cjsDirs) {
|
||||||
|
const abs = path.join(ROOT, d);
|
||||||
|
if (fs.existsSync(abs)) walk(abs, allCjs);
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const allEsm = [];
|
const allEsm = [];
|
||||||
|
|||||||
@@ -35,8 +35,22 @@
|
|||||||
* 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此),
|
* 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此),
|
||||||
* 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。
|
* 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。
|
||||||
*/
|
*/
|
||||||
|
// 末尾 $ 锚整串;scheme / data:image / 纯锚点分支用 [^\s]* 兜底后续字符。
|
||||||
|
// 顶层 `(?!)` 拒未在白名单的 scheme:相对路径分支 A (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`)
|
||||||
|
// 会让 `about:blank` / `javascript:foo` / `vbscript:foo` / `data:text/html` 同形态通过
|
||||||
|
// —— 这里在外层把已知危险 / 未授权 scheme 统一拒掉,保留分支 A 的 Mac 文件名修复。
|
||||||
|
// 相对路径分支:
|
||||||
|
// A. `[a-z+.-]+:[a-z\d.\-_]+(?![/\\])` —— Mac/Linux 文件名带单冒号(如
|
||||||
|
// `image:001.jpg`、`screenshot:1.png`),但 `:` 后不许是 `/` 或 `\`
|
||||||
|
// (否则会误吃 `data:text/html`、`javascript:foo/bar` 之类)。
|
||||||
|
// B. `[a-z+.-]+(?:[\\/][^/\\#]*)*` —— 多段路径,支持 `img/a.png` / `../up.md` /
|
||||||
|
// `/abs/x.png` / Windows 反斜杠 / 路径段内含 `:`(如 `dir/sub:dir/file.png`)
|
||||||
|
// / 段内 #fragment(如 `sprite.svg#icon-home`、`page.md#chapter`)。
|
||||||
|
// `data:image/...` 在顶层 `(?!)` 里只拒 `data:text/html`(白名单分支仍允许
|
||||||
|
// `data:image/png` 等栅格格式);非 IMG URI 由 isDangerousUriNonImg 兜底
|
||||||
|
// javascript:/vbscript:/file:/data(?!:image\/),IMG 由 installHooks 的额外检查兜底。
|
||||||
const ALLOWED_URI_REGEXP =
|
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;
|
/^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]+(?![/\\])|[a-z+.-]+(?:[\\/][^/\\#]*)*)$/i;
|
||||||
|
|
||||||
// data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图
|
// data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图
|
||||||
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
|
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
|
||||||
@@ -95,6 +109,9 @@ const DANGEROUS_STYLE_PATTERNS = [
|
|||||||
// url(javascript:...) / url("javascript:...") / url('javascript:...')
|
// url(javascript:...) / url("javascript:...") / url('javascript:...')
|
||||||
// 容忍可选空白 + 单/双引号包裹 + 大小写
|
// 容忍可选空白 + 单/双引号包裹 + 大小写
|
||||||
/url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i,
|
/url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i,
|
||||||
|
// 协议相对 URL(url(//evil.com/...)):浏览器按当前页协议补全为 http(s)://,
|
||||||
|
// 绕过上面的 scheme 白名单,造成外部资源请求(IP / Referer / UA 泄露)。
|
||||||
|
/url\s*\(\s*['"]?\s*\/\//i,
|
||||||
// legacy IE CSS expression()
|
// legacy IE CSS expression()
|
||||||
/expression\s*\(/i,
|
/expression\s*\(/i,
|
||||||
// legacy IE HTC behavior
|
// legacy IE HTC behavior
|
||||||
@@ -187,6 +204,18 @@ function installHooks(DOMPurifyInstance) {
|
|||||||
data.keepAttr = false;
|
data.keepAttr = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 显式拒未在白名单的 scheme:ALLOWED_URI_REGEXP 的分支 A
|
||||||
|
// (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`) 允许 Mac/Linux 文件名带单 `:`
|
||||||
|
// (如 `image:001.jpg`),但形态上同 `javascript:foo` / `vbscript:foo` /
|
||||||
|
// `about:blank` —— 后者也会通过 ALLOWED。isDangerousUriNonImg 只跑非 IMG
|
||||||
|
// 路径且只覆盖危险 scheme,不含 `about:`,IMG 必须自己再拦一道。
|
||||||
|
// data:text/html 在分支 A 里 `:text` 后续遇 `/` 被 `(?![/\\])` 否定、不通过;
|
||||||
|
// 这里保留是为了防御性深度扫描(与 audit Sec-D 风格一致),同时挡住形如
|
||||||
|
// `data:text/html;<script>` 等 `;` 后紧接 script payload 的边界情况。
|
||||||
|
if (/^\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|about\s*:)/i.test(data.attrValue)) {
|
||||||
|
data.keepAttr = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// audit fix (Round 13 / Sec-H2):file:// 必须严格 file:///... 三斜杠形式
|
// audit fix (Round 13 / Sec-H2):file:// 必须严格 file:///... 三斜杠形式
|
||||||
// —— 拒绝 file://host/... 与 file:////host/...。
|
// —— 拒绝 file://host/... 与 file:////host/...。
|
||||||
//
|
//
|
||||||
@@ -207,7 +236,7 @@ function installHooks(DOMPurifyInstance) {
|
|||||||
// 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。
|
// 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。
|
||||||
// 文件绝对路径/file:/https: 不进此分支,行为不变。
|
// 文件绝对路径/file:/https: 不进此分支,行为不变。
|
||||||
data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH
|
data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH
|
||||||
&& /^data:image\//i.test(data.attrValue)
|
&& /^data:image\//i.test(data.attrValue.trim())
|
||||||
) {
|
) {
|
||||||
data.keepAttr = false;
|
data.keepAttr = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const DEFAULT_SETTINGS = Object.freeze({
|
|||||||
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
|
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
|
||||||
readerFontSize: 17, // 阅读字号 (px),范围 12..24
|
readerFontSize: 17, // 阅读字号 (px),范围 12..24
|
||||||
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
|
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
|
||||||
fileListSort: 'name', // 'name' | 'mtime-desc'
|
fileListSort: 'name', // 'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'
|
||||||
autoSaveDebounceMs: AUTO_SAVE_DEFAULT_DEBOUNCE_MS, // 防抖延迟(ms);0 = 关闭;默认 500ms
|
autoSaveDebounceMs: AUTO_SAVE_DEFAULT_DEBOUNCE_MS, // 防抖延迟(ms);0 = 关闭;默认 500ms
|
||||||
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
|
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
|
||||||
sidebarWidth: null, // 侧栏拖拽后的宽度(px);null = 使用 CSS 默认 --w-sidebar
|
sidebarWidth: null, // 侧栏拖拽后的宽度(px);null = 使用 CSS 默认 --w-sidebar
|
||||||
@@ -118,6 +118,9 @@ const SETTINGS_SCHEMA = Object.freeze({
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
min: 1.4,
|
min: 1.4,
|
||||||
max: 2.2,
|
max: 2.2,
|
||||||
|
// 与 readerFontSize / autoSaveDebounceMs / splitRatio 一致启用 clamp,
|
||||||
|
// 手改越界值落盘时夹到范围内而非静默回落默认。
|
||||||
|
clamp: true,
|
||||||
// fix(audit 2026-08):round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
|
// fix(audit 2026-08):round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
|
||||||
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数,round:2 才能保留用户的选择。
|
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数,round:2 才能保留用户的选择。
|
||||||
round: 2,
|
round: 2,
|
||||||
@@ -125,7 +128,7 @@ const SETTINGS_SCHEMA = Object.freeze({
|
|||||||
},
|
},
|
||||||
fileListSort: {
|
fileListSort: {
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['name', 'mtime-desc'],
|
enum: ['name', 'name-desc', 'mtime-desc', 'mtime-asc'],
|
||||||
description: '侧栏文件列表排序',
|
description: '侧栏文件列表排序',
|
||||||
},
|
},
|
||||||
autoSaveDebounceMs: {
|
autoSaveDebounceMs: {
|
||||||
@@ -215,7 +218,9 @@ const SETTINGS_UI_OPTIONS = Object.freeze({
|
|||||||
]),
|
]),
|
||||||
fileListSort: Object.freeze([
|
fileListSort: Object.freeze([
|
||||||
{ value: 'name', label: '按名称', hint: 'A → Z,localeCompare(zh-CN)' },
|
{ value: 'name', label: '按名称', hint: 'A → Z,localeCompare(zh-CN)' },
|
||||||
|
{ value: 'name-desc', label: '按名称', hint: 'Z → A,localeCompare(zh-CN) 反向' },
|
||||||
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
|
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
|
||||||
|
{ value: 'mtime-asc', label: '按修改时间', hint: '最早修改排在最前' },
|
||||||
]),
|
]),
|
||||||
// 自动保存不再需要枚举选项:debounce 延迟是连续数值(0..60000 ms),
|
// 自动保存不再需要枚举选项:debounce 延迟是连续数值(0..60000 ms),
|
||||||
// 由 toolbar 按钮直接 toggle 0 ↔ 500,UI 也不再有「选几秒」的下拉。
|
// 由 toolbar 按钮直接 toggle 0 ↔ 500,UI 也不再有「选几秒」的下拉。
|
||||||
@@ -515,7 +520,7 @@ function sanitizeSync(key, raw) {
|
|||||||
// 注意:trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
|
// 注意:trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
|
||||||
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
|
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
|
||||||
// 但保留 nullable-path 上面已经拦截过的场景)。
|
// 但保留 nullable-path 上面已经拦截过的场景)。
|
||||||
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw;
|
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'nullable-path') ? raw.trim() : raw;
|
||||||
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
|
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
|
||||||
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKey(async 路径)
|
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKey(async 路径)
|
||||||
// 校验 format,coerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
|
// 校验 format,coerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
|
||||||
@@ -537,20 +542,22 @@ function sanitizeSync(key, raw) {
|
|||||||
}
|
}
|
||||||
case 'nullable-string': {
|
case 'nullable-string': {
|
||||||
if (typeof raw !== 'string') return undefined;
|
if (typeof raw !== 'string') return undefined;
|
||||||
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
|
// 与 string 分支一致先 trim,避免未来字段(带 max / url-https format)静默放行前后空白。
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
|
||||||
// fix(audit 2026-08):nullable-string 与 string 一样需要 format 校验,
|
// fix(audit 2026-08):nullable-string 与 string 一样需要 format 校验,
|
||||||
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
|
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
|
||||||
if (raw.length > 0 && rule.format === 'url-https') {
|
if (trimmed.length > 0 && rule.format === 'url-https') {
|
||||||
if (!/^https?:\/\//i.test(raw)) return undefined;
|
if (!/^https?:\/\//i.test(trimmed)) return undefined;
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(raw);
|
parsed = new URL(trimmed);
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
||||||
}
|
}
|
||||||
return raw;
|
return trimmed;
|
||||||
}
|
}
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return typeof raw === 'boolean' ? raw : undefined;
|
return typeof raw === 'boolean' ? raw : undefined;
|
||||||
@@ -589,7 +596,16 @@ function sanitizeSync(key, raw) {
|
|||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
if (!Number.isFinite(n)) return undefined;
|
if (!Number.isFinite(n)) return undefined;
|
||||||
let v = n;
|
let v = n;
|
||||||
|
// 与异步 validateKey 分支对称支持 clamp / round —— 否则同字段 update() clamp
|
||||||
|
// 但 load() 丢成默认,UI 行为漂移(audit 标注过的对称 bug)。
|
||||||
|
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 (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 undefined;
|
if (v < rule.min || v > rule.max) return undefined;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ function inferStatusType(text) {
|
|||||||
return 'success';
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2-23:决策 AI 面板 placeholder 三态文案。
|
||||||
|
*
|
||||||
|
* 优先级:!hasFile 永远盖过 !configured —— 即便用户既没打开文件又没配 AI,
|
||||||
|
* 也只提示「请先打开文件」,避免一屏两行矛盾提示。配置态仅在有文件
|
||||||
|
* 场景下区分。
|
||||||
|
*
|
||||||
|
* @param {{hasFile: boolean, configured: boolean}} state
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function pickPlaceholder({ hasFile, configured }) {
|
||||||
|
if (!hasFile) return '请先打开一个 Markdown 文件';
|
||||||
|
if (!configured) return 'AI 未配置 — 点「⚙ 设置」填 Base URL 与 API Key';
|
||||||
|
return '描述你想如何修改当前文档...';
|
||||||
|
}
|
||||||
|
|
||||||
export class AiChatPanel {
|
export class AiChatPanel {
|
||||||
/**
|
/**
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
@@ -63,12 +79,15 @@ export class AiChatPanel {
|
|||||||
* @param {(requestId:string) => void} options.onCancel
|
* @param {(requestId:string) => void} options.onCancel
|
||||||
* @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify]
|
* @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify]
|
||||||
* 把过程/错误文案转发到外部 UI(statusbar 上的 #status-ai chip)。不传则静默丢弃。
|
* 把过程/错误文案转发到外部 UI(statusbar 上的 #status-ai chip)。不传则静默丢弃。
|
||||||
|
* @param {() => void} [options.onOpenSettings] P2-23:未配置时点击「⚙ 设置」内联链调用。
|
||||||
|
* 不传则按钮 click 无效果(依然可见,让用户看见状态)。
|
||||||
*/
|
*/
|
||||||
constructor({ element, onSubmit, onCancel, onNotify }) {
|
constructor({ element, onSubmit, onCancel, onNotify, onOpenSettings }) {
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.onSubmit = onSubmit;
|
this.onSubmit = onSubmit;
|
||||||
this.onCancel = onCancel;
|
this.onCancel = onCancel;
|
||||||
this.onNotify = typeof onNotify === 'function' ? onNotify : null;
|
this.onNotify = typeof onNotify === 'function' ? onNotify : null;
|
||||||
|
this.onOpenSettings = typeof onOpenSettings === 'function' ? onOpenSettings : null;
|
||||||
this._open = false;
|
this._open = false;
|
||||||
this._enabled = true;
|
this._enabled = true;
|
||||||
this._submitting = false;
|
this._submitting = false;
|
||||||
@@ -83,6 +102,18 @@ export class AiChatPanel {
|
|||||||
const input = this.element.querySelector('.ai-chat-input');
|
const input = this.element.querySelector('.ai-chat-input');
|
||||||
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
||||||
const toggleBtn = this.element.querySelector('.ai-chat-toggle');
|
const toggleBtn = this.element.querySelector('.ai-chat-toggle');
|
||||||
|
const configureLink = this.element.querySelector('.ai-chat-configure-link');
|
||||||
|
|
||||||
|
if (configureLink) {
|
||||||
|
// P2-23:未配置时显示的「⚙ 设置」内联链;点击调 onOpenSettings(如果有)。
|
||||||
|
// 即使没传 callback,按钮也显示 —— 让用户看见状态差异,避免「为什么 AI 不工作」困惑。
|
||||||
|
this._listeners.push([configureLink, 'click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.onOpenSettings) {
|
||||||
|
try { this.onOpenSettings(); } catch { /* 回调失败不影响 UI */ }
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
if (input) {
|
if (input) {
|
||||||
this._listeners.push([input, 'input', () => {
|
this._listeners.push([input, 'input', () => {
|
||||||
@@ -224,13 +255,40 @@ export class AiChatPanel {
|
|||||||
*/
|
*/
|
||||||
setEnabled(enabled) {
|
setEnabled(enabled) {
|
||||||
this._enabled = !!enabled;
|
this._enabled = !!enabled;
|
||||||
|
this._syncInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2-23:通知面板 AI 配置状态变化,刷新 placeholder + 内联设置链。
|
||||||
|
* settings 保存 / 清空 API key 都会触发,placeholder 实时切换「未配置」↔「描述修改」。
|
||||||
|
* @param {boolean} configured
|
||||||
|
*/
|
||||||
|
setConfigured(configured) {
|
||||||
|
this._configured = !!configured;
|
||||||
|
this._syncInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2-23:把 hasFile + configured 同步到 input / submit / configure-link。
|
||||||
|
* 抽出避免 setEnabled / setConfigured 各算一遍。
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_syncInputState() {
|
||||||
const input = this.element.querySelector('.ai-chat-input');
|
const input = this.element.querySelector('.ai-chat-input');
|
||||||
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
||||||
|
const configureLink = this.element.querySelector('.ai-chat-configure-link');
|
||||||
|
const hasFile = this._enabled;
|
||||||
|
const configured = this._configured !== false; // 默认 true,避免未初始化中间态误判
|
||||||
if (input) {
|
if (input) {
|
||||||
input.disabled = this._inputShouldBeDisabled();
|
input.disabled = this._inputShouldBeDisabled();
|
||||||
input.placeholder = this._enabled ? '描述你想如何修改当前文档...' : '请先打开一个 Markdown 文件';
|
input.placeholder = pickPlaceholder({ hasFile, configured });
|
||||||
}
|
}
|
||||||
if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled();
|
if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled();
|
||||||
|
if (configureLink) {
|
||||||
|
// 内联链:仅在「有文件 + 未配置」时显示。!hasFile 时被「请先打开文件」盖过,
|
||||||
|
// 不必再多一个入口。
|
||||||
|
configureLink.hidden = !hasFile || configured;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ import { AiChatPanel } from './ai-chat-panel.js';
|
|||||||
import { AiDiffPanel } from './ai-diff-panel.js';
|
import { AiDiffPanel } from './ai-diff-panel.js';
|
||||||
import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js';
|
import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取 pendingDiff 的「不可变提交快照」:originalBaseContent 优先,缺时回退
|
||||||
|
* baseContent。apply / skip 漂移判定统一用此函数 —— 避免每个调用点重复
|
||||||
|
* `originalBaseContent || baseContent` 表达式,也方便后续把 baseContent
|
||||||
|
* 拆为「可变重算基线」/「不可变提交快照」两个独立字段时只改一处。
|
||||||
|
* @param {{originalBaseContent?: string, baseContent?: string} | null | undefined} pd
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getImmutableBase(pd) {
|
||||||
|
if (!pd) return '';
|
||||||
|
return pd.originalBaseContent || pd.baseContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给一段行级 lines 构造只读 context rows(用于 AI 普通回复展示)。
|
* 给一段行级 lines 构造只读 context rows(用于 AI 普通回复展示)。
|
||||||
* 行号必须与 shared/markdown-diff.js 的 splitLines 严格一致 —— 否则 viewer
|
* 行号必须与 shared/markdown-diff.js 的 splitLines 严格一致 —— 否则 viewer
|
||||||
@@ -274,9 +287,18 @@ export function createAiController({
|
|||||||
// 走 onNotify → app.js 的 updateStatusAi → statusbar 上的 #status-ai chip。
|
// 走 onNotify → app.js 的 updateStatusAi → statusbar 上的 #status-ai chip。
|
||||||
// 详见 [feedback-ai-tips-into-status-bar]。
|
// 详见 [feedback-ai-tips-into-status-bar]。
|
||||||
onNotify: notify,
|
onNotify: notify,
|
||||||
|
// P2-23:未配置时点击「⚙ 设置」内联链直接打开设置对话框。
|
||||||
|
// 与 togglePanel 中 !isConfigured() → onOpenSettings 同源。
|
||||||
|
onOpenSettings: () => {
|
||||||
|
if (typeof onOpenSettings === 'function') {
|
||||||
|
try { onOpenSettings(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// 一开始禁用(直到用户打开文件)
|
// 一开始禁用(直到用户打开文件)
|
||||||
chatPanel.syncFileState(!!state.currentFile);
|
chatPanel.syncFileState(!!state.currentFile);
|
||||||
|
// P2-23:bootstrap 时同步 AI 配置态(影响 placeholder 三态文案 + 内联设置链)
|
||||||
|
chatPanel.setConfigured(isConfigured());
|
||||||
|
|
||||||
diffPanel = new AiDiffPanel({
|
diffPanel = new AiDiffPanel({
|
||||||
element: diffElement,
|
element: diffElement,
|
||||||
@@ -367,9 +389,12 @@ export function createAiController({
|
|||||||
const isRaw = result.responseFormat === 'raw';
|
const isRaw = result.responseFormat === 'raw';
|
||||||
if (isRaw) {
|
if (isRaw) {
|
||||||
// AI 没改文档,只是回复一段文字
|
// AI 没改文档,只是回复一段文字
|
||||||
|
// originalBaseContent 必须填:apply 漂移判断靠它(与下方非 readOnly 分支对齐),
|
||||||
|
// 缺它会回退到 baseContent,被 recompute 覆写后漂移保护失效。
|
||||||
setPendingDiff({
|
setPendingDiff({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
baseContent: contentSnapshot,
|
baseContent: contentSnapshot,
|
||||||
|
originalBaseContent: contentSnapshot,
|
||||||
nextContent: aiContent,
|
nextContent: aiContent,
|
||||||
isReadOnly: true,
|
isReadOnly: true,
|
||||||
rows: linesToContextRows(aiContent),
|
rows: linesToContextRows(aiContent),
|
||||||
@@ -391,10 +416,13 @@ export function createAiController({
|
|||||||
|
|
||||||
if (!diffResult.regions || diffResult.regions.length === 0) {
|
if (!diffResult.regions || diffResult.regions.length === 0) {
|
||||||
// AI 返回内容与原文一致(已经过 schema 比较过,但 baseContent 可能在
|
// AI 返回内容与原文一致(已经过 schema 比较过,但 baseContent 可能在
|
||||||
// 期间被外部改过,所以再次校验)
|
// 期间被外部改过,所以再次校验)。
|
||||||
|
// 与非 readOnly 分支保持对称:都设 originalBaseContent = contentSnapshot,
|
||||||
|
// 让下游读到 originalBaseContent || baseContent 时语义一致。
|
||||||
setPendingDiff({
|
setPendingDiff({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
baseContent: contentSnapshot,
|
baseContent: contentSnapshot,
|
||||||
|
originalBaseContent: contentSnapshot,
|
||||||
nextContent: aiContent,
|
nextContent: aiContent,
|
||||||
isReadOnly: true,
|
isReadOnly: true,
|
||||||
rows: linesToContextRows(aiContent),
|
rows: linesToContextRows(aiContent),
|
||||||
@@ -408,6 +436,7 @@ export function createAiController({
|
|||||||
setPendingDiff({
|
setPendingDiff({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
baseContent: contentSnapshot,
|
baseContent: contentSnapshot,
|
||||||
|
originalBaseContent: contentSnapshot,
|
||||||
nextContent: aiContent,
|
nextContent: aiContent,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
rows: diffResult.rows,
|
rows: diffResult.rows,
|
||||||
@@ -545,7 +574,9 @@ export function createAiController({
|
|||||||
// 「编辑器的当前实际内容」—— 后续任何重算 / onFileChanged 比对都依赖它。
|
// 「编辑器的当前实际内容」—— 后续任何重算 / onFileChanged 比对都依赖它。
|
||||||
// 旧顺序里如果 aiDiffCompute 抛错,编辑器是新内容、pendingDiff.baseContent
|
// 旧顺序里如果 aiDiffCompute 抛错,编辑器是新内容、pendingDiff.baseContent
|
||||||
// 还是旧内容,用户看到「已应用的修改没体现在剩余 diff 里」的诡异行为。
|
// 还是旧内容,用户看到「已应用的修改没体现在剩余 diff 里」的诡异行为。
|
||||||
pendingDiff = { ...pendingDiff, baseContent: result.content };
|
// 同时把 originalBaseContent 也同步推进到 result.content —— 否则后续
|
||||||
|
// 「应用全部」会与残留的 submit-time 快照对比误判漂移,强制 readOnly。
|
||||||
|
pendingDiff = { ...pendingDiff, baseContent: result.content, originalBaseContent: result.content };
|
||||||
// preview 模式修复:apply 后立即通知外部刷新 viewer。split 模式下
|
// preview 模式修复:apply 后立即通知外部刷新 viewer。split 模式下
|
||||||
// scheduleLivePreview 会覆盖(防双触发不致命 —— loadContentLive 幂等)。
|
// scheduleLivePreview 会覆盖(防双触发不致命 —— loadContentLive 幂等)。
|
||||||
if (typeof onAfterApply === 'function') {
|
if (typeof onAfterApply === 'function') {
|
||||||
@@ -623,7 +654,10 @@ export function createAiController({
|
|||||||
// 或者撤销自己的编辑后重提。最坏情况保留全删路(用户主动重提后 baseContent
|
// 或者撤销自己的编辑后重提。最坏情况保留全删路(用户主动重提后 baseContent
|
||||||
// 重新对齐,apply 不再丢失任何东西)。
|
// 重新对齐,apply 不再丢失任何东西)。
|
||||||
const currentForApply = safeGetContent();
|
const currentForApply = safeGetContent();
|
||||||
if (currentForApply !== null && currentForApply !== pendingDiff.baseContent) {
|
// 漂移判定必须用「提交时的不可变快照」originalBaseContent,而非 pendingDiff.baseContent
|
||||||
|
// —— 后者会被 recomputeDiffFromBase 在每次用户输入后覆写为当前内容,导致本保护永远不触发、
|
||||||
|
// 「应用全部」静默覆盖用户等待期间的手动修改(数据丢失)。
|
||||||
|
if (currentForApply !== null && currentForApply !== getImmutableBase(pendingDiff)) {
|
||||||
if (typeof toast === 'function') {
|
if (typeof toast === 'function') {
|
||||||
toast('你在等待 AI 时已修改过文档,「应用全部」会覆盖未在 diff 内的修改,已切换为逐条确认', 'warning', 4000);
|
toast('你在等待 AI 时已修改过文档,「应用全部」会覆盖未在 diff 内的修改,已切换为逐条确认', 'warning', 4000);
|
||||||
}
|
}
|
||||||
@@ -695,9 +729,11 @@ export function createAiController({
|
|||||||
}
|
}
|
||||||
let nextRows = filteredRows;
|
let nextRows = filteredRows;
|
||||||
let nextRegions = filtered;
|
let nextRegions = filtered;
|
||||||
let nextBase = pendingDiff.baseContent;
|
let nextBase = getImmutableBase(pendingDiff);
|
||||||
const skippedRegion = (pendingDiff.regions || []).find((r) => r.id === regionId);
|
const skippedRegion = (pendingDiff.regions || []).find((r) => r.id === regionId);
|
||||||
if (current !== pendingDiff.baseContent && pendingDiff.nextContent) {
|
// 漂移判定同 handleApplyAll:用提交时的不可变快照 originalBaseContent,
|
||||||
|
// 避免 recompute 把 baseContent 覆写为当前内容后,漏掉"用户等待期间已编辑"的重算。
|
||||||
|
if (current !== getImmutableBase(pendingDiff) && pendingDiff.nextContent) {
|
||||||
try {
|
try {
|
||||||
const recomputed = api.aiDiffCompute(current, pendingDiff.nextContent, pickDiffOptions(current, pendingDiff.nextContent));
|
const recomputed = api.aiDiffCompute(current, pendingDiff.nextContent, pickDiffOptions(current, pendingDiff.nextContent));
|
||||||
if (recomputed && Array.isArray(recomputed.rows) && Array.isArray(recomputed.regions)) {
|
if (recomputed && Array.isArray(recomputed.rows) && Array.isArray(recomputed.regions)) {
|
||||||
@@ -742,7 +778,11 @@ export function createAiController({
|
|||||||
|
|
||||||
pendingDiff = {
|
pendingDiff = {
|
||||||
...pendingDiff,
|
...pendingDiff,
|
||||||
|
// originalBaseContent 跟随 nextBase 推进:下一次 skip / apply 的漂移
|
||||||
|
// 判定窗口不再包含本步跳过造成的编辑差异,否则「跳 1 → 再跳 1」
|
||||||
|
// 都会被假漂移触发重算。
|
||||||
baseContent: nextBase,
|
baseContent: nextBase,
|
||||||
|
originalBaseContent: nextBase,
|
||||||
rows: nextRows,
|
rows: nextRows,
|
||||||
regions: nextRegions,
|
regions: nextRegions,
|
||||||
};
|
};
|
||||||
@@ -911,5 +951,12 @@ export function createAiController({
|
|||||||
hasPendingDiff: () => !!pendingDiff,
|
hasPendingDiff: () => !!pendingDiff,
|
||||||
onFileChanged,
|
onFileChanged,
|
||||||
onContentChanged,
|
onContentChanged,
|
||||||
|
// P2-23:settings 改了(Base URL / API Key / Model)后调用,
|
||||||
|
// 把最新配置态推给 chatPanel 让 placeholder 立即切换「未配置」↔「描述修改」。
|
||||||
|
refreshConfigured: () => {
|
||||||
|
if (chatPanel && typeof chatPanel.setConfigured === 'function') {
|
||||||
|
chatPanel.setConfigured(isConfigured());
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
998
src/app.js
998
src/app.js
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ export function showConfirmDialog({ title, message, buttons = [], icon }) {
|
|||||||
// (app.js 外部修改冲突)把 modal-occupied 误识别为用户主动选择「暂不处理」
|
// (app.js 外部修改冲突)把 modal-occupied 误识别为用户主动选择「暂不处理」
|
||||||
// → 静默推进 lastSavedMtimeMs → 后续外部修改被吞。改为返回 null,让调用方
|
// → 静默推进 lastSavedMtimeMs → 后续外部修改被吞。改为返回 null,让调用方
|
||||||
// 能区分「对话框没出来」与「用户主动取消」。
|
// 能区分「对话框没出来」与「用户主动取消」。
|
||||||
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
|
showToast('已有对话框打开,请先关闭后再试', 'warning');
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/editor-mode-reset.js
Normal file
41
src/editor-mode-reset.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// splitRatio 重置决策 —— applyEditorMode 内部按视觉模式决定是否重置。
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 背景(audit P0-4 + Memory-2):
|
||||||
|
// - 编辑双栏(splitRatio)每次「打开」都默认对半分,drag 比例仅本次会话内
|
||||||
|
// 有效,不跨应用重启保留。
|
||||||
|
// - 旧实现用 `document.body.dataset.editorMode !== 'split'` 做闸 —— 但默认
|
||||||
|
// editorMode='split' 冷启动时 dataset 已是 'split',闸永远为假,上次拖的
|
||||||
|
// 70% 在冷启动被恢复。
|
||||||
|
// - 修复:按 visualMode 决定(不再依赖 DOM dataset / state.editorMode)——
|
||||||
|
// visualMode='split' 即重置,其它不动。包括非 md 文件强制回落 'edit'
|
||||||
|
// 的场景,自然不重置。
|
||||||
|
//
|
||||||
|
// 错误处理:settingsStore.update 失败用 onError 上报(默认 console.warn),
|
||||||
|
// 不抛出 —— 布局重置是非关键路径,写盘失败不应让 app 挂掉。
|
||||||
|
//
|
||||||
|
// mountSplitter.onDragEnd 同步改为 noop(drag 不再持久化),留在 app.js
|
||||||
|
// commit 里人工 review,不在本文件。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视觉模式为 'split' 时,重置 settings.splitRatio 到 0.5;其它模式不动。
|
||||||
|
*
|
||||||
|
* @param {string} visualMode - 解析后的视觉模式('preview' | 'edit' | 'split')
|
||||||
|
* @param {{ update: (partial: object) => Promise<*> }} settingsStore
|
||||||
|
* @param {(msg: string) => void} [onError] - settingsStore.update 失败上报;
|
||||||
|
* 默认 console.warn(不抛,避免布局重置错误炸掉 app)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function applySplitRatioResetIfEnteringSplit(visualMode, settingsStore, onError) {
|
||||||
|
if (visualMode !== 'split') return;
|
||||||
|
// 已经是 0.5 就跳过写盘:本函数会在每次打开 / 模式切换 / save() finally 里被调用,
|
||||||
|
// 若每次都写 splitRatio:0.5,会对 settings 文件产生无意义的写入(触发额外的
|
||||||
|
// scan / 写入竞争)。settingsStore.get 存在时才短路(不存在则照旧写,保持旧行为)。
|
||||||
|
if (typeof settingsStore.get === 'function' && settingsStore.get('splitRatio') === 0.5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const report = onError || ((msg) => console.warn(msg));
|
||||||
|
settingsStore.update({ splitRatio: 0.5 }).catch((e) => {
|
||||||
|
report(`布局比例重置失败:${e?.message || '未知错误'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
608
src/file-list.js
608
src/file-list.js
@@ -68,6 +68,24 @@ function fuzzyScore(name, lowerQuery) {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合法 sortMode 白名单 —— setSort 与构造器都走它,避免漏判 / 拼写错误导致静默
|
||||||
|
// 退回到 'name'。新排序模式只在 SETTINGS_UI_OPTIONS.fileListSort 与本常量同时更新。
|
||||||
|
const SORT_MODES = new Set(['name', 'name-desc', 'mtime-desc', 'mtime-asc']);
|
||||||
|
function normalizeSortMode(mode) {
|
||||||
|
return SORT_MODES.has(mode) ? mode : 'name';
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit fix (R7a P1-15):windowed 虚拟滚动常量。
|
||||||
|
// - FILE_ITEM_HEIGHT 必须与 .file-item 的 height CSS 同步(src/styles/05-sidebar.css)
|
||||||
|
// 改一边忘了另一边会出"位置算出来但渲染时错位"。
|
||||||
|
// - OVERSCAN 是「视口外多渲几个」的缓冲,跨边界滚动时不出现短暂空白。
|
||||||
|
// 5 个 = 250px 缓冲,肉眼看不出延迟。
|
||||||
|
// - SENTINEL_CLASS 是撑出滚动条高度的占位 <li>,不渲染内容、aria-hidden。
|
||||||
|
const FILE_ITEM_HEIGHT = 50;
|
||||||
|
const FILE_ITEM_OVERSCAN = 5;
|
||||||
|
const SENTINEL_CLASS = 'file-list-sentinel';
|
||||||
|
const WINDOWED_CLASS = 'is-windowed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件列表组件
|
* 文件列表组件
|
||||||
*/
|
*/
|
||||||
@@ -77,20 +95,36 @@ export class FileList {
|
|||||||
* @param {HTMLUListElement} options.element - <ul> 容器
|
* @param {HTMLUListElement} options.element - <ul> 容器
|
||||||
* @param {HTMLInputElement} options.searchInput - 搜索输入框
|
* @param {HTMLInputElement} options.searchInput - 搜索输入框
|
||||||
* @param {(file: {path:string,name:string}) => void} options.onSelect - 点击文件回调
|
* @param {(file: {path:string,name:string}) => void} options.onSelect - 点击文件回调
|
||||||
* @param {string} [options.sortMode='name'] - 排序模式:'name' | 'mtime-desc'
|
* @param {string} [options.sortMode='name'] - 排序模式:'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'
|
||||||
*/
|
*/
|
||||||
constructor({ element, searchInput, onSelect, onContextMenu, sortMode = 'name' }) {
|
constructor({ element, searchInput, onSelect, onContextMenu, onContextMenuEmpty, onDropIntoFolder, sortMode = 'name' }) {
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.searchInput = searchInput;
|
this.searchInput = searchInput;
|
||||||
this.onSelect = onSelect;
|
this.onSelect = onSelect;
|
||||||
this.onContextMenu = onContextMenu;
|
this.onContextMenu = onContextMenu;
|
||||||
|
// 空白区域右键:没命中任何 file-item 时回调(弹「粘贴到当前目录」菜单)。
|
||||||
|
this.onContextMenuEmpty = typeof onContextMenuEmpty === 'function' ? onContextMenuEmpty : null;
|
||||||
|
// 拖拽到文件夹:把条目拖到 folder 项上时回调(move;按住 Ctrl/Cmd 时 copy)。
|
||||||
|
this.onDropIntoFolder = typeof onDropIntoFolder === 'function' ? onDropIntoFolder : null;
|
||||||
|
// 当前正在拖拽的源条目(dragstart 写入、dragend 清空)。
|
||||||
|
// dataTransfer 在 dragover 阶段读不到自定义数据,所以源靠这里保存。
|
||||||
|
this._dragEntry = null;
|
||||||
this.files = [];
|
this.files = [];
|
||||||
this.filteredFiles = [];
|
this.filteredFiles = [];
|
||||||
this.activePath = null;
|
this.activePath = null;
|
||||||
this.searchTerm = '';
|
this.searchTerm = '';
|
||||||
this.sortMode = sortMode === 'mtime-desc' ? 'mtime-desc' : 'name';
|
this.sortMode = normalizeSortMode(sortMode);
|
||||||
/** path → file,供事件委托时反查(避免每个 <li> 挂一个闭包) */
|
/** path → file,供事件委托时反查(避免每个 <li> 挂一个闭包) */
|
||||||
this._byPath = new Map();
|
this._byPath = new Map();
|
||||||
|
// audit fix (R7a P1-15):windowed 虚拟滚动状态。
|
||||||
|
// - _sentinel:撑出滚动条高度的占位 <li>
|
||||||
|
// - _pool:复用的 <li> 池,scroll 时只 flush textContent + classList
|
||||||
|
// - _windowed:当前是否在 windowed 模式(决定 render 走哪条路径)
|
||||||
|
// - _scrollFrame:rAF token,scroll 事件节流
|
||||||
|
this._sentinel = null;
|
||||||
|
this._pool = [];
|
||||||
|
this._windowed = false;
|
||||||
|
this._scrollFrame = null;
|
||||||
|
|
||||||
// 统一 lifecycle:dispose() 一次性 unbind 所有 listener。
|
// 统一 lifecycle:dispose() 一次性 unbind 所有 listener。
|
||||||
createDisposable(this);
|
createDisposable(this);
|
||||||
@@ -128,13 +162,101 @@ export class FileList {
|
|||||||
}
|
}
|
||||||
}, { signal: this.signal });
|
}, { signal: this.signal });
|
||||||
|
|
||||||
// 右键菜单:弹给调用方处理(context-menu.js)
|
// 右键菜单:命中 file-item 弹条目菜单;空白区域弹「粘贴到当前目录」。
|
||||||
element.addEventListener('contextmenu', (e) => {
|
element.addEventListener('contextmenu', (e) => {
|
||||||
const file = this._fileFromEvent(e);
|
const file = this._fileFromEvent(e);
|
||||||
if (!file || !this.onContextMenu) return;
|
if (file && this.onContextMenu) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.onContextMenu(file, e.clientX, e.clientY);
|
this.onContextMenu(file, e.clientX, e.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 没命中条目(列表空白 / 滚动条留白)→ 给调用方弹粘贴菜单
|
||||||
|
if (this.onContextMenuEmpty) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onContextMenuEmpty(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
}, { signal: this.signal });
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
// 拖拽:把文件 / 文件夹拖到其他条目。
|
||||||
|
// 仅 folder 条目是合法投放目标;dragstart 记录源条目,dragover/drop 用事件
|
||||||
|
// 委托反查目标 folder。move = 普通拖拽,copy = 按住 Ctrl/Cmd 拖拽。
|
||||||
|
// 用 createDisposable 的 signal 统一解绑(dispose 时自动移除)。
|
||||||
|
element.addEventListener('dragstart', (e) => {
|
||||||
|
const li = e.target.closest && e.target.closest('.file-item');
|
||||||
|
if (!li) return;
|
||||||
|
const entry = this._byPath.get(li.dataset.path);
|
||||||
|
if (!entry) return;
|
||||||
|
this._dragEntry = entry;
|
||||||
|
try {
|
||||||
|
e.dataTransfer.setData('text/plain', entry.path);
|
||||||
|
e.dataTransfer.setData('application/x-notes-path',
|
||||||
|
JSON.stringify({ path: entry.path, entryType: entry.entryType, name: entry.name }));
|
||||||
|
} catch { /* 某些浏览器 setData 受限,但 drag 仍可继续 */ }
|
||||||
|
e.dataTransfer.effectAllowed = (e.ctrlKey || e.metaKey) ? 'copy' : 'move';
|
||||||
|
li.classList.add('is-dragging');
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
element.addEventListener('dragover', (e) => {
|
||||||
|
if (!this._dragEntry) return;
|
||||||
|
const li = e.target.closest && e.target.closest('.file-item');
|
||||||
|
this._clearDropTargets();
|
||||||
|
if (!li) { this._setDropEffect(e, 'none'); return; }
|
||||||
|
const target = this._byPath.get(li.dataset.path);
|
||||||
|
if (!target || target.entryType !== 'folder' || this._isInvalidDropTarget(this._dragEntry, target)) {
|
||||||
|
this._setDropEffect(e, 'none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault(); // 允许 drop 的必要前提
|
||||||
|
this._setDropEffect(e, (e.ctrlKey || e.metaKey) ? 'copy' : 'move');
|
||||||
|
li.classList.add('is-drop-target');
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
// 真正离开列表(relatedTarget 跑到列表外)才清高亮;子项之间移动会冒泡
|
||||||
|
// dragleave,但 relatedTarget 仍在列表内,不误清。
|
||||||
|
element.addEventListener('dragleave', (e) => {
|
||||||
|
if (e.relatedTarget && element.contains(e.relatedTarget)) return;
|
||||||
|
this._clearDropTargets();
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
element.addEventListener('drop', (e) => {
|
||||||
|
if (!this._dragEntry) return;
|
||||||
|
const li = e.target.closest && e.target.closest('.file-item');
|
||||||
|
this._clearDropTargets();
|
||||||
|
if (!li) return;
|
||||||
|
const target = this._byPath.get(li.dataset.path);
|
||||||
|
if (!target || target.entryType !== 'folder' || this._isInvalidDropTarget(this._dragEntry, target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const copy = !!(e.ctrlKey || e.metaKey);
|
||||||
|
const dragged = this._dragEntry;
|
||||||
|
if (this.onDropIntoFolder) this.onDropIntoFolder(dragged, target, { copy });
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
element.addEventListener('dragend', () => {
|
||||||
|
this._clearDropTargets();
|
||||||
|
if (this._dragEntry && this.element) {
|
||||||
|
const dragLi = Array.from(this.element.children).find(
|
||||||
|
(li) => li.dataset && li.dataset.path === this._dragEntry.path,
|
||||||
|
);
|
||||||
|
if (dragLi) dragLi.classList.remove('is-dragging');
|
||||||
|
}
|
||||||
|
this._dragEntry = null;
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
// audit fix (R7a P1-15):windowed 模式 scroll 监听。
|
||||||
|
// passive: true 不阻塞滚动主线程;rAF 节流(_scheduleWindowedRender)保证
|
||||||
|
// 一帧最多一次 render,避免快速滚动时每个 scroll 事件都触发全池子 update。
|
||||||
|
element.addEventListener('scroll', () => this._scheduleWindowedRender(), {
|
||||||
|
signal: this.signal,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
// dispose 时把挂起的 rAF 清掉,否则它会在组件已销毁后
|
||||||
|
// 触发 _renderWindowed 访问已被释放的 this.filteredFiles。
|
||||||
|
this.addAbort(() => {
|
||||||
|
if (this._scrollFrame) {
|
||||||
|
cancelAnimationFrame(this._scrollFrame);
|
||||||
|
this._scrollFrame = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
@@ -183,12 +305,55 @@ export class FileList {
|
|||||||
return this._byPath.get(li.dataset.path) || null;
|
return this._byPath.get(li.dataset.path) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有 .is-drop-target 高亮(drop 目标指示)。
|
||||||
|
*/
|
||||||
|
_clearDropTargets() {
|
||||||
|
if (!this.element) return;
|
||||||
|
for (const li of this.element.children) {
|
||||||
|
if (li.classList && li.classList.contains('is-drop-target')) {
|
||||||
|
li.classList.remove('is-drop-target');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全设置 dataTransfer.dropEffect(部分浏览器 dataTransfer 受限时静默失败)。
|
||||||
|
* @param {DragEvent} e
|
||||||
|
* @param {'move'|'copy'|'none'} effect
|
||||||
|
*/
|
||||||
|
_setDropEffect(e, effect) {
|
||||||
|
try { e.dataTransfer.dropEffect = effect; } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 src 能否投放到 target(target 必须是 folder)。
|
||||||
|
* 非法情形:
|
||||||
|
* - 拖到自己(src.path === target.path)
|
||||||
|
* - 文件夹拖入自身或其子孙(循环 / 路径穿越)
|
||||||
|
* - 源已在 target 内部(同目录拖入 = 无意义的 name (2) 重名)
|
||||||
|
* @param {{path:string,entryType:string}} src
|
||||||
|
* @param {{path:string,entryType:string}} target
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isInvalidDropTarget(src, target) {
|
||||||
|
if (!src || !target) return true;
|
||||||
|
if (src.path === target.path) return true;
|
||||||
|
// 跨平台分隔符(Windows \ 与 POSIX /)都覆盖
|
||||||
|
const selfDesc = src.entryType === 'folder'
|
||||||
|
&& (target.path === src.path + '/' || target.path === src.path + '\\'
|
||||||
|
|| target.path.startsWith(src.path + '/') || target.path.startsWith(src.path + '\\'));
|
||||||
|
const alreadyInside = target.path === src.path
|
||||||
|
|| target.path.startsWith(src.path + '/') || target.path.startsWith(src.path + '\\');
|
||||||
|
return !!(selfDesc || alreadyInside);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置排序模式(供设置对话框等调用方热切换)
|
* 设置排序模式(供设置对话框等调用方热切换)
|
||||||
* @param {'name' | 'mtime-desc'} mode
|
* @param {'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'} mode
|
||||||
*/
|
*/
|
||||||
setSort(mode) {
|
setSort(mode) {
|
||||||
const next = mode === 'mtime-desc' ? 'mtime-desc' : 'name';
|
const next = normalizeSortMode(mode);
|
||||||
if (next === this.sortMode) return;
|
if (next === this.sortMode) return;
|
||||||
this.sortMode = next;
|
this.sortMode = next;
|
||||||
this.applyFilter();
|
this.applyFilter();
|
||||||
@@ -281,13 +446,27 @@ export class FileList {
|
|||||||
// audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入
|
// audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入
|
||||||
// 的 case,fs 返回的是磁盘真实 case。直接 === 比对会让侧栏永远找不到 active
|
// 的 case,fs 返回的是磁盘真实 case。直接 === 比对会让侧栏永远找不到 active
|
||||||
// 条目、不显示高亮。统一走 pathsEqual(含 Windows case-fold)。
|
// 条目、不显示高亮。统一走 pathsEqual(含 Windows case-fold)。
|
||||||
|
// audit fix (R7a P1-15):windowed 模式下 _sentinel 也是 element.children,
|
||||||
|
// 它的 dataset.path 是空字符串,与 activePath 永远不等,无副作用(不会变
|
||||||
|
// is-active),但白跑一遍 pathsEqual 不必要。显式 skip 节省一点点。
|
||||||
for (const li of this.element.children) {
|
for (const li of this.element.children) {
|
||||||
|
if (li.classList.contains(SENTINEL_CLASS)) continue;
|
||||||
const active = pathsEqual(li.dataset.path, this.activePath);
|
const active = pathsEqual(li.dataset.path, this.activePath);
|
||||||
li.classList.toggle('is-active', active);
|
li.classList.toggle('is-active', active);
|
||||||
li.setAttribute('aria-selected', active ? 'true' : 'false');
|
li.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前高亮条目对应的 entry 对象(供键盘剪切 / 复制 / 粘贴定位源条目)。
|
||||||
|
* 没有高亮时返回 null。windowed 模式下 _byPath 仍含全部条目,查找不受影响。
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
getActiveEntry() {
|
||||||
|
if (!this.activePath) return null;
|
||||||
|
return this._byPath.get(this.activePath) || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表获焦时由键盘导航调用:移动激活态到相邻项。只挪高亮 + 焦点,
|
* 列表获焦时由键盘导航调用:移动激活态到相邻项。只挪高亮 + 焦点,
|
||||||
* 不触发 onSelect —— 打开文件由 Enter / Space 键负责(走 _fileFromEvent)。
|
* 不触发 onSelect —— 打开文件由 Enter / Space 键负责(走 _fileFromEvent)。
|
||||||
@@ -295,26 +474,40 @@ export class FileList {
|
|||||||
* @param {number} delta -1 上一项 / +1 下一项 / 'first' / 'last'
|
* @param {number} delta -1 上一项 / +1 下一项 / 'first' / 'last'
|
||||||
*/
|
*/
|
||||||
moveActive(delta) {
|
moveActive(delta) {
|
||||||
const items = Array.from(this.element.querySelectorAll('.file-item'));
|
const total = this.filteredFiles.length;
|
||||||
if (items.length === 0) return;
|
if (total === 0) return;
|
||||||
// audit fix:与 setActive 保持一致 —— Windows 上 case-fold 比对。
|
// audit fix (R7a P1-15):windowed 模式下 DOM 里的 .file-item 只是当前
|
||||||
let idx = items.findIndex(li => pathsEqual(li.dataset.path, this.activePath));
|
// 可见的窗口,activePath 对应的项可能不在 DOM 里。用 filteredFiles
|
||||||
|
// 反查 idx 而不是 querySelectorAll('.file-item')。
|
||||||
|
// audit fix:Windows 大小写不敏感走 pathsEqual。
|
||||||
|
let idx = this.activePath
|
||||||
|
? this.filteredFiles.findIndex(f => pathsEqual(f.path, this.activePath))
|
||||||
|
: -1;
|
||||||
if (idx < 0) idx = 0;
|
if (idx < 0) idx = 0;
|
||||||
if (delta === 'first') idx = 0;
|
if (delta === 'first') idx = 0;
|
||||||
else if (delta === 'last') idx = items.length - 1;
|
else if (delta === 'last') idx = total - 1;
|
||||||
else idx = Math.max(0, Math.min(items.length - 1, idx + delta));
|
else idx = Math.max(0, Math.min(total - 1, idx + delta));
|
||||||
const target = items[idx];
|
const target = this.filteredFiles[idx];
|
||||||
// 直接给目标项加高亮,避免 setActive→render 整树重画把焦点带走
|
this.activePath = target.path;
|
||||||
items.forEach(li => {
|
// 给当前 DOM 里的 .file-item(窗口化的池子)打高亮;sentinel 跳过。
|
||||||
const active = li === target;
|
// 旧的 forEach(items) 写法在 windowed 下不可靠 —— target 可能不在池里。
|
||||||
|
for (const li of this.element.children) {
|
||||||
|
if (li.classList.contains(SENTINEL_CLASS)) continue;
|
||||||
|
const active = pathsEqual(li.dataset.path, this.activePath);
|
||||||
li.classList.toggle('is-active', active);
|
li.classList.toggle('is-active', active);
|
||||||
li.setAttribute('aria-selected', active ? 'true' : 'false');
|
li.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
});
|
}
|
||||||
this.activePath = target.dataset.path;
|
// windowed 模式:把 target 滚到可视区(仅当 activePath 真正变化时调 scroll)
|
||||||
// audit fix (2.4):focus 默认会触发 scrollIntoView → 侧栏布局可能跳动。
|
this._scrollIndexIntoView(idx);
|
||||||
// 改用 preventScroll,再用 scrollIntoView('nearest') 把项拉到可视区但不强制居中。
|
// focus 当前 activePath 对应的 <li>(可能在池里、可能不在);
|
||||||
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
|
// 不在时 focus 落到 ul 容器上,键盘导航下次按键仍能 continue。
|
||||||
try { target.scrollIntoView({ block: 'nearest' }); } catch { /* ignore */ }
|
const targetLi = Array.from(this.element.children).find(li =>
|
||||||
|
!li.classList.contains(SENTINEL_CLASS) && pathsEqual(li.dataset.path, this.activePath));
|
||||||
|
if (targetLi) {
|
||||||
|
// audit fix (2.4):focus 默认会触发 scrollIntoView → 侧栏布局可能跳动。
|
||||||
|
// 改用 preventScroll,再用 _scrollIndexIntoView 把项拉到可视区但不强制居中。
|
||||||
|
try { targetLi.focus({ preventScroll: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -342,24 +535,45 @@ export class FileList {
|
|||||||
scored.sort((a, b) => b.score - a.score);
|
scored.sort((a, b) => b.score - a.score);
|
||||||
list = scored.map(s => s.file);
|
list = scored.map(s => s.file);
|
||||||
}
|
}
|
||||||
// 仅在无搜索时使用 sortMode;有搜索时 fuzzy 排序已覆盖
|
// 有搜索时 fuzzy 排序已覆盖 sortMode,无需再切(避免重复扫描 + 误判)
|
||||||
if (!term) {
|
if (term) {
|
||||||
if (this.sortMode === 'mtime-desc') {
|
this.filteredFiles = list;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (this.sortMode) {
|
||||||
|
case 'mtime-desc':
|
||||||
list.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
list.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
||||||
} else {
|
break;
|
||||||
|
case 'mtime-asc':
|
||||||
|
list.sort((a, b) => (a.mtimeMs || 0) - (b.mtimeMs || 0));
|
||||||
|
break;
|
||||||
|
case 'name-desc':
|
||||||
|
// localeCompare(zh-CN) 反向:b 在前,a 在后
|
||||||
|
list.sort((a, b) => b.name.localeCompare(a.name, 'zh-CN'));
|
||||||
|
break;
|
||||||
|
case 'name':
|
||||||
|
default:
|
||||||
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||||
}
|
break;
|
||||||
}
|
}
|
||||||
this.filteredFiles = list;
|
this.filteredFiles = list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染 DOM
|
* 渲染 DOM
|
||||||
|
*
|
||||||
|
* audit fix (R7a P1-15):windowed 虚拟滚动分发。
|
||||||
|
* 10000 文件全量重建 ~200ms+ → 走 windowed 只渲 visibleCount+overscan 个,
|
||||||
|
* scroll 时复用 _pool 的 <li>,只 flush textContent + classList。
|
||||||
|
*
|
||||||
|
* 模式选择:
|
||||||
|
* - clientHeight === 0(jsdom 测试 / 隐藏的侧栏 / 测布局前)
|
||||||
|
* - 或 total ≤ visibleCount + 2*overscan(小列表)
|
||||||
|
* → 走 _renderFull,全量重建(保持 jsdom 既有 34 个测试直接通过)
|
||||||
|
* 其他情况 → 走 _renderWindowed,sentinel + pool + absolute 定位
|
||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
if (!this.element) return;
|
if (!this.element) return;
|
||||||
this.element.replaceChildren();
|
|
||||||
|
|
||||||
// 重建 path → file 索引,供委托的 click / keydown 反查
|
// 重建 path → file 索引,供委托的 click / keydown 反查
|
||||||
this._byPath.clear();
|
this._byPath.clear();
|
||||||
for (const file of this.filteredFiles) {
|
for (const file of this.filteredFiles) {
|
||||||
@@ -375,6 +589,8 @@ export class FileList {
|
|||||||
const emptyMsg = this.searchTerm
|
const emptyMsg = this.searchTerm
|
||||||
? `没有匹配 “${this.searchTerm}” 的文件`
|
? `没有匹配 “${this.searchTerm}” 的文件`
|
||||||
: '此文件夹为空';
|
: '此文件夹为空';
|
||||||
|
this._teardownWindowed();
|
||||||
|
this.element.replaceChildren();
|
||||||
this.element.dataset.empty = emptyMsg;
|
this.element.dataset.empty = emptyMsg;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -382,68 +598,128 @@ export class FileList {
|
|||||||
// 有内容时清掉 data-empty,避免渲染期间被 :empty::after 短暂闪一下
|
// 有内容时清掉 data-empty,避免渲染期间被 :empty::after 短暂闪一下
|
||||||
this.element.removeAttribute('data-empty');
|
this.element.removeAttribute('data-empty');
|
||||||
|
|
||||||
const term = this.searchTerm;
|
const total = this.filteredFiles.length;
|
||||||
|
const viewportH = this.element.clientHeight;
|
||||||
|
const visibleCount = viewportH > 0 ? Math.ceil(viewportH / FILE_ITEM_HEIGHT) : 0;
|
||||||
|
|
||||||
|
// 走 full 模式的两个条件(满足任一即 full):
|
||||||
|
// 1) clientHeight 0 → 没法算窗口,先 full 渲(jsdom / 隐藏侧栏)
|
||||||
|
// 2) 总数 ≤ 可见 + 2*overscan → 虚拟化没收益还多 sentinel 开销
|
||||||
|
if (viewportH === 0 || total <= visibleCount + FILE_ITEM_OVERSCAN * 2) {
|
||||||
|
if (this._windowed) this._teardownWindowed();
|
||||||
|
this._renderFull();
|
||||||
|
} else {
|
||||||
|
this._renderWindowed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量渲染(兼容 jsdom / 小列表)
|
||||||
|
* 行为与 audit R7a P1-15 之前一致:replaceChildren + createElement + appendChild
|
||||||
|
*/
|
||||||
|
_renderFull() {
|
||||||
|
this.element.replaceChildren();
|
||||||
|
this._windowed = false;
|
||||||
|
const term = this.searchTerm;
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
for (const file of this.filteredFiles) {
|
for (const file of this.filteredFiles) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'file-item';
|
this._fillItem(li, file, term, false, 0);
|
||||||
li.dataset.path = file.path;
|
frag.appendChild(li);
|
||||||
// entryType / isFolder 是渲染端做图标 / 灰态分流的依据,
|
}
|
||||||
// 也让右键菜单 / onSelect 分发有统一锚点。
|
this.element.appendChild(frag);
|
||||||
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';
|
* windowed 虚拟滚动渲染。
|
||||||
// 搜索高亮:纯 textContent + createElement('mark'),避免 innerHTML 引入 XSS
|
* - 用 _sentinel 撑出 total * FILE_ITEM_HEIGHT 的滚动高度
|
||||||
if (term) {
|
* - 用 _pool 维护 visibleCount + 2*overscan 个复用的 <li>
|
||||||
const lowerName = file.name.toLowerCase();
|
* - 每个 <li> position:absolute + top:N*50 钉在指定行
|
||||||
const idx = lowerName.indexOf(term);
|
* - 滚动时只 flush textContent + classList,不重新创建 DOM
|
||||||
if (idx >= 0) {
|
*/
|
||||||
const before = file.name.slice(0, idx);
|
_renderWindowed() {
|
||||||
const hit = file.name.slice(idx, idx + term.length);
|
if (!this._windowed) {
|
||||||
const after = file.name.slice(idx + term.length);
|
// 首次进入 windowed:清掉 full 模式的旧 <li>,建 sentinel
|
||||||
if (before) name.appendChild(document.createTextNode(before));
|
this.element.replaceChildren();
|
||||||
const mark = document.createElement('mark');
|
this._sentinel = document.createElement('li');
|
||||||
mark.textContent = hit;
|
this._sentinel.className = SENTINEL_CLASS;
|
||||||
name.appendChild(mark);
|
this._sentinel.setAttribute('aria-hidden', 'true');
|
||||||
if (after) name.appendChild(document.createTextNode(after));
|
this.element.appendChild(this._sentinel);
|
||||||
} else {
|
this._pool = [];
|
||||||
name.textContent = file.name;
|
this._windowed = true;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
name.textContent = file.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const total = this.filteredFiles.length;
|
||||||
meta.className = 'file-meta';
|
const viewportH = this.element.clientHeight;
|
||||||
// Stage 8:folder / binary 没有 size+mtime,只有 editable 显示完整 meta。
|
const visibleCount = Math.ceil(viewportH / FILE_ITEM_HEIGHT);
|
||||||
// folder 显示「文件夹」;binary 显示大小 + 「不可编辑」提示。
|
const scrollTop = this.element.scrollTop;
|
||||||
if (file.entryType === 'folder') {
|
// startIndex:scrollTop 落在哪一行。减 overscan 让上方提前几行开始出现,
|
||||||
meta.textContent = '文件夹';
|
// 跨边界滚动时不出现"滚到了但还有几行空白"的延迟感。
|
||||||
} else if (file.entryType === 'binary') {
|
let startIndex = Math.floor(scrollTop / FILE_ITEM_HEIGHT) - FILE_ITEM_OVERSCAN;
|
||||||
meta.textContent = `${formatSize(file.size)} · 不可编辑`;
|
if (startIndex < 0) startIndex = 0;
|
||||||
} else {
|
// endIndex:start + 可见 + 下方 overscan。封顶 total。
|
||||||
meta.textContent = `${formatSize(file.size)} · ${formatRelativeTime(file.mtimeMs)}`;
|
const endIndex = Math.min(total, startIndex + visibleCount + FILE_ITEM_OVERSCAN * 2);
|
||||||
}
|
const needed = endIndex - startIndex;
|
||||||
|
|
||||||
// 文件图标:左侧装饰,让 list 项视觉更有「文件感」。
|
// sentinel 高度 = total * itemHeight,给滚动条撑出总高
|
||||||
// aria-hidden,因为纯视觉;语义信息已经在 file-name 里。
|
this._sentinel.style.height = `${total * FILE_ITEM_HEIGHT}px`;
|
||||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
||||||
|
// 缩放 pool 到 needed:池子小了 append 新 li,池子大了 remove 末尾的
|
||||||
|
while (this._pool.length < needed) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
// 第一次建出来的 li 就要带 WINDOWED_CLASS(绝对定位),否则
|
||||||
|
// append 后到 _fillItem 设置 position 之间会先以 flex item 出现再跳位。
|
||||||
|
li.classList.add(WINDOWED_CLASS);
|
||||||
|
this.element.appendChild(li);
|
||||||
|
this._pool.push(li);
|
||||||
|
}
|
||||||
|
while (this._pool.length > needed) {
|
||||||
|
const li = this._pool.pop();
|
||||||
|
li.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把数据填进 pool 的每个 li(复用:已有 li 只 flush textContent / classList)
|
||||||
|
const term = this.searchTerm;
|
||||||
|
for (let i = 0; i < needed; i++) {
|
||||||
|
const dataIndex = startIndex + i;
|
||||||
|
const file = this.filteredFiles[dataIndex];
|
||||||
|
const li = this._pool[i];
|
||||||
|
this._fillItem(li, file, term, true, dataIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个 file 对象的数据填进已有的 <li>。full 模式与 windowed 模式共用。
|
||||||
|
* 复用关键:只在数据真变化时写 DOM(classList / textContent / dataset)。
|
||||||
|
* @param {HTMLLIElement} li
|
||||||
|
* @param {object} file
|
||||||
|
* @param {string} term - 已 lowercase 的搜索词;空串 = 无搜索
|
||||||
|
* @param {boolean} isWindowed - 是否 windowed 模式(决定是否设 WINDOWED_CLASS + style.top)
|
||||||
|
* @param {number} dataIndex - 当前项在 filteredFiles 里的下标(windowed 用)
|
||||||
|
*/
|
||||||
|
_fillItem(li, file, term, isWindowed, dataIndex) {
|
||||||
|
// 基础属性:path / entryType / class(用 contains 避免重复 add)
|
||||||
|
if (li.dataset.path !== file.path) li.dataset.path = file.path;
|
||||||
|
if (li.dataset.entryType !== file.entryType) li.dataset.entryType = file.entryType;
|
||||||
|
// 所有条目可拖拽(文件 / 文件夹 / 二进制都可选中拖到其它文件夹)
|
||||||
|
if (li.draggable !== true) li.draggable = true;
|
||||||
|
const wantClass = `file-item is-${file.entryType}${isWindowed ? ` ${WINDOWED_CLASS}` : ''}`;
|
||||||
|
if (li.className !== wantClass) li.className = wantClass;
|
||||||
|
if (li.getAttribute('role') !== 'option') li.setAttribute('role', 'option');
|
||||||
|
if (!li.hasAttribute('tabindex')) li.tabIndex = 0;
|
||||||
|
|
||||||
|
// 激活态(Windows 大小写不敏感走 pathsEqual)
|
||||||
|
const isActive = pathsEqual(file.path, this.activePath);
|
||||||
|
li.classList.toggle('is-active', isActive);
|
||||||
|
li.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
|
|
||||||
|
// 子节点:icon / name / meta。首次建后保留引用,scroll 时只改 textContent。
|
||||||
|
let name = li._cachedName;
|
||||||
|
let meta = li._cachedMeta;
|
||||||
|
let icon = li._cachedIcon;
|
||||||
|
if (!name) {
|
||||||
|
// 首次:建好结构并存到 ._cachedXxx。name/meta 是直接子 div,icon 是 svg
|
||||||
|
icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
icon.setAttribute('class', 'file-icon');
|
icon.setAttribute('class', 'file-icon');
|
||||||
icon.setAttribute('viewBox', '0 0 24 24');
|
icon.setAttribute('viewBox', '0 0 24 24');
|
||||||
icon.setAttribute('width', '14');
|
icon.setAttribute('width', '14');
|
||||||
@@ -454,29 +730,153 @@ export class FileList {
|
|||||||
icon.setAttribute('stroke-linecap', 'round');
|
icon.setAttribute('stroke-linecap', 'round');
|
||||||
icon.setAttribute('stroke-linejoin', 'round');
|
icon.setAttribute('stroke-linejoin', 'round');
|
||||||
icon.setAttribute('aria-hidden', 'true');
|
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');
|
const text = document.createElement('div');
|
||||||
text.className = 'file-text';
|
text.className = 'file-text';
|
||||||
|
name = document.createElement('div');
|
||||||
|
name.className = 'file-name';
|
||||||
|
meta = document.createElement('div');
|
||||||
|
meta.className = 'file-meta';
|
||||||
text.appendChild(name);
|
text.appendChild(name);
|
||||||
text.appendChild(meta);
|
text.appendChild(meta);
|
||||||
|
|
||||||
li.appendChild(icon);
|
li.appendChild(icon);
|
||||||
li.appendChild(text);
|
li.appendChild(text);
|
||||||
// 点击 / 键盘由构造函数里的委托监听统一处理,这里不再逐项绑定
|
li._cachedName = name;
|
||||||
frag.appendChild(li);
|
li._cachedMeta = meta;
|
||||||
|
li._cachedIcon = icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称:搜索高亮(纯 textContent + <mark>,避免 innerHTML XSS)
|
||||||
|
if (term) {
|
||||||
|
const lowerName = file.name.toLowerCase();
|
||||||
|
const idx = lowerName.indexOf(term);
|
||||||
|
// 重渲时如果高亮结构变了(之前无 <mark> 现在有 / 反之),直接 replaceChildren
|
||||||
|
const existingMark = name.querySelector('mark');
|
||||||
|
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 (existingMark) {
|
||||||
|
// 复用:把结构稳定归一为 [beforeText, mark, afterText],否则搜索词在
|
||||||
|
// 开头/结尾时 childNodes 数量会变化,走 appendChild 会把旧名字的尾巴拼到新名字后
|
||||||
|
// (如 abc.md → 复用给 abc.txt 显示成 abc.md.txt)。before/after 文本节点可能为空。
|
||||||
|
let beforeNode = existingMark.previousSibling;
|
||||||
|
if (!beforeNode || beforeNode.nodeType !== Node.TEXT_NODE) {
|
||||||
|
beforeNode = document.createTextNode('');
|
||||||
|
name.insertBefore(beforeNode, existingMark);
|
||||||
|
}
|
||||||
|
let afterNode = existingMark.nextSibling;
|
||||||
|
if (!afterNode || afterNode.nodeType !== Node.TEXT_NODE) {
|
||||||
|
afterNode = document.createTextNode('');
|
||||||
|
name.appendChild(afterNode);
|
||||||
|
}
|
||||||
|
beforeNode.nodeValue = before;
|
||||||
|
existingMark.textContent = hit;
|
||||||
|
afterNode.nodeValue = after;
|
||||||
|
} else {
|
||||||
|
// 重建(之前无 mark)
|
||||||
|
name.replaceChildren();
|
||||||
|
if (before) name.appendChild(document.createTextNode(before));
|
||||||
|
const m = document.createElement('mark');
|
||||||
|
m.textContent = hit;
|
||||||
|
name.appendChild(m);
|
||||||
|
if (after) name.appendChild(document.createTextNode(after));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没命中 → 整个 name 设 textContent(无 mark)
|
||||||
|
if (existingMark) {
|
||||||
|
name.textContent = file.name;
|
||||||
|
} else if (name.textContent !== file.name) {
|
||||||
|
name.textContent = file.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无搜索:name 只在变化时写
|
||||||
|
if (name.textContent !== file.name) name.textContent = file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta:folder/binary/editable 三种文案
|
||||||
|
let metaText;
|
||||||
|
if (file.entryType === 'folder') {
|
||||||
|
metaText = '文件夹';
|
||||||
|
} else if (file.entryType === 'binary') {
|
||||||
|
metaText = `${formatSize(file.size)} · 不可编辑`;
|
||||||
|
} else {
|
||||||
|
metaText = `${formatSize(file.size)} · ${formatRelativeTime(file.mtimeMs)}`;
|
||||||
|
}
|
||||||
|
if (meta.textContent !== metaText) meta.textContent = metaText;
|
||||||
|
|
||||||
|
// icon:entryType 不同用不同 SVG path。innerHTML 路径已硬编码且非用户输入,
|
||||||
|
// 这里继续走 innerHTML 比 createElement('path') + setAttribute 快 3-5x,
|
||||||
|
// windowed scroll 触发的 update 路径上值得这点性能。
|
||||||
|
let iconHtml;
|
||||||
|
if (file.entryType === 'folder') {
|
||||||
|
iconHtml = '<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') {
|
||||||
|
iconHtml = '<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 {
|
||||||
|
iconHtml = '<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"/>';
|
||||||
|
}
|
||||||
|
if (icon.innerHTML !== iconHtml) icon.innerHTML = iconHtml;
|
||||||
|
|
||||||
|
// windowed 专属:position:absolute + top:N*50(left/right 在 CSS 的 .is-windowed)
|
||||||
|
if (isWindowed) {
|
||||||
|
const top = dataIndex * FILE_ITEM_HEIGHT;
|
||||||
|
if (li.style.top !== `${top}px`) li.style.top = `${top}px`;
|
||||||
}
|
}
|
||||||
this.element.appendChild(frag);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出 windowed 模式:清掉 sentinel + pool,DOM 回到「无项目」状态
|
||||||
|
* (render 的 full 分支会自己 replaceChildren + append 新 li)
|
||||||
|
*/
|
||||||
|
_teardownWindowed() {
|
||||||
|
if (this._sentinel) {
|
||||||
|
this._sentinel.remove();
|
||||||
|
this._sentinel = null;
|
||||||
|
}
|
||||||
|
for (const li of this._pool) {
|
||||||
|
// 清掉 windowed 专属的 inline style,full 模式不需要 absolute 定位
|
||||||
|
li.style.top = '';
|
||||||
|
li.remove();
|
||||||
|
}
|
||||||
|
this._pool = [];
|
||||||
|
this._windowed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scroll 事件 rAF 节流:一帧最多触发一次 _renderWindowed
|
||||||
|
* 快速滚动 wheel 事件 60+ 次/秒,不节流会卡
|
||||||
|
*/
|
||||||
|
_scheduleWindowedRender() {
|
||||||
|
if (this._scrollFrame) return;
|
||||||
|
if (!this._windowed) return; // full 模式不响应 scroll 事件
|
||||||
|
this._scrollFrame = requestAnimationFrame(() => {
|
||||||
|
this._scrollFrame = null;
|
||||||
|
this._renderWindowed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把第 idx 项滚到可见区域(仅 windowed 模式有效,full 模式 noop)
|
||||||
|
* moveActive 用:跨视口的键盘导航不依赖浏览器原生 focus
|
||||||
|
*/
|
||||||
|
_scrollIndexIntoView(idx) {
|
||||||
|
if (!this._windowed) return;
|
||||||
|
const top = idx * FILE_ITEM_HEIGHT;
|
||||||
|
const bottom = top + FILE_ITEM_HEIGHT;
|
||||||
|
const scrollTop = this.element.scrollTop;
|
||||||
|
const viewportH = this.element.clientHeight;
|
||||||
|
if (top < scrollTop) {
|
||||||
|
this.element.scrollTop = top;
|
||||||
|
} else if (bottom > scrollTop + viewportH) {
|
||||||
|
this.element.scrollTop = bottom - viewportH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** test hook:当前是否在 windowed 模式(单测断言) */
|
||||||
|
_test_isWindowed() { return this._windowed; }
|
||||||
|
/** test hook:当前 pool 大小 */
|
||||||
|
_test_poolSize() { return this._pool.length; }
|
||||||
|
/** test hook:当前 sentinel 是否存在 */
|
||||||
|
_test_hasSentinel() { return !!this._sentinel; }
|
||||||
}
|
}
|
||||||
|
|||||||
453
src/file-ops.js
453
src/file-ops.js
@@ -21,7 +21,7 @@
|
|||||||
import { showToast } from './feedback.js';
|
import { showToast } from './feedback.js';
|
||||||
import { showPromptDialog } from './prompt-dialog.js';
|
import { showPromptDialog } from './prompt-dialog.js';
|
||||||
import { showConfirmDialog } from './confirm-dialog.js';
|
import { showConfirmDialog } from './confirm-dialog.js';
|
||||||
import { isMarkdownFile } from './utils.js';
|
import { isMarkdownFile, basenameOf } from './utils.js';
|
||||||
// 救援工具:与 src/app.js 共享同一份命名 / toast / friendlyFsError 逻辑,
|
// 救援工具:与 src/app.js 共享同一份命名 / toast / friendlyFsError 逻辑,
|
||||||
// 这里只关心「我有 content、想救回」。详见 src/rescue.js 注释。
|
// 这里只关心「我有 content、想救回」。详见 src/rescue.js 注释。
|
||||||
import { rescueDirtyContent } from './rescue.js';
|
import { rescueDirtyContent } from './rescue.js';
|
||||||
@@ -72,10 +72,46 @@ function validateFileName(raw) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验输入是否可作为文件夹名。镜像 main resolveFolderName 的输入合法性检查。
|
||||||
|
* 不校验「重名」—— 那是 IPC 端的事,folder:create 也走主进程协商,不在 renderer 重复实现。
|
||||||
|
*
|
||||||
|
* 与 validateFileName 保持一致:文件夹名**也做扩展名切割**,按基名判定保留设备名。
|
||||||
|
* Windows 对目录同样按基名保留设备名("CON.txt" / "AUX.abc" 都算保留,扩展名被忽略),
|
||||||
|
* 不切割会漏掉 "CON.txt" 这类非法名(客户端校验通过却最终在主进程 / 文件系统被拒)。
|
||||||
|
*
|
||||||
|
* 共享注释见上方 WIN_RESERVED_DEVICE_NAMES:
|
||||||
|
* main 是 CJS(require('fs')),renderer 是 ESM 不能 require Node 模块;
|
||||||
|
* 退而求其次把规则文档化在 main + renderer 两处注释,改动频次极低(Windows 保留设备名
|
||||||
|
* 清单基本不变),双拷贝维护成本可控。
|
||||||
|
*
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function validateFolderName(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 对文件夹同样按「基名」判定保留设备名("CON.txt" / "AUX.abc" 都算保留),
|
||||||
|
// 与文件一致:取最后一个 . 之前的部分判断。不切割会漏掉 "CON.txt" 这类非法名。
|
||||||
|
const lastDotF = name.lastIndexOf('.');
|
||||||
|
const baseForReservedF = (lastDotF > 0 ? name.slice(0, lastDotF) : name).toUpperCase();
|
||||||
|
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReservedF)) {
|
||||||
|
return `"${baseForReservedF}" 是 Windows 保留设备名,不允许作为文件夹名`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} FileOpsCtx
|
* @typedef {object} FileOpsCtx
|
||||||
* @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs)
|
* @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs)
|
||||||
* @property {object} controls - { fileList, viewer, editor, contextMenu }
|
* @property {object} controls - { fileList, viewer, editor, contextMenu }
|
||||||
|
* 注意(P2-9):editor 由 app.js bootstrap 延迟实例化,controls.editor 必须
|
||||||
|
* 经 getter 运行时现取(app.js 用对象字面量 getter 提供),本模块内部
|
||||||
|
* 一律走 getEditor(),不在构造时解构缓存。
|
||||||
* @property {object} api - preload contextBridge 暴露的 window.api
|
* @property {object} api - preload contextBridge 暴露的 window.api
|
||||||
* @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? }
|
* @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? }
|
||||||
*/
|
*/
|
||||||
@@ -88,9 +124,23 @@ export function createFileOps(ctx) {
|
|||||||
if (!state || !controls || !api || !callbacks) {
|
if (!state || !controls || !api || !callbacks) {
|
||||||
throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks');
|
throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks');
|
||||||
}
|
}
|
||||||
const { contextMenu, viewer, fileList, editor } = controls;
|
const { contextMenu, viewer, fileList } = controls;
|
||||||
|
// P2-9(audit C-4):app.js 顶层不再 new MarkdownEditor(CM6 dynamic import,
|
||||||
|
// bootstrap 里才实例化),createFileOps 在模块 eval 时 controls.editor 还是
|
||||||
|
// null —— 不能构造时解构缓存,必须运行时现取。app.js 侧 controls 用对象
|
||||||
|
// 字面量 getter 提供活视图(tests/unit/file-ops-lazy-editor.test.js 锁死)。
|
||||||
|
const getEditor = () => controls.editor;
|
||||||
const { openFile, updateWindowTitle, clearCurrentFile } = callbacks;
|
const { openFile, updateWindowTitle, clearCurrentFile } = callbacks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* editor.getContent() 安全读取:editor 未实例化(null)或无该方法时回 ''。
|
||||||
|
* 仅 bootstrap 完成前的事件路径才可能命中 null 分支,正常恒非空。
|
||||||
|
*/
|
||||||
|
function _editorContentOrEmpty() {
|
||||||
|
const ed = getEditor();
|
||||||
|
return (ed && typeof ed.getContent === 'function') ? ed.getContent() : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名)。
|
* 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名)。
|
||||||
*/
|
*/
|
||||||
@@ -141,13 +191,13 @@ export function createFileOps(ctx) {
|
|||||||
// 审计修复 (Round 11 deep-fix P1-1):syncDirtyFromEditor 把 editor.isDirty()
|
// 审计修复 (Round 11 deep-fix P1-1):syncDirtyFromEditor 把 editor.isDirty()
|
||||||
// 真相同步到 state.isDirty,避免 IME 合成期删除当前文件时跳过 dirty 确认
|
// 真相同步到 state.isDirty,避免 IME 合成期删除当前文件时跳过 dirty 确认
|
||||||
// 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。
|
// 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。
|
||||||
syncDirtyFromEditor(state, editor);
|
syncDirtyFromEditor(state, getEditor());
|
||||||
const isCurrent = state.currentFile && state.currentFile.path === file.path;
|
const isCurrent = state.currentFile && state.currentFile.path === file.path;
|
||||||
// 删除当前文件 + 有未保存改动 → 二次确认
|
// 删除当前文件 + 有未保存改动 → 二次确认
|
||||||
if (isCurrent && state.isDirty) {
|
if (isCurrent && state.isDirty) {
|
||||||
const choice = await showConfirmDialog({
|
const choice = await showConfirmDialog({
|
||||||
title: '删除未保存的文件',
|
title: '删除未保存的文件',
|
||||||
message: `「${file.name}」有未保存改动,确定要删除(移至回收站)吗?`,
|
message: `「${file.name}」有未保存改动。删除(移至回收站)后无法恢复,确定继续?`,
|
||||||
icon: 'danger',
|
icon: 'danger',
|
||||||
buttons: [
|
buttons: [
|
||||||
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||||
@@ -162,26 +212,27 @@ export function createFileOps(ctx) {
|
|||||||
const result = await api.deleteFile(file.path);
|
const result = await api.deleteFile(file.path);
|
||||||
if (!result || !result.ok) {
|
if (!result || !result.ok) {
|
||||||
if (result && result.error === 'FILE_NOT_FOUND') {
|
if (result && result.error === 'FILE_NOT_FOUND') {
|
||||||
// 文件已不在了(用户手动删过、外部工具删过)—— 仿照 renameCurrentOrFile
|
// 文件/文件夹已不在了(用户手动删过、外部工具删过)。dirty 状态下不能直接
|
||||||
// 的 FILE_NOT_FOUND 路径:dirty 状态下不能直接 clearCurrentFile 丢内容。
|
// clearCurrentFile 丢内容 —— 先救到 <base>-rescued-<stamp>.md,再清视图
|
||||||
// 先取 editor.getContent() 救到 <base>-rescued-<stamp>-<suffix>.md,再清视图。
|
// (与 renameCurrentOrFile 的 FILE_NOT_FOUND 路径对齐)。state.deletingPath
|
||||||
// audit fix (Round 4 F1):之前 deleteFile 的 FILE_NOT_FOUND 直接 clearCurrentFile
|
// 已设,挡住 onFilesChanged rescue 分支的并发救回,由本函数自己救一次。
|
||||||
// → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged
|
|
||||||
// rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。
|
|
||||||
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
|
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
|
||||||
// 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。
|
// 命名 / toast 文案 / 错误码 vs message 判断全部收口到 src/rescue.js。
|
||||||
// 命名 / toast 文案 / friendlyFsError 错误码 vs message 的判断
|
|
||||||
// 全部收口到 src/rescue.js(之前这里手动传 r?.message || r?.error
|
|
||||||
// 是 audit Round 16 R2 修过的关键 bug —— 业务码不能给用户看)。
|
|
||||||
rescueDirtyContent({
|
rescueDirtyContent({
|
||||||
api,
|
api,
|
||||||
originalName: state.currentFile.name,
|
originalName: state.currentFile.name,
|
||||||
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
|
content: _editorContentOrEmpty(),
|
||||||
event: '文件已不在',
|
event: '文件已不在',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
showToast('文件已不存在', 'warning');
|
showToast('文件已不存在', 'warning');
|
||||||
}
|
}
|
||||||
|
// 与 renameCurrentOrFile 对齐:dirty 救回 / 非脏提示后都清掉已删除文件的视图,
|
||||||
|
// 否则会留下一个指向已删除文件的空壳视图(之前漏了 clearCurrentFile)。
|
||||||
|
if (state.currentFile && state.currentFile.path === file.path) {
|
||||||
|
clearCurrentFile();
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
return;
|
return;
|
||||||
@@ -214,10 +265,10 @@ export function createFileOps(ctx) {
|
|||||||
if (!file || !file.path) return;
|
if (!file || !file.path) return;
|
||||||
// 审计修复 (Round 11 deep-fix P1-1):sync editor.isDirty() → state.isDirty。
|
// 审计修复 (Round 11 deep-fix P1-1):sync editor.isDirty() → state.isDirty。
|
||||||
// rename 的 dirty 检查在 FILE_NOT_FOUND 路径下出现,IME 期间跳过会丢 IME buffer。
|
// rename 的 dirty 检查在 FILE_NOT_FOUND 路径下出现,IME 期间跳过会丢 IME buffer。
|
||||||
syncDirtyFromEditor(state, editor);
|
syncDirtyFromEditor(state, getEditor());
|
||||||
const raw = await showPromptDialog({
|
const raw = await showPromptDialog({
|
||||||
title: '重命名',
|
title: '重命名文件',
|
||||||
message: '重命名为',
|
message: '新文件名(可改为其他扩展名)',
|
||||||
defaultValue: file.name,
|
defaultValue: file.name,
|
||||||
confirmLabel: '重命名',
|
confirmLabel: '重命名',
|
||||||
validate: validateFileName,
|
validate: validateFileName,
|
||||||
@@ -259,7 +310,7 @@ export function createFileOps(ctx) {
|
|||||||
rescueDirtyContent({
|
rescueDirtyContent({
|
||||||
api,
|
api,
|
||||||
originalName: state.currentFile.name,
|
originalName: state.currentFile.name,
|
||||||
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
|
content: _editorContentOrEmpty(),
|
||||||
event: '原文件已被删除',
|
event: '原文件已被删除',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -309,9 +360,7 @@ export function createFileOps(ctx) {
|
|||||||
// 再调 applyEditorMode 让工具栏按钮可用性 跟着对。
|
// 再调 applyEditorMode 让工具栏按钮可用性 跟着对。
|
||||||
if (wasMd !== isMd) {
|
if (wasMd !== isMd) {
|
||||||
try {
|
try {
|
||||||
const currentContent = editor && typeof editor.getContent === 'function'
|
const currentContent = _editorContentOrEmpty();
|
||||||
? editor.getContent()
|
|
||||||
: '';
|
|
||||||
if (isMd) {
|
if (isMd) {
|
||||||
viewer.loadContent(state.currentFile, currentContent);
|
viewer.loadContent(state.currentFile, currentContent);
|
||||||
} else {
|
} else {
|
||||||
@@ -353,31 +402,40 @@ export function createFileOps(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 顶部「+」新建按钮:弹 prompt 取名 → 走 createFile → 自动打开新文件。
|
* 「新建笔记」入口:弹 prompt 取名 → 走 createFile → 自动打开新文件。
|
||||||
*
|
*
|
||||||
* Stage 8:把 state.currentDir 透传给 api.createFile,让新文件落在当前浏览的
|
* 2026-09 设计优化后,调用入口只剩两个:
|
||||||
* 子目录里(而不是永远落到数据根)。
|
* - 侧栏空白区域右键菜单的「新建文件」(src/app.js#showSidebarEmptyContextMenu)
|
||||||
|
* - 文件夹条目右键菜单的「在文件夹中新建文件」(showFileListContextMenu 的
|
||||||
|
* `new_file_in` 项,传 entry.path 作 targetDir)
|
||||||
|
* 侧栏顶部原「+」按钮已移除;旧的「顶部 + 按钮」注释保留会让后续维护者
|
||||||
|
* grep 不到调用点。Stage 8 把 state.currentDir 透传给 api.createFile,让新
|
||||||
|
* 文件落在当前浏览的子目录里(而不是永远落到数据根)。
|
||||||
*
|
*
|
||||||
* 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动
|
* 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动
|
||||||
* 加上」—— 默认值已带 .md,用户可见 / 可改(直接改 .txt 等)。主进程
|
* 加上」—— 默认值已带 .md,用户可见 / 可改(直接改 .txt 等)。主进程
|
||||||
* resolveFileName 也对齐 resolveRenameName:用户输入什么就用什么,
|
* resolveFileName 也对齐 resolveRenameName:用户输入什么就用什么,
|
||||||
* 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json
|
* 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json
|
||||||
* 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。
|
* 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。
|
||||||
|
*
|
||||||
|
* @param {string} [targetDir] 可选:在某个文件夹内新建时传入该文件夹绝对路径
|
||||||
|
* (侧栏右键文件夹 → 「在文件夹中新建文件」)。省略则落在当前浏览目录
|
||||||
|
* state.currentDir。prompt 文案据 targetDir 显示「当前文件夹:xxx」。
|
||||||
*/
|
*/
|
||||||
async function createNewFile() {
|
async function createNewFile(targetDir) {
|
||||||
// 审计修复 (Round 11 deep-fix P1-1):entry sync 把 editor.isDirty() 真相同步给
|
// 审计修复 (Round 11 deep-fix P1-1):entry sync 把 editor.isDirty() 真相同步给
|
||||||
// state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认,
|
// state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认,
|
||||||
// 用户的 CJK buffer 静默丢失。
|
// 用户的 CJK buffer 静默丢失。
|
||||||
syncDirtyFromEditor(state, editor);
|
syncDirtyFromEditor(state, getEditor());
|
||||||
if (state.isDirty) {
|
if (state.isDirty) {
|
||||||
// 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动
|
// 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动
|
||||||
const choice = await showConfirmDialog({
|
const choice = await showConfirmDialog({
|
||||||
title: '新建文件',
|
title: '新建笔记',
|
||||||
message: '当前文件有未保存改动,新建文件将丢弃这些改动。继续?',
|
message: `「${state.currentFile?.name || '当前笔记'}」有未保存改动,新建后会被丢弃。`,
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
buttons: [
|
buttons: [
|
||||||
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||||
{ value: 'confirm', label: '继续新建', variant: 'danger' },
|
{ value: 'confirm', label: '丢弃并新建', variant: 'danger' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (choice !== 'confirm') return;
|
if (choice !== 'confirm') return;
|
||||||
@@ -397,9 +455,11 @@ export function createFileOps(ctx) {
|
|||||||
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
|
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
|
||||||
? state.breadcrumb.map((s) => s.name).join('/')
|
? state.breadcrumb.map((s) => s.name).join('/')
|
||||||
: '';
|
: '';
|
||||||
const message = breadcrumbTail
|
// targetDir 非空 ⇒ 在某个文件夹内新建,提示用该文件夹名而非当前浏览路径
|
||||||
? `新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)\n当前文件夹:${breadcrumbTail}`
|
const targetLabel = targetDir ? basenameOf(targetDir) : breadcrumbTail;
|
||||||
: '新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)';
|
const message = targetLabel
|
||||||
|
? `新笔记的名称(含扩展名,默认 .md)\n当前文件夹:${targetLabel}`
|
||||||
|
: '新笔记的名称(含扩展名,默认 .md)';
|
||||||
const raw = await showPromptDialog({
|
const raw = await showPromptDialog({
|
||||||
title: '新建笔记',
|
title: '新建笔记',
|
||||||
message,
|
message,
|
||||||
@@ -413,8 +473,10 @@ export function createFileOps(ctx) {
|
|||||||
showToast('文件名不能为空', 'warning');
|
showToast('文件名不能为空', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Stage 8:把当前目录绝对路径传给主进程;主进程负责 auto-mkdir(用户主动在该目录下新建)。
|
// 落点:targetDir 优先(右键文件夹内新建),否则当前浏览目录 state.currentDir。
|
||||||
const result = await api.createFile(name, { dir: state.currentDir || '' });
|
// 主进程负责 auto-mkdir(用户主动在该目录下新建)。
|
||||||
|
const dir = targetDir || state.currentDir || '';
|
||||||
|
const result = await api.createFile(name, { dir });
|
||||||
if (!result || !result.ok) {
|
if (!result || !result.ok) {
|
||||||
showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
return;
|
return;
|
||||||
@@ -432,27 +494,325 @@ export function createFileOps(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件列表右键菜单:重命名 / 在文件夹中显示 / 删除
|
* 新建文件夹:弹 prompt 取名 → 走 folder:create。
|
||||||
|
* 设计上**不自动进入**新建的空目录,保持当前视图不变(用户继续浏览原目录列表)。
|
||||||
|
* 自动进入会让上下文跳动,且 fs-watcher 会推「新增一条」让侧栏自动出现新条目。
|
||||||
*
|
*
|
||||||
* Stage 8:folder 条目上隐藏「重命名」—— file:rename 主进程逻辑强制 .md 后缀,
|
* 落点:state.currentDir(当前浏览目录),与 createNewFile 同源语义。
|
||||||
* 对目录并不合适;文件夹的批量整理通过 shell 文件管理器(系统级)操作更直接,
|
* 默认值不带 .md / 任何后缀 —— 文件夹不要扩展名。
|
||||||
* 这里至少保留删除 + 在文件夹中显示。
|
*
|
||||||
|
* @param {string} [targetDir] 可选:在某个文件夹内新建时传入该文件夹绝对路径
|
||||||
|
* (侧栏右键文件夹 → 「在文件夹中新建文件夹」)。省略则落在当前浏览目录。
|
||||||
|
*/
|
||||||
|
async function createNewFolder(targetDir) {
|
||||||
|
syncDirtyFromEditor(state, getEditor());
|
||||||
|
if (state.isDirty) {
|
||||||
|
const choice = await showConfirmDialog({
|
||||||
|
title: '新建文件夹',
|
||||||
|
message: `「${state.currentFile?.name || '当前笔记'}」有未保存改动,新建后会被丢弃。`,
|
||||||
|
icon: 'warning',
|
||||||
|
buttons: [
|
||||||
|
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||||
|
{ value: 'confirm', label: '丢弃并新建', variant: 'danger' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (choice !== 'confirm') return;
|
||||||
|
// 与 createNewFile 同源:在 prompt 阶段保持 dirty 文件不变,成功建完才清。
|
||||||
|
// 若用户取消 prompt,编辑器内容原样保留。
|
||||||
|
}
|
||||||
|
const defaultName = `新建文件夹-${formatStampForName(new Date())}`;
|
||||||
|
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
|
||||||
|
? state.breadcrumb.map((s) => s.name).join('/')
|
||||||
|
: '';
|
||||||
|
// targetDir 非空 ⇒ 在某个文件夹内新建,提示用该文件夹名而非当前浏览路径
|
||||||
|
const targetLabel = targetDir ? basenameOf(targetDir) : breadcrumbTail;
|
||||||
|
const message = targetLabel
|
||||||
|
? `新文件夹的名称(不含扩展名)\n当前文件夹:${targetLabel}`
|
||||||
|
: '新文件夹的名称(不含扩展名)';
|
||||||
|
const raw = await showPromptDialog({
|
||||||
|
title: '新建文件夹',
|
||||||
|
message,
|
||||||
|
defaultValue: defaultName,
|
||||||
|
confirmLabel: '创建',
|
||||||
|
validate: validateFolderName,
|
||||||
|
});
|
||||||
|
if (raw == null) return;
|
||||||
|
const name = raw.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast('文件夹名不能为空', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = targetDir || state.currentDir || '';
|
||||||
|
const result = await api.createFolder(name, { dir });
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
showToast(`新建文件夹失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 推迟清空当前文件 —— 与 createNewFile 同源语义(用户已经同意丢弃 → 现在建成功再清)。
|
||||||
|
if (state.isDirty) clearCurrentFile();
|
||||||
|
showToast(`已创建文件夹:${result.name}`, 'success', 1500);
|
||||||
|
// 不自动进入新文件夹 —— fs-watcher 会把新条目推到侧栏,用户需要时再点。
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件夹:弹 prompt 取新名 → 走 folder:rename。
|
||||||
|
* 若被重命名的是当前目录或其祖先上的某一段,需要 callback 把
|
||||||
|
* state.currentDir / fs-watcher / breadcrumb 同步到新路径。
|
||||||
|
* 决策权交给 caller 的 `wasEntryAffectingCurrentDir` + `navigateToRelDir` 回调。
|
||||||
|
*/
|
||||||
|
async function renameFolder(entry) {
|
||||||
|
if (!entry || !entry.path || entry.entryType !== 'folder') return;
|
||||||
|
syncDirtyFromEditor(state, getEditor()); // 与 file rename 对齐:IME 合成期避免 stale state
|
||||||
|
const raw = await showPromptDialog({
|
||||||
|
title: '重命名文件夹',
|
||||||
|
message: '新文件夹名称(不含扩展名)',
|
||||||
|
defaultValue: entry.name,
|
||||||
|
confirmLabel: '重命名',
|
||||||
|
validate: validateFolderName,
|
||||||
|
});
|
||||||
|
if (raw == null) return;
|
||||||
|
const newName = raw.trim();
|
||||||
|
if (!newName) {
|
||||||
|
showToast('文件夹名不能为空', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName === entry.name) return;
|
||||||
|
const result = await api.renameFolder(entry.path, newName);
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
showToast(`重命名失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(`已重命名为:${result.name}`, 'success', 1500);
|
||||||
|
|
||||||
|
// 重命名影响当前路径:把 state.currentDir / currentRelDir / fs-watcher 同步过去
|
||||||
|
const { wasEntryAffectingCurrentDir, computeCurrentRelAfterRename, navigateToRelDir } = callbacks;
|
||||||
|
if (typeof wasEntryAffectingCurrentDir === 'function'
|
||||||
|
&& typeof computeCurrentRelAfterRename === 'function'
|
||||||
|
&& typeof navigateToRelDir === 'function') {
|
||||||
|
if (wasEntryAffectingCurrentDir(entry.path)) {
|
||||||
|
const newRel = computeCurrentRelAfterRename(entry.path, entry.name, result.path, result.name);
|
||||||
|
if (newRel != null) {
|
||||||
|
try {
|
||||||
|
await navigateToRelDir(newRel);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[file-ops] rename 后续 navigateToRelDir 失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件夹:弹 confirm → 走 file:delete(主进程已支持 folder 分支)。
|
||||||
|
* 用户反馈设计:"删除成功后将视图导到父级" —— 不因文件夹被删而失位。
|
||||||
|
*
|
||||||
|
* 复用 file:delete IPC 的 isFolder 分支:
|
||||||
|
* - 主进程调用 shell.trashItem(macOS / Windows 把整树挪到回收站)
|
||||||
|
* - 失败兜底 fs.rmdir(仅对空目录)
|
||||||
|
* - 返回 result.isFolder(本函数用不上 —— 不需要判断 file vs folder,调用方已知是 folder)
|
||||||
|
*
|
||||||
|
* 文件级 dirty 内容由 onFilesChanged 后续触发 rescue —— shell.trashItem 异步移走之后
|
||||||
|
* fs-watcher 会推「文件已不在」,state.currentFile 被清同时 dirty 内容被 rescue。
|
||||||
|
*/
|
||||||
|
async function deleteFolder(entry) {
|
||||||
|
if (!entry || !entry.path || entry.entryType !== 'folder') return;
|
||||||
|
syncDirtyFromEditor(state, getEditor());
|
||||||
|
const choice = await showConfirmDialog({
|
||||||
|
title: '删除文件夹',
|
||||||
|
message: `「${entry.name}」将被移至回收站(含其全部内容)。\n\n若其中有正在编辑的笔记,未保存内容会另存为「<基名>-rescued-<时间戳>.md」。`,
|
||||||
|
icon: 'danger',
|
||||||
|
buttons: [
|
||||||
|
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||||
|
{ value: 'confirm', label: '删除', variant: 'danger' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (choice !== 'confirm') return;
|
||||||
|
|
||||||
|
// 记下删除前目录影响 —— 用 callback 判断被删的是否影响当前路径
|
||||||
|
const { wasEntryAffectingCurrentDir, parentRelForRemovedEntry, navigateToRelDir } = callbacks;
|
||||||
|
const affectCurrent = typeof wasEntryAffectingCurrentDir === 'function'
|
||||||
|
&& wasEntryAffectingCurrentDir(entry.path);
|
||||||
|
|
||||||
|
const result = await api.deleteFile(entry.path);
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
if (result && result.error === 'FILE_NOT_FOUND') {
|
||||||
|
showToast('文件夹已不存在', 'warning');
|
||||||
|
} else {
|
||||||
|
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(`已移至回收站:${entry.name}`, 'success', 1500);
|
||||||
|
|
||||||
|
// 被删的是当前目录或其祖先 → 导航到被删 entry 的父级
|
||||||
|
if (affectCurrent && typeof navigateToRelDir === 'function') {
|
||||||
|
const parentRel = typeof parentRelForRemovedEntry === 'function'
|
||||||
|
? parentRelForRemovedEntry(entry.path, entry.name)
|
||||||
|
: '';
|
||||||
|
try {
|
||||||
|
await navigateToRelDir(parentRel);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[file-ops] deleteFolder 后续 navigateToRelDir 失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把条目(文件 / 文件夹)移动到目标目录。
|
||||||
|
* 拖拽到文件夹、右键「剪切 → 粘贴」都走这里。
|
||||||
|
*
|
||||||
|
* 与 rename 的差异:move 不改扩展名、不动编辑器内容(只是换目录),
|
||||||
|
* 所以**不需要** syncDirtyFromEditor 的脏确认(用户当前的编辑内容仍留在编辑器里)。
|
||||||
|
*
|
||||||
|
* 若被移动的条目是当前打开的文件(或它的某个祖先文件夹里包含打开的文件):
|
||||||
|
* 更新 state.currentFile 的 path/name(mirror renameCurrentOrFile 的同步分支),
|
||||||
|
* 让侧栏高亮 / 状态栏 / 图片相对路径都跟过去。
|
||||||
|
*
|
||||||
|
* @param {{path:string,name:string,entryType?:string}} entry
|
||||||
|
* @param {string} destDir 目标目录绝对路径
|
||||||
|
*/
|
||||||
|
async function moveEntry(entry, destDir) {
|
||||||
|
if (!entry || !entry.path || !destDir) return null;
|
||||||
|
const result = await api.moveFile(entry.path, destDir);
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
showToast(`移动失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
|
return result || null;
|
||||||
|
}
|
||||||
|
showToast(`已移动到:${result.name}`, 'success', 1500);
|
||||||
|
// 移动涉及当前打开文件 → 同步路径 / 状态栏 / viewer 相对图解析
|
||||||
|
// 覆盖两种情况:
|
||||||
|
// 1) 移动的就是打开的文件本身(state.currentFile.path === entry.path)
|
||||||
|
// 2) 移动的是打开文件的祖先文件夹(state.currentFile.path 在 entry.path 之下)——
|
||||||
|
// audit 修复:之前只覆盖情况 1,导致情况 2 下 state.currentFileRelDir /
|
||||||
|
// currentFile.path 仍指向旧位置,onFilesChanged 把"旧目录的移除事件"
|
||||||
|
// 误判为"当前文件被删除",触发 rescue + 写一个孤儿 .md 到根目录。
|
||||||
|
if (state.currentFile && state.currentFile.path) {
|
||||||
|
const cfPath = state.currentFile.path;
|
||||||
|
if (cfPath === entry.path) {
|
||||||
|
_applyMoveToOpenFile(result);
|
||||||
|
} else if (result.entryType === 'folder' && cfPath.startsWith(entry.path)) {
|
||||||
|
// 祖先文件夹被移走 → 重新拼出文件新路径
|
||||||
|
const newFilePath = destDir + cfPath.slice(entry.path.length);
|
||||||
|
const newName = newFilePath.replace(/^.*[\\/]/, '');
|
||||||
|
_applyMoveToOpenFile({
|
||||||
|
path: newFilePath,
|
||||||
|
name: newName,
|
||||||
|
mtimeMs: state.currentFile.mtimeMs,
|
||||||
|
size: state.currentFile.size,
|
||||||
|
// destDir 的 relDir 与 result.relDir 一致(祖先移动后,新父 = destDir)
|
||||||
|
relDir: typeof result.relDir === 'string' ? result.relDir : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把条目(文件 / 文件夹)复制到目标目录。
|
||||||
|
* 拖拽(按住 Ctrl/Cmd)/ 右键「复制 → 粘贴」走这里。
|
||||||
|
* 复制不影响当前打开的文件(源不变),无需同步 state.currentFile。
|
||||||
|
*
|
||||||
|
* @param {{path:string,name:string,entryType:string}} entry
|
||||||
|
* @param {string} destDir 目标目录绝对路径
|
||||||
|
*/
|
||||||
|
async function copyEntry(entry, destDir) {
|
||||||
|
if (!entry || !entry.path || !destDir) return null;
|
||||||
|
const result = await api.copyFile(entry.path, destDir);
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
showToast(`复制失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||||
|
return result || null;
|
||||||
|
}
|
||||||
|
showToast(`已复制到:${result.name}`, 'success', 1500);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被「移动」的当前打开文件:把磁盘新路径同步到 state / viewer / 侧栏高亮 / AI。
|
||||||
|
* move 不改变扩展名,所以不触发 内容重渲染(与 rename 带扩展名切换的路径不同)。
|
||||||
|
* 逻辑收口自 renameCurrentOrFile 的同步分支。
|
||||||
|
*/
|
||||||
|
function _applyMoveToOpenFile(result) {
|
||||||
|
// result 由 moveEntry 在 IPC 成功后传入,永不为 null(moveEntry 已有 !result.ok 守卫)。
|
||||||
|
const newMeta = {
|
||||||
|
path: result.path,
|
||||||
|
name: result.name,
|
||||||
|
mtimeMs: result.mtimeMs,
|
||||||
|
size: result.size,
|
||||||
|
};
|
||||||
|
state.currentFile = { ...state.currentFile, ...newMeta };
|
||||||
|
state.lastSavedMtimeMs = result.mtimeMs;
|
||||||
|
// 移动后文件所在目录变了:同步 currentFileRelDir(来自主进程 file:move 返回的
|
||||||
|
// POSIX relDir),否则 onFilesChanged 仍用旧目录比对,会把"旧目录的移除事件"
|
||||||
|
// 误判为"当前文件被删除"并清空编辑器(见 app.js onFilesChanged 的 payloadRel 比对)。
|
||||||
|
if (typeof result.relDir === 'string') {
|
||||||
|
state.currentFileRelDir = result.relDir;
|
||||||
|
}
|
||||||
|
if (fileList && typeof fileList.setActive === 'function') {
|
||||||
|
try { fileList.setActive(result.path); } catch { /* 不让 setActive 异常阻断流程 */ }
|
||||||
|
}
|
||||||
|
// viewer 用 currentFile.path 当相对图片 baseDir,路径变了必须重跑
|
||||||
|
viewer.setFileMeta(newMeta);
|
||||||
|
updateWindowTitle();
|
||||||
|
// 路径变化 → AI pendingDiff 的 baseContent / forFilePath 脱节 → 通知清盘
|
||||||
|
if (typeof callbacks.onFileChanged === 'function') {
|
||||||
|
try { callbacks.onFileChanged(state.currentFile); } catch (e) {
|
||||||
|
console.warn('[file-ops] move 通知 AI 失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件列表右键菜单:
|
||||||
|
* 文件条目:剪切 / 复制 / 粘贴 / 重命名 / 在文件夹中显示 / 删除
|
||||||
|
* 文件夹条目:在文件夹中新建文件 / 在文件夹中新建文件夹 / (以上 + 文件条目全套)
|
||||||
|
*
|
||||||
|
* Stage 8:folder 条目也解锁「重命名」。
|
||||||
|
* 文件夹条目顶部多一组「在该文件夹内新建」,落点指向被右击的文件夹
|
||||||
|
* (右键文件夹 → 「在文件夹中新建文件」直接建进它里面);
|
||||||
|
* 侧栏空白右键的「新建」则落在当前浏览目录(见 src/app.js#showSidebarEmptyContextMenu)。
|
||||||
|
* 剪切 / 复制把条目记到 clipboard(state.clipboard,由上层回调维护);
|
||||||
|
* 粘贴仅在 clipboard 非空时可用(当前浏览目录为目标)。
|
||||||
*/
|
*/
|
||||||
function showFileListContextMenu(entry, x, y) {
|
function showFileListContextMenu(entry, x, y) {
|
||||||
const isFolder = entry && entry.entryType === 'folder';
|
const isFolder = entry && entry.entryType === 'folder';
|
||||||
const items = [];
|
const items = [];
|
||||||
if (!isFolder) {
|
// 文件夹条目:顶部加「在该文件夹内新建」一组,落点指向被右击的文件夹
|
||||||
items.push({ label: '重命名', value: 'rename' });
|
// (与侧栏空白右键「新建」落当前目录相对,这里是「建进这个文件夹」)。
|
||||||
|
if (isFolder) {
|
||||||
|
items.push({ label: '在文件夹中新建文件', value: 'new_file_in' });
|
||||||
|
items.push({ label: '在文件夹中新建文件夹', value: 'new_folder_in' });
|
||||||
|
items.push({ separator: true, value: '' });
|
||||||
}
|
}
|
||||||
|
items.push({ label: '剪切', value: 'cut' });
|
||||||
|
items.push({ label: '复制', value: 'copy' });
|
||||||
|
items.push({ label: '粘贴', value: 'paste', disabled: !state.clipboard });
|
||||||
|
items.push({ separator: true, value: '' });
|
||||||
|
items.push({ label: '重命名', value: 'rename' });
|
||||||
items.push({ label: '在文件夹中显示', value: 'reveal' });
|
items.push({ label: '在文件夹中显示', value: 'reveal' });
|
||||||
items.push({ separator: true, value: '' });
|
items.push({ separator: true, value: '' });
|
||||||
items.push({ label: '删除', value: 'delete' });
|
items.push({ label: '删除', value: 'delete' });
|
||||||
contextMenu.show({
|
contextMenu.show({
|
||||||
x, y, items,
|
x, y, items,
|
||||||
onSelect: (value) => {
|
onSelect: (value) => {
|
||||||
if (value === 'rename') renameCurrentOrFile(entry);
|
if (value === 'new_file_in') {
|
||||||
else if (value === 'reveal') revealFile(entry);
|
createNewFile(entry.path);
|
||||||
else if (value === 'delete') deleteFile(entry);
|
} else if (value === 'new_folder_in') {
|
||||||
|
createNewFolder(entry.path);
|
||||||
|
} else if (value === 'cut') {
|
||||||
|
if (typeof callbacks.onCut === 'function') callbacks.onCut(entry);
|
||||||
|
} else if (value === 'copy') {
|
||||||
|
if (typeof callbacks.onCopy === 'function') callbacks.onCopy(entry);
|
||||||
|
} else if (value === 'paste') {
|
||||||
|
if (typeof callbacks.onPaste === 'function') callbacks.onPaste();
|
||||||
|
} else if (value === 'rename') {
|
||||||
|
if (isFolder) renameFolder(entry);
|
||||||
|
else renameCurrentOrFile(entry);
|
||||||
|
} else if (value === 'reveal') {
|
||||||
|
revealFile(entry);
|
||||||
|
} else if (value === 'delete') {
|
||||||
|
if (isFolder) deleteFolder(entry);
|
||||||
|
else deleteFile(entry);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -460,9 +820,14 @@ export function createFileOps(ctx) {
|
|||||||
return {
|
return {
|
||||||
showFileListContextMenu,
|
showFileListContextMenu,
|
||||||
createNewFile,
|
createNewFile,
|
||||||
|
createNewFolder,
|
||||||
|
moveEntry,
|
||||||
|
copyEntry,
|
||||||
// 暴露给测试 / 调试用
|
// 暴露给测试 / 调试用
|
||||||
renameCurrentOrFile,
|
renameCurrentOrFile,
|
||||||
|
renameFolder,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
|
deleteFolder,
|
||||||
revealFile,
|
revealFile,
|
||||||
formatStampForName,
|
formatStampForName,
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/fs-handler-lock.js
Normal file
63
src/fs-handler-lock.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// src/app.js onFilesChanged —— handler 并发闸(audit P1-4)
|
||||||
|
//
|
||||||
|
// 背景:原代码
|
||||||
|
// const myToken = ++fsHandlerToken;
|
||||||
|
// if (inFlightToken !== 0) { return; }
|
||||||
|
// inFlightToken = myToken;
|
||||||
|
// 自增和 SET 之间是 microtask 窗口:burst 50 events 时多个 callback 排在同一
|
||||||
|
// microtask queue,每个都看到 inFlightToken === 0,全部过闸 → handler
|
||||||
|
// 并发跑(applyEntries 双跑、scanDir 双发、UI 闪烁)。
|
||||||
|
//
|
||||||
|
// 修复:makeLock() 工厂 → { tryClaim, release, current }。tryClaim 是同步
|
||||||
|
// check-and-set 原子操作:锁空闲时 SET 并返新 token(非零),否则返 null。
|
||||||
|
// callback 入口 tryClaim → null 直接 return(pendingRescanRel 由调用方决定
|
||||||
|
// 是否记下)。
|
||||||
|
//
|
||||||
|
// 计数器:每次成功 tryClaim 自增,token 永不复用 —— 旧 handler 的 finally
|
||||||
|
// 即使「迟到的 release」也不会误释新 handler 的锁(release 检查 token 匹配)。
|
||||||
|
//
|
||||||
|
// ESM 导出(src/ 已是 ESM,跨 test 直接 import)。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个并发闸对象。
|
||||||
|
* @returns {{
|
||||||
|
* tryClaim: () => (number | null),
|
||||||
|
* release: (token: number) => boolean,
|
||||||
|
* current: () => number,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function makeLock() {
|
||||||
|
let inFlightToken = 0;
|
||||||
|
let nextToken = 0;
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* 尝试占锁。锁空闲 → SET + 返新 token;锁被占 → 返 null。
|
||||||
|
* 同步原子,不留 microtask 窗口。
|
||||||
|
* @returns {number | null} 新 token(非零)或 null
|
||||||
|
*/
|
||||||
|
tryClaim() {
|
||||||
|
if (inFlightToken !== 0) return null;
|
||||||
|
nextToken += 1;
|
||||||
|
inFlightToken = nextToken;
|
||||||
|
return inFlightToken;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 释放锁。token 与当前锁不匹配 → 返 false,锁不变(防止旧 handler 误释
|
||||||
|
* 新 handler 的锁,例如「finally 迟到」场景)。
|
||||||
|
* @param {number} token tryClaim 返回的 token
|
||||||
|
* @returns {boolean} true = 已释放,false = token 不匹配
|
||||||
|
*/
|
||||||
|
release(token) {
|
||||||
|
if (inFlightToken !== token) return false;
|
||||||
|
inFlightToken = 0;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 当前锁的 token。0 = 空闲。
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
current() {
|
||||||
|
return inFlightToken;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { EditorState, Prec, Compartment } from '@codemirror/state';
|
import { EditorState, Prec, Compartment } from '@codemirror/state';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
import { basicSetup } from 'codemirror';
|
import { basicSetup } from 'codemirror';
|
||||||
|
import { history } from '@codemirror/commands';
|
||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { getTextStats } from './stats.js';
|
import { getTextStats } from './stats.js';
|
||||||
import { pickEditorTheme } from './editor-theme.js';
|
import { pickEditorTheme } from './editor-theme.js';
|
||||||
@@ -103,7 +104,16 @@ export class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载文件内容到编辑器。多次调用会销毁旧 view。
|
* 加载文件内容到编辑器。多次调用复用同一 view(不再 _destroyView + new)。
|
||||||
|
*
|
||||||
|
* audit fix (R7b P1-18):单实例 `EditorView` 持久化。
|
||||||
|
* - 旧实现每次 loadFile 都 _destroyView() + new EditorView() → history /
|
||||||
|
* selection / 折叠全丢 + 30-100ms 黑闪 + 重新挂 N 个 IME listener
|
||||||
|
* (极端时序 compositionend 跑多次 → state.isDirty 翻脏态反复)。
|
||||||
|
* - 新实现:view 已存在 → dispatch 替换 doc + selection reset 到开头
|
||||||
|
* (用户「打开文件」是导航动作,光标落文首是符合直觉的默认);
|
||||||
|
* view 不存在 → 走 _createView 一次性建好(首次 loadFile 触发)。
|
||||||
|
*
|
||||||
* @param {{path:string,name:string}} file
|
* @param {{path:string,name:string}} file
|
||||||
* @param {string} content
|
* @param {string} content
|
||||||
*/
|
*/
|
||||||
@@ -114,27 +124,56 @@ export class MarkdownEditor {
|
|||||||
if (!this.element || typeof this.element.appendChild !== 'function') {
|
if (!this.element || typeof this.element.appendChild !== 'function') {
|
||||||
throw new Error('MarkdownEditor: element required (must be a DOM node)');
|
throw new Error('MarkdownEditor: element required (must be a DOM node)');
|
||||||
}
|
}
|
||||||
this._destroyView();
|
|
||||||
|
|
||||||
// Round 13 fix (H8):clearListeners 用新 AbortController 包住本轮 view 的
|
|
||||||
// listener 与 IME handler。下次 loadFile() / dispose() 时清掉旧 listener。
|
|
||||||
// 旧实现每次 loadFile 都给 contentDOM addEventListener,但只 dispose 时
|
|
||||||
// 才走 addAbort 的 remove —— 多次开文件会让 DOM 上累积多份 IME handler,
|
|
||||||
// 极端时序(compositionend 在 view 已 destroy 之后才 fire)会跑多次
|
|
||||||
// onChangeCallback,让 app 翻脏态 + 调度多余的 auto-save。
|
|
||||||
this._viewAbort = new AbortController();
|
|
||||||
// 把 contentDOM 上的 listener 挂到这个 signal,loadFile 重入 / dispose
|
|
||||||
// 都一次性 abort。CM6 自己的 listener 用 contentDOM 默认挂(不在 signal
|
|
||||||
// 上),由 _destroyView / view.destroy 拆解。
|
|
||||||
const contentSignal = this._viewAbort.signal;
|
|
||||||
|
|
||||||
this.lastSavedDoc = content || '';
|
this.lastSavedDoc = content || '';
|
||||||
|
const insert = content || '';
|
||||||
|
|
||||||
|
if (this.view) {
|
||||||
|
// view 已存在 → 复用。dispatch 替换 doc,selection 故意重置到 (0,0)
|
||||||
|
// (用户主动打开文件 = 导航,光标落文首是合理默认;与 setExternalContent
|
||||||
|
// 的「保留光标位置」差异是设计意图:setExternalContent 是外部覆盖,
|
||||||
|
// 不该打断用户当前阅读位置;loadFile 是用户主动切文件,重置是合理的)。
|
||||||
|
this.view.dispatch({
|
||||||
|
changes: { from: 0, to: this.view.state.doc.length, insert },
|
||||||
|
selection: { anchor: 0, head: 0 },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次:建 view(之后不再重建,直到 dispose / unload)
|
||||||
|
this._createView(insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次创建 EditorView + IME listener。view 整个 lifetime 只调一次。
|
||||||
|
* loadFile 在 view 已存在时不会重入这里。
|
||||||
|
*
|
||||||
|
* @param {string} initialContent - 初始 doc 内容
|
||||||
|
*/
|
||||||
|
_createView(initialContent) {
|
||||||
|
// Round 13 fix (H8):clearListeners 用新 AbortController 包住本轮 view 的
|
||||||
|
// listener 与 IME handler。下次 dispose() 时清掉旧 listener。view 在
|
||||||
|
// MarkdownEditor 生命周期内只建一次,所以这个 signal 也只创一次。
|
||||||
|
this._viewAbort = new AbortController();
|
||||||
|
// 把 contentDOM 上的 listener 挂到这个 signal,dispose 时整体 abort。
|
||||||
|
// CM6 自己的 listener 用 contentDOM 默认挂(不在 signal 上),由 view.destroy
|
||||||
|
// 拆解。
|
||||||
|
const contentSignal = this._viewAbort.signal;
|
||||||
|
|
||||||
const state = EditorState.create({
|
const state = EditorState.create({
|
||||||
doc: this.lastSavedDoc,
|
doc: initialContent,
|
||||||
extensions: [
|
extensions: [
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
basicSetup,
|
basicSetup,
|
||||||
|
// audit P0-3(C-1):basicSetup 默认带 history() 但 history 大小无显式
|
||||||
|
// 上限(默认 minDepth=100,updateBranch 实际有 +20 buffer,所以 120)。
|
||||||
|
// 打开 5MB 文件 + 长时间编辑 → history 数组几十~几百 MB,OOM 风险。
|
||||||
|
// 覆盖配置:minDepth=500 限制 history 长度(老 step 被丢弃,updateBranch
|
||||||
|
// 内部 +20 buffer,所以 520 上限),newGroupDelay=200ms 把短时间内的连续
|
||||||
|
// keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千条 history)。
|
||||||
|
// 注:CM6 API 用 minDepth(虽然语义上是 max),不是 audit 文档误写的
|
||||||
|
// maxDepth;参数名以 @codemirror/commands 的 HistoryConfig 为准。
|
||||||
|
Prec.high(history({ minDepth: 500, newGroupDelay: 200 })),
|
||||||
markdown(),
|
markdown(),
|
||||||
this.themeCompartment.of(pickEditorTheme(this.currentTheme)),
|
this.themeCompartment.of(pickEditorTheme(this.currentTheme)),
|
||||||
this.editableCompartment.of(EditorView.editable.of(true)),
|
this.editableCompartment.of(EditorView.editable.of(true)),
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ function cssAttrEscape(value) {
|
|||||||
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态栏路径在「无文件 / 无目录信息」时的占位文字。
|
||||||
|
* 提取常量是为了 clear() / refreshCurrentDir() 里的 aria-label 判定
|
||||||
|
* 不再硬编码 '—' 字面量,将来换占位也不会静默错分支。
|
||||||
|
*/
|
||||||
|
const STATUS_PATH_PLACEHOLDER = '—';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染 markdown 字符串为安全的 HTML
|
* 渲染 markdown 字符串为安全的 HTML
|
||||||
* - 委托给 utils.safeRenderMarkdown:marked + DOMPurify 实际初始化在 preload.js 里完成
|
* - 委托给 utils.safeRenderMarkdown:marked + DOMPurify 实际初始化在 preload.js 里完成
|
||||||
@@ -27,6 +34,13 @@ function renderMarkdown(markdown) {
|
|||||||
return safeRenderMarkdown(markdown);
|
return safeRenderMarkdown(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 代码块复制按钮图标(lucide copy) */
|
||||||
|
const ICON_COPY = '<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="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
||||||
|
/** 复制成功对勾(lucide check) */
|
||||||
|
const ICON_CHECK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||||
|
/** lightbox 关闭(×) */
|
||||||
|
const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
|
||||||
|
|
||||||
export class MarkdownViewer {
|
export class MarkdownViewer {
|
||||||
/**
|
/**
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
@@ -60,8 +74,12 @@ export class MarkdownViewer {
|
|||||||
// 由 _rebuildHeadingObserver 同步构建;状态栏 heading chip 用它把
|
// 由 _rebuildHeadingObserver 同步构建;状态栏 heading chip 用它把
|
||||||
// currentHeadingId 还原成可读文本。
|
// currentHeadingId 还原成可读文本。
|
||||||
this._headingMap = new Map();
|
this._headingMap = new Map();
|
||||||
// IntersectionObserver:跟踪"当前在视口顶部的 heading"
|
// IntersectionObserver:跟踪"当前在视口顶部的 heading"。
|
||||||
|
// P2-16:实例持久化(只建一次);_headingNodes / _headingVisibleIds 是
|
||||||
|
// 它的回调读取的实例状态,每次 _rebuildHeadingObserver 全量替换。
|
||||||
this._headingObserver = null;
|
this._headingObserver = null;
|
||||||
|
this._headingNodes = [];
|
||||||
|
this._headingVisibleIds = null;
|
||||||
// 每个打开过的文件保留一份滚动位置。
|
// 每个打开过的文件保留一份滚动位置。
|
||||||
// 上限 SCROLL_POSITION_CACHE_LIMIT = 200:超出后按插入顺序淘汰最旧未访问的条目,
|
// 上限 SCROLL_POSITION_CACHE_LIMIT = 200:超出后按插入顺序淘汰最旧未访问的条目,
|
||||||
// 防止打开几千个文件后 Map 无限增长(每个 entry ≈ 8 字节 key + 8 字节 number)。
|
// 防止打开几千个文件后 Map 无限增长(每个 entry ≈ 8 字节 key + 8 字节 number)。
|
||||||
@@ -75,6 +93,13 @@ export class MarkdownViewer {
|
|||||||
// 关键 await 之后再比一次 —— 不等就 return,避免 A 的解析污染 B 的图片。
|
// 关键 await 之后再比一次 —— 不等就 return,避免 A 的解析污染 B 的图片。
|
||||||
this._resolveGen = 0;
|
this._resolveGen = 0;
|
||||||
|
|
||||||
|
// audit R7b P1-16:loadContentLive 的 rAF chunked insert 单飞行控制。
|
||||||
|
// 新一次 loadContentLive / loadContent / clear / showUnsupportedFile / dispose
|
||||||
|
// 都必须 abort 上一次还没跑完的分批插入,否则旧 chunk 会 append 进新 DOM。
|
||||||
|
this._liveInsertAbort = null;
|
||||||
|
// 测试钩子用:_applyHtmlLive 返回的 promise(已 .catch 兜底,不会 reject)。
|
||||||
|
this._liveInsertPromise = null;
|
||||||
|
|
||||||
// 缓存空态元素初始 innerHTML —— showUnsupportedFile 会临时改写它,
|
// 缓存空态元素初始 innerHTML —— showUnsupportedFile 会临时改写它,
|
||||||
// 下一次 loadContent/clear 再还原回 index.html 里的默认文案(避免
|
// 下一次 loadContent/clear 再还原回 index.html 里的默认文案(避免
|
||||||
// 「上次打开 .txt 残留的'不支持预览'文字」污染下次空态)。
|
// 「上次打开 .txt 残留的'不支持预览'文字」污染下次空态)。
|
||||||
@@ -89,6 +114,9 @@ export class MarkdownViewer {
|
|||||||
// 替换后旧回调还能触发,让新组件的 state 被污染。abort 时一次性清空。
|
// 替换后旧回调还能触发,让新组件的 state 被污染。abort 时一次性清空。
|
||||||
this.addAbort(() => {
|
this.addAbort(() => {
|
||||||
this.onHeadingChangeCallbacks.clear();
|
this.onHeadingChangeCallbacks.clear();
|
||||||
|
// audit R7b P1-16:dispose 时取消还没跑完的 chunked insert,
|
||||||
|
// 避免后续 rAF 帧继续往已废弃的 bodyElement 里 append。
|
||||||
|
this._cancelLiveInsert();
|
||||||
// audit fix (Round 12 P3):_headingObserver 是 IntersectionObserver,
|
// audit fix (Round 12 P3):_headingObserver 是 IntersectionObserver,
|
||||||
// _rebuildHeadingObserver / clear / showUnsupportedFile 三处各自
|
// _rebuildHeadingObserver / clear / showUnsupportedFile 三处各自
|
||||||
// disconnect,但 dispose 路径没有兜底。detach 后的 heading 节点
|
// disconnect,但 dispose 路径没有兜底。detach 后的 heading 节点
|
||||||
@@ -98,6 +126,20 @@ export class MarkdownViewer {
|
|||||||
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
|
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
|
||||||
this._headingObserver = null;
|
this._headingObserver = null;
|
||||||
}
|
}
|
||||||
|
// P2-16:持久化 observer 的实例状态也一并释放(节点数组 / 可见集),
|
||||||
|
// 防止 dispose 后仍持有 detached heading 节点引用。
|
||||||
|
this._headingNodes = [];
|
||||||
|
this._headingVisibleIds = null;
|
||||||
|
// 清理交互增强产生的全局浮层(FAB / lightbox),避免 dispose 后残留
|
||||||
|
if (this._copyFab && this._copyFab.parentNode) {
|
||||||
|
this._copyFab.parentNode.removeChild(this._copyFab);
|
||||||
|
this._copyFab = null;
|
||||||
|
}
|
||||||
|
window.clearTimeout(this._copyFabReset);
|
||||||
|
if (this._lightboxOverlay && this._lightboxOverlay.parentNode) {
|
||||||
|
this._lightboxOverlay.parentNode.removeChild(this._lightboxOverlay);
|
||||||
|
this._lightboxOverlay = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拦截链接点击:所有 .markdown-body 内的 <a> 都通过 onExternalLink 走系统默认浏览器
|
// 拦截链接点击:所有 .markdown-body 内的 <a> 都通过 onExternalLink 走系统默认浏览器
|
||||||
@@ -137,6 +179,10 @@ export class MarkdownViewer {
|
|||||||
}
|
}
|
||||||
}, { signal: this.signal });
|
}, { signal: this.signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 阅读区交互增强:代码块复制 + 图片放大。刻意不改动 <pre>/<img> 的 DOM,
|
||||||
|
// 以免破坏 loadContentLive 的 isEqualNode 前缀/后缀 diff(见 [[feedback-live-diff-safe]])。
|
||||||
|
this._setupContentInteractions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,6 +214,10 @@ export class MarkdownViewer {
|
|||||||
async loadContent(file, content) {
|
async loadContent(file, content) {
|
||||||
if (!file || !this.bodyElement) return;
|
if (!file || !this.bodyElement) return;
|
||||||
|
|
||||||
|
// audit R7b P1-16:切文件必须先取消还在跑的 live chunked insert,
|
||||||
|
// 否则旧文件的剩余 chunk 会 append 进新文件的 DOM。
|
||||||
|
this._cancelLiveInsert();
|
||||||
|
|
||||||
// 恢复空态为 index.html 里的默认文案(showUnsupportedFile 可能改写过了)
|
// 恢复空态为 index.html 里的默认文案(showUnsupportedFile 可能改写过了)
|
||||||
this._resetEmptyState();
|
this._resetEmptyState();
|
||||||
|
|
||||||
@@ -257,6 +307,8 @@ export class MarkdownViewer {
|
|||||||
*/
|
*/
|
||||||
showUnsupportedFile(file) {
|
showUnsupportedFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
// audit R7b P1-16:清空正文前取消 in-flight live chunked insert
|
||||||
|
this._cancelLiveInsert();
|
||||||
this.currentFile = file;
|
this.currentFile = file;
|
||||||
this.currentContent = '';
|
this.currentContent = '';
|
||||||
this.currentHeadingId = null;
|
this.currentHeadingId = null;
|
||||||
@@ -318,8 +370,13 @@ export class MarkdownViewer {
|
|||||||
* `() => window.api.getDataDir()`),取不到时退化为「只挡 ../.. 越界字符串」的旧行为。
|
* `() => window.api.getDataDir()`),取不到时退化为「只挡 ../.. 越界字符串」的旧行为。
|
||||||
*
|
*
|
||||||
* @param {string} filePath - 当前 markdown 文件的绝对路径
|
* @param {string} filePath - 当前 markdown 文件的绝对路径
|
||||||
|
* @param {Node[]} [roots] - audit P2-14:只扫这些顶级块内的 img(live diff
|
||||||
|
* 路径传入本次新插入的块)。未变更块的 img 上一轮已解析成 file://
|
||||||
|
* (绝对 src 在循环里本来就会跳过),scope 后连 querySelectorAll +
|
||||||
|
* getAttribute 的 O(N) 遍历都省掉。不传 → 全 body 扫描(loadContent /
|
||||||
|
* setFileMeta 路径维持原语义)。传 [] → 零变更,直接不扫。
|
||||||
*/
|
*/
|
||||||
async resolveRelativeImages(filePath) {
|
async resolveRelativeImages(filePath, roots) {
|
||||||
if (!this.bodyElement || !filePath) return;
|
if (!this.bodyElement || !filePath) return;
|
||||||
// audit fix (perf-MEDIUM):A→B 快速切文件时,旧 loadContent 启动的
|
// audit fix (perf-MEDIUM):A→B 快速切文件时,旧 loadContent 启动的
|
||||||
// resolveRelativeImages(A) 是 fire-and-forget;await getDataDir 期间
|
// resolveRelativeImages(A) 是 fire-and-forget;await getDataDir 期间
|
||||||
@@ -352,7 +409,21 @@ export class MarkdownViewer {
|
|||||||
// await 之后再判一次 —— 期间可能被新 loadContent / loadContentLive 接管
|
// await 之后再判一次 —— 期间可能被新 loadContent / loadContentLive 接管
|
||||||
if (myGen !== this._resolveGen) return;
|
if (myGen !== this._resolveGen) return;
|
||||||
|
|
||||||
for (const img of this.bodyElement.querySelectorAll('img[src]')) {
|
// audit P2-14:roots scope。块自身是 <img>(顶级即图片)或块内嵌套
|
||||||
|
// <img>(<p><img></p> 等)两种情况都要覆盖。
|
||||||
|
let imgs;
|
||||||
|
if (Array.isArray(roots)) {
|
||||||
|
imgs = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
if (!root || root.nodeType !== 1) continue;
|
||||||
|
if (root.tagName === 'IMG' && root.hasAttribute('src')) imgs.push(root);
|
||||||
|
for (const img of root.querySelectorAll('img[src]')) imgs.push(img);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imgs = this.bodyElement.querySelectorAll('img[src]');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const img of imgs) {
|
||||||
const src = img.getAttribute('src');
|
const src = img.getAttribute('src');
|
||||||
if (!src) continue;
|
if (!src) continue;
|
||||||
// 带协议(含 data:/file:/http:)或以 / 开头的绝对路径 → 原样保留
|
// 带协议(含 data:/file:/http:)或以 / 开头的绝对路径 → 原样保留
|
||||||
@@ -433,6 +504,8 @@ export class MarkdownViewer {
|
|||||||
clear() {
|
clear() {
|
||||||
// 恢复空态默认文案(showUnsupportedFile 可能改写过了)
|
// 恢复空态默认文案(showUnsupportedFile 可能改写过了)
|
||||||
this._resetEmptyState();
|
this._resetEmptyState();
|
||||||
|
// audit R7b P1-16:清空正文前取消 in-flight live chunked insert
|
||||||
|
this._cancelLiveInsert();
|
||||||
|
|
||||||
if (this.bodyElement) {
|
if (this.bodyElement) {
|
||||||
this.bodyElement.innerHTML = '';
|
this.bodyElement.innerHTML = '';
|
||||||
@@ -449,7 +522,14 @@ export class MarkdownViewer {
|
|||||||
}
|
}
|
||||||
// 只删当前文件的滚动位置 —— loadContent 已经在切换前把 top 存进 scrollPositions,
|
// 只删当前文件的滚动位置 —— loadContent 已经在切换前把 top 存进 scrollPositions,
|
||||||
// 再清一次反而覆盖。clear 的语义就是「不再有 currentFile」,所以 entry 留着无害。
|
// 再清一次反而覆盖。clear 的语义就是「不再有 currentFile」,所以 entry 留着无害。
|
||||||
if (this.statusPath) this.statusPath.textContent = this._folderLabel();
|
if (this.statusPath) {
|
||||||
|
const label = this._folderLabel();
|
||||||
|
this.statusPath.textContent = label;
|
||||||
|
// audit P2-19:aria-label 与 textContent 同步,让屏幕阅读器既知道按钮作用
|
||||||
|
// 也知道当前是哪个文件 / 文件夹。无文件时退到仅描述行为。
|
||||||
|
this.statusPath.setAttribute('aria-label',
|
||||||
|
label === STATUS_PATH_PLACEHOLDER ? '在文件管理器中显示当前文件夹' : `在文件管理器中显示 ${label}`);
|
||||||
|
}
|
||||||
if (this.statusMeta) this.statusMeta.textContent = '';
|
if (this.statusMeta) this.statusMeta.textContent = '';
|
||||||
this._emitHeadingChange();
|
this._emitHeadingChange();
|
||||||
}
|
}
|
||||||
@@ -462,13 +542,17 @@ export class MarkdownViewer {
|
|||||||
refreshCurrentDir() {
|
refreshCurrentDir() {
|
||||||
if (!this.statusPath) return;
|
if (!this.statusPath) return;
|
||||||
if (this.currentFile) return; // 文件路径优先,不覆盖
|
if (this.currentFile) return; // 文件路径优先,不覆盖
|
||||||
this.statusPath.textContent = this._folderLabel();
|
const label = this._folderLabel();
|
||||||
|
this.statusPath.textContent = label;
|
||||||
|
// 与 clear() 同步:路径刷新后 aria-label 跟着变(audit P2-19)。
|
||||||
|
this.statusPath.setAttribute('aria-label',
|
||||||
|
label === STATUS_PATH_PLACEHOLDER ? '在文件管理器中显示当前文件夹' : `在文件管理器中显示 ${label}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到 '—'。 */
|
/** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到「—」占位。 */
|
||||||
_folderLabel() {
|
_folderLabel() {
|
||||||
const dir = this.getCurrentDir ? this.getCurrentDir() : '';
|
const dir = this.getCurrentDir ? this.getCurrentDir() : '';
|
||||||
return dir || '—';
|
return dir || STATUS_PATH_PLACEHOLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -476,7 +560,15 @@ export class MarkdownViewer {
|
|||||||
*/
|
*/
|
||||||
updateStatusBar(file) {
|
updateStatusBar(file) {
|
||||||
if (this.statusPath) {
|
if (this.statusPath) {
|
||||||
this.statusPath.textContent = file.path || file.name;
|
const path = file.path || file.name || STATUS_PATH_PLACEHOLDER;
|
||||||
|
this.statusPath.textContent = path;
|
||||||
|
// audit P2-19:aria-label 与 textContent 同源(统一用 path),屏幕阅读器
|
||||||
|
// 听到什么、看到什么就一致。原本用 file.name 时,对深层路径 SR 只听到短名、
|
||||||
|
// 视觉用户看到全路径,两边漂移。
|
||||||
|
this.statusPath.setAttribute('aria-label',
|
||||||
|
path === STATUS_PATH_PLACEHOLDER
|
||||||
|
? '在文件管理器中显示当前文件夹'
|
||||||
|
: `在文件管理器中显示 ${path}`);
|
||||||
}
|
}
|
||||||
if (this.statusMeta) {
|
if (this.statusMeta) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -551,6 +643,179 @@ export class MarkdownViewer {
|
|||||||
return () => scroller.removeEventListener('scroll', handler);
|
return () => scroller.removeEventListener('scroll', handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置阅读区「微交互」:代码块复制按钮 + 图片点击放大。
|
||||||
|
*
|
||||||
|
* 设计约束:loadContentLive 用 isEqualNode 做前缀/后缀 diff,任何对 <pre>/<img>
|
||||||
|
* 子节点的 DOM 注入都会在实时编辑时破坏匹配、造成闪烁。所以这里的两类增强都
|
||||||
|
* 走「事件委托 + 全局浮层」,绝不往 pre/img 里塞节点:
|
||||||
|
* - 复制按钮 = 一个固定定位的浮动 FAB(document.body 上常驻一个),
|
||||||
|
* 悬浮代码块时算出位置贴到右上角;点击时读取 pre 文本写入剪贴板。
|
||||||
|
* - 图片放大 = 一个常驻的全局 lightbox 浮层(#image-lightbox),
|
||||||
|
* 点击正文 <img> 时填入 src/alt 并显示。
|
||||||
|
* 所有监听挂在 this.signal 上,dispose 自动解绑;FAB / lightbox 浮层在
|
||||||
|
* 构造函数 addAbort 中一并从 document 移除。
|
||||||
|
*/
|
||||||
|
_setupContentInteractions() {
|
||||||
|
if (!this.bodyElement) return;
|
||||||
|
const body = this.bodyElement;
|
||||||
|
let copyFab = null;
|
||||||
|
let hoverPre = null;
|
||||||
|
|
||||||
|
const ensureCopyFab = () => {
|
||||||
|
if (copyFab) return copyFab;
|
||||||
|
copyFab = document.createElement('button');
|
||||||
|
copyFab.type = 'button';
|
||||||
|
copyFab.className = 'code-copy-fab';
|
||||||
|
copyFab.setAttribute('aria-label', '复制代码');
|
||||||
|
copyFab.hidden = true;
|
||||||
|
copyFab.innerHTML = ICON_COPY;
|
||||||
|
copyFab.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!hoverPre) return;
|
||||||
|
const codeEl = hoverPre.querySelector('code');
|
||||||
|
const text = (codeEl ? codeEl.innerText : hoverPre.innerText) || '';
|
||||||
|
this._copyText(text).then((ok) => {
|
||||||
|
if (!ok) return;
|
||||||
|
copyFab.classList.add('is-copied');
|
||||||
|
copyFab.innerHTML = ICON_CHECK;
|
||||||
|
window.clearTimeout(this._copyFabReset);
|
||||||
|
this._copyFabReset = window.setTimeout(() => {
|
||||||
|
copyFab.classList.remove('is-copied');
|
||||||
|
copyFab.innerHTML = ICON_COPY;
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
copyFab.addEventListener('mouseleave', (e) => {
|
||||||
|
// 从 FAB 移回 pre 时不收起,避免点击前按钮消失
|
||||||
|
if (e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('pre')) return;
|
||||||
|
copyFab.hidden = true;
|
||||||
|
hoverPre = null;
|
||||||
|
});
|
||||||
|
document.body.appendChild(copyFab);
|
||||||
|
this._copyFab = copyFab;
|
||||||
|
return copyFab;
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionFab = (pre) => {
|
||||||
|
const fab = ensureCopyFab();
|
||||||
|
const rect = pre.getBoundingClientRect();
|
||||||
|
// FAB 默认隐藏(offsetWidth=0),直接用 CSS 约定的 28px 宽度算位置,避免测不到
|
||||||
|
const fabW = 28;
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||||
|
fab.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let left = rect.right - fabW - 8;
|
||||||
|
let top = rect.top + 8;
|
||||||
|
left = Math.max(8, Math.min(left, window.innerWidth - fabW - 8));
|
||||||
|
top = Math.max(8, top);
|
||||||
|
fab.style.left = `${left}px`;
|
||||||
|
fab.style.top = `${top}px`;
|
||||||
|
fab.hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
body.addEventListener('mouseover', (e) => {
|
||||||
|
const pre = e.target.closest && e.target.closest('pre');
|
||||||
|
if (!pre) return;
|
||||||
|
positionFab(pre);
|
||||||
|
hoverPre = pre;
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
body.addEventListener('mouseout', (e) => {
|
||||||
|
const pre = e.target.closest && e.target.closest('pre');
|
||||||
|
if (!pre) return;
|
||||||
|
// 移到 FAB(在 bodyElement 之外)或 pre 内部子元素时不收起
|
||||||
|
if (e.relatedTarget === copyFab) return;
|
||||||
|
if (e.relatedTarget && pre.contains(e.relatedTarget)) return;
|
||||||
|
if (hoverPre === pre) hoverPre = null;
|
||||||
|
if (copyFab) copyFab.hidden = true;
|
||||||
|
}, { signal: this.signal });
|
||||||
|
|
||||||
|
const scroller = body.parentElement;
|
||||||
|
if (scroller) {
|
||||||
|
scroller.addEventListener('scroll', () => {
|
||||||
|
if (hoverPre && hoverPre.isConnected) positionFab(hoverPre);
|
||||||
|
else if (copyFab) copyFab.hidden = true;
|
||||||
|
}, { passive: true, signal: this.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片点击放大(事件委托,不改动 <img>)
|
||||||
|
body.addEventListener('click', (e) => {
|
||||||
|
const img = e.target.closest && e.target.closest('img');
|
||||||
|
if (!img) return;
|
||||||
|
this._openLightbox(img.currentSrc || img.src, img.alt || '');
|
||||||
|
}, { signal: this.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开图片 lightbox(常驻全局浮层,避免重复创建 / 破坏 live diff)。 */
|
||||||
|
_openLightbox(src, alt) {
|
||||||
|
let overlay = this._lightboxOverlay;
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'image-lightbox';
|
||||||
|
overlay.className = 'image-lightbox';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', '图片预览');
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="image-lightbox-backdrop" data-close="1"></div>
|
||||||
|
<div class="image-lightbox-stage">
|
||||||
|
<img class="image-lightbox-img" alt="">
|
||||||
|
<button type="button" class="image-lightbox-close" data-close="1" aria-label="关闭 (Esc)">${ICON_CLOSE}</button>
|
||||||
|
</div>`;
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target.dataset && e.target.dataset.close) this._closeLightbox();
|
||||||
|
});
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this._lightboxOverlay = overlay;
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && overlay && !overlay.hidden) this._closeLightbox();
|
||||||
|
}, { signal: this.signal });
|
||||||
|
}
|
||||||
|
const img = overlay.querySelector('.image-lightbox-img');
|
||||||
|
img.src = src;
|
||||||
|
img.alt = alt;
|
||||||
|
overlay.hidden = false;
|
||||||
|
const closeBtn = overlay.querySelector('.image-lightbox-close');
|
||||||
|
if (closeBtn) closeBtn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭图片 lightbox。 */
|
||||||
|
_closeLightbox() {
|
||||||
|
const overlay = this._lightboxOverlay;
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.hidden = true;
|
||||||
|
const img = overlay.querySelector('.image-lightbox-img');
|
||||||
|
if (img) img.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入剪贴板,Electron 环境下 clipboard API 可能不可用,用 textarea execCommand 兜底。 */
|
||||||
|
async _copyText(text) {
|
||||||
|
if (!text) return false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch { /* 走兜底 */ }
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.top = '-1000px';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轻量刷新(仅替换 body HTML,不动滚动位置 / 不触发 status bar 更新 / 不存历史)。
|
* 轻量刷新(仅替换 body HTML,不动滚动位置 / 不触发 status bar 更新 / 不存历史)。
|
||||||
*
|
*
|
||||||
@@ -584,20 +849,190 @@ export class MarkdownViewer {
|
|||||||
// resolveRelativeImages 也必须走 generation 比对,单靠 capturedBody 不够
|
// resolveRelativeImages 也必须走 generation 比对,单靠 capturedBody 不够
|
||||||
// (bodyElement 身份不变)。
|
// (bodyElement 身份不变)。
|
||||||
this._resolveGen += 1;
|
this._resolveGen += 1;
|
||||||
this.bodyElement.innerHTML = html;
|
|
||||||
this.resolveRelativeImages(this.currentFile ? this.currentFile.path : null);
|
// audit R7b P1-16 + R8 P2-17:不再同步 `bodyElement.innerHTML = html` 整页替换。
|
||||||
// P1-1:增量更新 IntersectionObserver 而不是每次 innerHTML 后全量重建。
|
// 5MB markdown 渲染后 ~7-10MB HTML,同步解析 + 布局一次吃掉 100-400ms
|
||||||
// 编辑实时预览里 200ms 防抖触发一次,10K 行的笔记每次重建 observer
|
// 主线程,200ms debounce 的实时预览路径打字明显掉帧。_applyHtmlLive 做
|
||||||
// 会做 disconnect + new + N 次 observe,长文档能感知到卡顿。
|
// 两端 diff:未变更的顶级块保留原 DOM 节点(heading observer 的观察
|
||||||
// 仅当 heading 集合发生变化(增 / 删 / id 变)才重建,否则完全跳过。
|
// 目标随之存活),只删 + 插真正变化的中段;大中段再按每帧 ≤ 200 个
|
||||||
// audit fix (Round 4 F11):直呼底层 _rebuildHeadingObserver。_syncHeadingObserver
|
// 节点分批,把成本摊到多帧。
|
||||||
// 是仅做一层转发的薄壳,签名上的优化语义已被 _rebuildHeadingObserver 全量重建
|
//
|
||||||
// 替代,留着壳子只会让读代码的人多绕一层。直接调底层 + 注释说明「全量重建代价
|
// 单飞行:新一次 loadContentLive 先 abort 上一次还没跑完的 apply。
|
||||||
// 可忽略」。
|
// 中途 abort 留下的「新旧混合」body 会被下一次 diff 自动收敛(diff 的
|
||||||
this._rebuildHeadingObserver();
|
// 旧侧总是读当前真实 DOM),不需要清空重来。
|
||||||
|
this._cancelLiveInsert();
|
||||||
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
|
this._liveInsertAbort = controller;
|
||||||
|
this._liveInsertPromise = this._applyHtmlLive(html, controller ? controller.signal : null)
|
||||||
|
.catch((e) => {
|
||||||
|
// apply 内部不抛(每步都有 signal 检查),这里是防御性兜底,
|
||||||
|
// 与 loadContent 的 resolveRelativeImages catch 对齐。
|
||||||
|
console.warn('[viewer] live apply 失败:', e && e.message);
|
||||||
|
});
|
||||||
return { changed: true };
|
return { changed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消还没跑完的 live apply(audit R7b P1-16 / R8 P2-17)。
|
||||||
|
*
|
||||||
|
* 所有「替换 / 清空 bodyElement」的入口都要先调它:loadContent /
|
||||||
|
* loadContentLive(下一次)/ clear / showUnsupportedFile / dispose。
|
||||||
|
* 否则旧 apply 的后续 rAF 帧会继续 insertBefore,污染新 DOM。
|
||||||
|
*/
|
||||||
|
_cancelLiveInsert() {
|
||||||
|
if (this._liveInsertAbort) {
|
||||||
|
try { this._liveInsertAbort.abort(); } catch { /* ignore */ }
|
||||||
|
this._liveInsertAbort = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 HTML 字符串增量应用到 bodyElement(audit R7b P1-16 + R8 P2-17)。
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. (同步,大 html 推迟一帧)createContextualFragment 解析 html
|
||||||
|
* —— fragment 节点全部 detached,解析不触发 body 的重排
|
||||||
|
* 2. 两端 diff:旧 body.childNodes vs 新 fragment.childNodes,
|
||||||
|
* 共同前缀 / 后缀用 isEqualNode 判定保留 —— 节点引用不动,
|
||||||
|
* heading observer 的观察目标随之存活。
|
||||||
|
* 已知例外:含已解析图片的块无法保留 —— resolveRelativeImages 把
|
||||||
|
* src 改写成 file:// 并加 loading/decoding,与下一轮渲染产物里的
|
||||||
|
* 原始相对 src 不再 isEqualNode → 该块被替换 + scoped 重解析。
|
||||||
|
* 与 P1-16 前的全量替换基线等价(图片块本来就每轮重解析),非回归。
|
||||||
|
* 3. 只删「旧中段」、只插「新中段」:实时打字场景中段通常就是
|
||||||
|
* 正在编辑的那一个块 → 每次 debounce 只动 1 个节点
|
||||||
|
* 4. 新中段 ≤ CHUNK 同步插(小文档零额外延迟);> CHUNK 每帧最多
|
||||||
|
* CHUNK 个节点分批,每帧开头检查 signal.aborted
|
||||||
|
* 5. 全部落地 → _finishLiveInsert:重建 heading observer + 按变更块
|
||||||
|
* scope 启动 resolveRelativeImages。两者都必须看到完整 DOM 才有意义。
|
||||||
|
*
|
||||||
|
* 两端 diff 的适用性:每次 debounce tick 对应源文档一段连续编辑,
|
||||||
|
* 渲染产物的差异也是一段连续区间(单块改 / 单块增删最常见),
|
||||||
|
* 前缀+后缀双侧收缩正好覆盖。退化场景(AI apply-all / 外部全量改写)
|
||||||
|
* 中段 = 全文,行为等价于 P1-16 的分批全量替换, correctness 不变。
|
||||||
|
*
|
||||||
|
* abort 自愈:中途 abort 不做任何回滚,body 停在「新旧混合」态;
|
||||||
|
* 下一次 _applyHtmlLive 的旧侧读当前真实 DOM,diff 自动补齐差异。
|
||||||
|
*
|
||||||
|
* @param {string} html - renderMarkdown 的产物
|
||||||
|
* @param {AbortSignal|null} signal - 取消信号;null 时退化为不检查(兼容老环境)
|
||||||
|
*/
|
||||||
|
async _applyHtmlLive(html, signal) {
|
||||||
|
const body = this.bodyElement;
|
||||||
|
if (!body) return;
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
|
||||||
|
const source = html || '';
|
||||||
|
// 大文档(> 256KB)连解析都推迟到下一帧:createContextualFragment 对
|
||||||
|
// 7-10MB HTML 的解析本身就要 50-150ms,放在同步段会让 loadContentLive
|
||||||
|
// 的调用方(editor onChange → debounce 回调)依然卡一个大帧。
|
||||||
|
// 小文档保持同步解析 —— 典型笔记(几 KB)零额外延迟。
|
||||||
|
const LARGE_HTML_BYTES = 256 * 1024;
|
||||||
|
if (source.length > LARGE_HTML_BYTES) {
|
||||||
|
await this._nextFrame();
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// createContextualFragment 走浏览器原生 HTML parser,产物是 detached
|
||||||
|
// fragment,解析期间不触发 body 的 layout/paint。
|
||||||
|
const range = document.createRange();
|
||||||
|
const fragment = range.createContextualFragment(source);
|
||||||
|
|
||||||
|
// 两端 diff。isEqualNode 是原生深比较(属性顺序不敏感、无字符串分配),
|
||||||
|
// 未变更块 O(块大小) 但全程 native code,远快于重建 DOM。
|
||||||
|
const oldNodes = Array.from(body.childNodes);
|
||||||
|
const newNodes = Array.from(fragment.childNodes);
|
||||||
|
let start = 0;
|
||||||
|
let oldEnd = oldNodes.length - 1;
|
||||||
|
let newEnd = newNodes.length - 1;
|
||||||
|
while (start <= oldEnd && start <= newEnd && oldNodes[start].isEqualNode(newNodes[start])) start++;
|
||||||
|
while (oldEnd >= start && newEnd >= start && oldNodes[oldEnd].isEqualNode(newNodes[newEnd])) {
|
||||||
|
oldEnd--;
|
||||||
|
newEnd--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCount = oldEnd - start + 1;
|
||||||
|
const insertCount = newEnd - start + 1;
|
||||||
|
if (removeCount <= 0 && insertCount <= 0) {
|
||||||
|
// 完全等价(debounce 抖动 / 属性顺序差异 / 改了又改回)→ 零 DOM 变更。
|
||||||
|
// 仍走 finish:heading observer reconcile + 图片解析保持幂等语义。
|
||||||
|
this._finishLiveInsert(signal, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删旧中段(detach 便宜,同步做)。
|
||||||
|
for (let i = start; i <= oldEnd; i++) {
|
||||||
|
body.removeChild(oldNodes[i]);
|
||||||
|
}
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
|
||||||
|
// 插新中段:refNode 是旧 suffix 第一个保留节点(仍在 body 里),
|
||||||
|
// 没有 suffix 时 insertBefore(n, null) 等价 appendChild。
|
||||||
|
const refNode = oldNodes[oldEnd + 1] || null;
|
||||||
|
const toInsert = newNodes.slice(start, newEnd + 1);
|
||||||
|
|
||||||
|
const CHUNK = 200; // 每帧 ≤ 200 个节点(P1-16 与用户对齐的取值)
|
||||||
|
if (toInsert.length <= CHUNK) {
|
||||||
|
for (const n of toInsert) body.insertBefore(n, refNode);
|
||||||
|
} else {
|
||||||
|
let idx = 0;
|
||||||
|
while (idx < toInsert.length) {
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
await this._nextFrame();
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
const batch = document.createDocumentFragment();
|
||||||
|
const end = Math.min(idx + CHUNK, toInsert.length);
|
||||||
|
for (let i = idx; i < end; i++) batch.appendChild(toInsert[i]);
|
||||||
|
body.insertBefore(batch, refNode);
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._finishLiveInsert(signal, toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* live apply 全部完成后的收尾(audit R7b P1-16 / R8 P2-14 / P2-16):
|
||||||
|
* heading observer 与 resolveRelativeImages 都依赖「完整 DOM」,
|
||||||
|
* 必须等所有变更落地再跑,否则 observer 盯半截 DOM / 图片漏解析。
|
||||||
|
*
|
||||||
|
* @param {AbortSignal|null} signal
|
||||||
|
* @param {Node[]} changedRoots - 本次新插入的顶级块(P2-14:图片解析只扫
|
||||||
|
* 这些块;未变更块的 img 上一轮已解析过,重扫是纯浪费)。传 [] 表示
|
||||||
|
* 零变更;loadContent 等非 diff 路径不传(= 全 body 扫描)。
|
||||||
|
*/
|
||||||
|
_finishLiveInsert(signal, changedRoots) {
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
// P2-16 契约(与 notes-2c 对齐):observer 实例持久化,但每次重建必须
|
||||||
|
// 全量 re-observe(renderer-M1 历史教训)。diff 保留了 heading 节点引用,
|
||||||
|
// 但 re-observe 的成本 O(heading 数 5-50) 可忽略,不为此破坏统一语义。
|
||||||
|
this._rebuildHeadingObserver();
|
||||||
|
// fire-and-forget,与 loadContent 路径一致;catch 兜底避免 unhandled rejection。
|
||||||
|
this.resolveRelativeImages(this.currentFile ? this.currentFile.path : null, changedRoots)
|
||||||
|
.catch((e) => console.warn('[viewer] resolveRelativeImages 失败:', e && e.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等下一帧。jsdom / 老环境没有 requestAnimationFrame 时退化为 setTimeout(0)。
|
||||||
|
* 独立成方法方便测试替换 / 未来统一调度。
|
||||||
|
*/
|
||||||
|
_nextFrame() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||||
|
window.requestAnimationFrame(() => resolve());
|
||||||
|
} else {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试钩子:等当前 in-flight 的 chunked insert 完成(含被 abort 的 ——
|
||||||
|
* promise 已 .catch 兜底,永远 resolve)。
|
||||||
|
*/
|
||||||
|
_test_liveInsertDone() {
|
||||||
|
return this._liveInsertPromise || Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前在视口里"最顶部可见"的 heading
|
* 当前在视口里"最顶部可见"的 heading
|
||||||
* @returns {{level:1|2|3, text:string, id:string}|null}
|
* @returns {{level:1|2|3, text:string, id:string}|null}
|
||||||
@@ -626,15 +1061,19 @@ export class MarkdownViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重建 heading IntersectionObserver
|
* 重建 heading 观察:每次 loadContent / loadContentLive 都会调用 —— 因为
|
||||||
*
|
* DOM 全换了,旧观察目标节点已 detach,必须重新 observe 新节点。
|
||||||
* 每次 loadContent / loadContentLive 都会调用 —— 因为 DOM 全换了,
|
* (P2-16 后 observer 实例本身持久化,见函数体注释。)
|
||||||
* 旧的 observer 持有的 DOM 节点已经无效,必须重建。
|
|
||||||
*/
|
*/
|
||||||
_rebuildHeadingObserver() {
|
_rebuildHeadingObserver() {
|
||||||
|
// P2-16 (audit):observer 实例持久化 —— 不再每次 new IntersectionObserver。
|
||||||
|
// loadContentLive 在 split 模式每个 debounced 击键都跑一次重建,旧实现
|
||||||
|
// 每次都分配 observer 对象 + 回调闭包。现在实例只建一次,重建只做
|
||||||
|
// disconnect + 全量 re-observe。
|
||||||
|
// 注意:「全量 re-observe」不能省(renderer-M1 历史教训:innerHTML 替换
|
||||||
|
// detach 旧节点,增量 observe 会盯着死节点,heading 追踪冻死)。
|
||||||
if (this._headingObserver) {
|
if (this._headingObserver) {
|
||||||
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
|
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
|
||||||
this._headingObserver = null;
|
|
||||||
}
|
}
|
||||||
if (!this.bodyElement) return;
|
if (!this.bodyElement) return;
|
||||||
const headings = this.bodyElement.querySelectorAll('h1, h2, h3');
|
const headings = this.bodyElement.querySelectorAll('h1, h2, h3');
|
||||||
@@ -652,67 +1091,71 @@ export class MarkdownViewer {
|
|||||||
// 记录当前观察的 heading id 列表(调试 / 复用参考)
|
// 记录当前观察的 heading id 列表(调试 / 复用参考)
|
||||||
this._observedHeadingIds = headings.length === 0 ? [] : Array.from(headings).map(h => h.id);
|
this._observedHeadingIds = headings.length === 0 ? [] : Array.from(headings).map(h => h.id);
|
||||||
if (headings.length === 0) {
|
if (headings.length === 0) {
|
||||||
|
// 清空旧文档遗留的 heading 引用,避免指向已 detach 的节点(非空分支才在下方重建
|
||||||
|
// _headingNodes / _headingVisibleIds,提前 return 时要补,否则 _onHeadingIntersection
|
||||||
|
// 在空文档上仍持有旧节点引用)。
|
||||||
|
this._headingNodes = [];
|
||||||
|
this._headingVisibleIds = null;
|
||||||
this.currentHeadingId = null;
|
this.currentHeadingId = null;
|
||||||
this._emitHeadingChange();
|
this._emitHeadingChange();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 把"当前可见的 heading 集合"实时维护:进入带 → 加入;离开带 → 移除。
|
// P2-16:回调改读实例状态(_headingNodes / _headingVisibleIds),
|
||||||
// 最后 emit 的 currentHeadingId 是"位置最低且仍在带内"的(最接近顶部
|
// 不再闭包捕获本次的 headings / visibleIds —— 闭包捕获会让旧 observer
|
||||||
// 但还没滚走的那个 —— 这就是用户在阅读的章节)。
|
// 持有旧 NodeList,也正是「必须每次 new」的根因;实例状态化之后
|
||||||
const visibleIds = new Set();
|
// observer 可以跨重建存活。
|
||||||
this._headingObserver = new IntersectionObserver((entries) => {
|
this._headingNodes = Array.from(headings);
|
||||||
for (const e of entries) {
|
this._headingVisibleIds = new Set();
|
||||||
const id = e.target.id;
|
if (!this._headingObserver) {
|
||||||
if (!id) continue;
|
this._headingObserver = new IntersectionObserver((entries) => {
|
||||||
if (e.isIntersecting) visibleIds.add(id);
|
this._onHeadingIntersection(entries);
|
||||||
else visibleIds.delete(id);
|
}, {
|
||||||
}
|
root: this.bodyElement.parentElement || null,
|
||||||
// 找在 visibleIds 里、且 DOM 顺序中位置最低(最后)的 heading
|
// 顶部 0-20% 的"激活带" —— 进入这个带才算"当前正在阅读"
|
||||||
let next = null;
|
rootMargin: '0px 0px -80% 0px',
|
||||||
for (const h of headings) {
|
// P2-9:单阈值 0 即可。原来的 [0, 1] 会在「进入带」与「完全在带里」各发一次,
|
||||||
if (visibleIds.has(h.id)) next = h.id;
|
// 状态栏 heading chip 在边界来回闪烁;激活带由 rootMargin 控制已经够准确。
|
||||||
}
|
threshold: 0,
|
||||||
// 如果一个都不在带内(滚过头了),保持上一次
|
});
|
||||||
if (next === null && visibleIds.size === 0) {
|
}
|
||||||
// 不主动改 currentHeadingId —— 让"上次已知"留在状态栏
|
for (const h of this._headingNodes) this._headingObserver.observe(h);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
// 滚过头(带内全空,且最后位置在视口下方) → 选中最后一个 heading
|
/**
|
||||||
if (next === null) {
|
* heading IntersectionObserver 回调(P2-16 抽成实例方法,供持久化 observer 复用)。
|
||||||
// 用 viewport 顶部坐标最近的 heading
|
*
|
||||||
const scroller = this.bodyElement.parentElement;
|
* 把"当前可见的 heading 集合"实时维护:进入带 → 加入;离开带 → 移除。
|
||||||
if (scroller) {
|
* 最后 emit 的 currentHeadingId 是"位置最低且仍在带内"的(最接近顶部
|
||||||
// offsetTop 相对最近的 positioned 祖先;本布局里 .viewer / .markdown-body
|
* 但还没滚走的那个 —— 这就是用户在阅读的章节)。
|
||||||
// 都是 position: static,offsetParent 一路爬到 <body> —— 与
|
* @param {IntersectionObserverEntry[]} entries
|
||||||
// scroller.scrollTop 完全不在同一坐标系,会导致「滚过头时永远选不中」。
|
*/
|
||||||
// 改用 getBoundingClientRect 拿视口坐标,scroller.scrollTop 在两边都加
|
_onHeadingIntersection(entries) {
|
||||||
// 上 → 相互抵消,等价于比较 h.rect.top <= scroller.rect.top。
|
const headings = this._headingNodes || [];
|
||||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
const visibleIds = this._headingVisibleIds;
|
||||||
let best = null;
|
if (!visibleIds) return;
|
||||||
let bestDist = Infinity;
|
for (const e of entries) {
|
||||||
for (const h of headings) {
|
const id = e.target.id;
|
||||||
const hTop = h.getBoundingClientRect().top;
|
if (!id) continue;
|
||||||
if (hTop <= scrollerTop) {
|
if (e.isIntersecting) visibleIds.add(id);
|
||||||
const d = scrollerTop - hTop;
|
else visibleIds.delete(id);
|
||||||
if (d < bestDist) { bestDist = d; best = h.id; }
|
}
|
||||||
}
|
// 找在 visibleIds 里、且 DOM 顺序中位置最低(最后)的 heading
|
||||||
}
|
let next = null;
|
||||||
next = best;
|
for (const h of headings) {
|
||||||
}
|
if (visibleIds.has(h.id)) next = h.id;
|
||||||
}
|
}
|
||||||
if (next !== this.currentHeadingId) {
|
// 如果一个都不在带内(滚过头了),保持上一次
|
||||||
this.currentHeadingId = next;
|
if (next === null && visibleIds.size === 0) {
|
||||||
this._emitHeadingChange();
|
// 不主动改 currentHeadingId —— 让"上次已知"留在状态栏
|
||||||
}
|
return;
|
||||||
}, {
|
}
|
||||||
root: this.bodyElement.parentElement || null,
|
// 注:`next === null` 在此处不可达 —— 进入本分支前 `if (next === null && visibleIds.size === 0) return;`
|
||||||
// 顶部 0-20% 的"激活带" —— 进入这个带才算"当前正在阅读"
|
// 已把「带内全空」的情况拦截;若 next 为 null 则 visibleIds 必为空,早已 return。
|
||||||
rootMargin: '0px 0px -80% 0px',
|
// 故这里无需再处理「滚过头」回退逻辑(那段 getBoundingClientRect 计算是死代码,已删除)。
|
||||||
// P2-9:单阈值 0 即可。原来的 [0, 1] 会在「进入带」与「完全在带里」各发一次,
|
if (next !== this.currentHeadingId) {
|
||||||
// 状态栏 heading chip 在边界来回闪烁;激活带由 rootMargin 控制已经够准确。
|
this.currentHeadingId = next;
|
||||||
threshold: 0,
|
this._emitHeadingChange();
|
||||||
});
|
}
|
||||||
for (const h of headings) this._headingObserver.observe(h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function mountModal({
|
|||||||
<div class="modal" role="document">
|
<div class="modal" role="document">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="${escapeHtml(id)}" class="modal-title">${escapeHtml(title)}</h2>
|
<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>
|
<button class="modal-close" type="button" data-action="__modal_close__" aria-label="关闭对话框">${CLOSE_BUTTON_SVG}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" data-modal-body></div>
|
<div class="modal-body" data-modal-body></div>
|
||||||
<div class="modal-footer" data-modal-footer></div>
|
<div class="modal-footer" data-modal-footer></div>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function showPromptDialog({
|
|||||||
// ('prompt() is and will not be supported'),正好是我们要绕开的坑。
|
// ('prompt() is and will not be supported'),正好是我们要绕开的坑。
|
||||||
// 兜底行为:当作取消处理(resolve null),并 toast 提示用户,让重要
|
// 兜底行为:当作取消处理(resolve null),并 toast 提示用户,让重要
|
||||||
// 操作(重命名 / 新建文件)不会静默无反应(audit L2 #7)。
|
// 操作(重命名 / 新建文件)不会静默无反应(audit L2 #7)。
|
||||||
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
|
showToast('已有对话框打开,请先关闭后再试', 'warning');
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ export class SettingsDialog {
|
|||||||
|
|
||||||
// 3 个 radio 都已是默认值时按钮应禁用 —— click 后立即重算。
|
// 3 个 radio 都已是默认值时按钮应禁用 —— click 后立即重算。
|
||||||
updateResetBtnState();
|
updateResetBtnState();
|
||||||
showToast('已恢复外观默认值,点保存后生效', 'info', 1800);
|
showToast('外观已恢复默认,点保存后生效', 'info', 1800);
|
||||||
}, { signal });
|
}, { signal });
|
||||||
|
|
||||||
// 暴露给 _bindAppearancePickers 的 change 监听器:用户改 picker 后实时
|
// 暴露给 _bindAppearancePickers 的 change 监听器:用户改 picker 后实时
|
||||||
@@ -570,7 +570,7 @@ export class SettingsDialog {
|
|||||||
// 审计修复 (Round 11 P2-4):去掉重复的 applyHint('openai')。
|
// 审计修复 (Round 11 P2-4):去掉重复的 applyHint('openai')。
|
||||||
// 上面 dispatch + openaiRadio.checked 已触发 aiProviderRadios 的 change
|
// 上面 dispatch + openaiRadio.checked 已触发 aiProviderRadios 的 change
|
||||||
// 监听器跑过一次 applyHint,再调一次是冗余。
|
// 监听器跑过一次 applyHint,再调一次是冗余。
|
||||||
showToast('已清空 AI 配置,点保存后生效', 'info', 1800);
|
showToast('AI 配置已清空,点保存后生效', 'info', 1800);
|
||||||
// focus 到 Base URL 让用户能直接接着填;不放 model 是因为清空后
|
// focus 到 Base URL 让用户能直接接着填;不放 model 是因为清空后
|
||||||
// 通常会先确认/切换 provider,再选对应模型。
|
// 通常会先确认/切换 provider,再选对应模型。
|
||||||
aiBaseUrlInput.focus();
|
aiBaseUrlInput.focus();
|
||||||
@@ -683,8 +683,24 @@ export class SettingsDialog {
|
|||||||
// 只提供「配色(palette)」,因为它没有顶部按钮入口。
|
// 只提供「配色(palette)」,因为它没有顶部按钮入口。
|
||||||
const palette = THEME_PALETTES.some(p => p.value === initial.themePalette) ? initial.themePalette : 'default';
|
const palette = THEME_PALETTES.some(p => p.value === initial.themePalette) ? initial.themePalette : 'default';
|
||||||
const paletteLabel = THEME_PALETTES.find(p => p.value === palette)?.label || palette;
|
const paletteLabel = THEME_PALETTES.find(p => p.value === palette)?.label || palette;
|
||||||
const fs = READER_FONT_SIZES.includes(initial.readerFontSize) ? initial.readerFontSize : 17;
|
// 显示列表:若当前值在 schema 合法范围但不在 UI 枚举里(用户手改 config.json 设了 20),
|
||||||
const lh = READER_LINE_HEIGHTS.includes(initial.readerLineHeight) ? initial.readerLineHeight : 1.85;
|
// 把它前置为可选项并选中,避免打开设置一保存就静默回落到 17(丢弃用户真实设置)。
|
||||||
|
const fsList = READER_FONT_SIZES.includes(initial.readerFontSize)
|
||||||
|
? READER_FONT_SIZES
|
||||||
|
: (typeof initial.readerFontSize === 'number' && Number.isInteger(initial.readerFontSize)
|
||||||
|
&& initial.readerFontSize >= 12 && initial.readerFontSize <= 24)
|
||||||
|
? [initial.readerFontSize, ...READER_FONT_SIZES]
|
||||||
|
: READER_FONT_SIZES;
|
||||||
|
const fsFallback = READER_FONT_SIZES.includes(17) ? 17 : (READER_FONT_SIZES[0] ?? 17);
|
||||||
|
const fs = initial.readerFontSize != null && fsList.includes(initial.readerFontSize)
|
||||||
|
? initial.readerFontSize : fsFallback;
|
||||||
|
const lhList = READER_LINE_HEIGHTS.includes(initial.readerLineHeight)
|
||||||
|
? READER_LINE_HEIGHTS
|
||||||
|
: (typeof initial.readerLineHeight === 'number' && initial.readerLineHeight >= 1.4 && initial.readerLineHeight <= 2.2)
|
||||||
|
? [initial.readerLineHeight, ...READER_LINE_HEIGHTS]
|
||||||
|
: READER_LINE_HEIGHTS;
|
||||||
|
const lh = initial.readerLineHeight != null && lhList.includes(initial.readerLineHeight)
|
||||||
|
? initial.readerLineHeight : 1.85;
|
||||||
|
|
||||||
// 「恢复默认」按钮的初始 disabled 态:当前 3 个视觉字段都已经是 schema 默认值时禁用。
|
// 「恢复默认」按钮的初始 disabled 态:当前 3 个视觉字段都已经是 schema 默认值时禁用。
|
||||||
// 默认值在 _render 入口已统一计算到 this._appearanceDefaults。
|
// 默认值在 _render 入口已统一计算到 this._appearanceDefaults。
|
||||||
@@ -711,8 +727,8 @@ export class SettingsDialog {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// 字号卡片:5 张卡,每张渲染一个 "Aa" 样本
|
// 字号卡片:5 张卡,每张渲染一个 "Aa" 样本(fsList 可能含用户手设的自定义值)
|
||||||
const fontSizeCards = READER_FONT_SIZES.map((n) => {
|
const fontSizeCards = fsList.map((n) => {
|
||||||
const isSelected = n === fs;
|
const isSelected = n === fs;
|
||||||
return `
|
return `
|
||||||
<label class="picker-card fontsize-card ${isSelected ? 'is-selected' : ''}" data-picker="fontsize" data-value="${n}" style="--sample-size:${n}px">
|
<label class="picker-card fontsize-card ${isSelected ? 'is-selected' : ''}" data-picker="fontsize" data-value="${n}" style="--sample-size:${n}px">
|
||||||
@@ -724,8 +740,8 @@ export class SettingsDialog {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// 行距卡片:4 张卡,每张渲染 3 行示例文本
|
// 行距卡片:4 张卡,每张渲染 3 行示例文本(lhList 可能含用户手设的自定义值)
|
||||||
const lineHeightCards = READER_LINE_HEIGHTS.map((n) => {
|
const lineHeightCards = lhList.map((n) => {
|
||||||
const isSelected = n === lh;
|
const isSelected = n === lh;
|
||||||
return `
|
return `
|
||||||
<label class="picker-card lineheight-card ${isSelected ? 'is-selected' : ''}" data-picker="lineheight" data-value="${n}" style="--sample-lh:${n}">
|
<label class="picker-card lineheight-card ${isSelected ? 'is-selected' : ''}" data-picker="lineheight" data-value="${n}" style="--sample-lh:${n}">
|
||||||
@@ -912,7 +928,7 @@ print('hello')</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
${initial._aiKeyRevealFailed
|
${initial._aiKeyRevealFailed
|
||||||
? `<div class="form-hint form-hint-warning" role="status">
|
? `<div class="form-hint form-hint-warning" role="status">
|
||||||
Key 已保存但当前无法回显。如要替换请输入完整新 Key;留空不会清空已有 Key(需清空请手动编辑 config.json)。
|
已保存的 Key 当前无法回显。输入完整新 Key 可替换;留空将保留原值。
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -937,7 +953,7 @@ print('hello')</code></pre>
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div class="settings-callout-text">
|
<div class="settings-callout-text">
|
||||||
API Key 明文存储于本机 <code>config.json</code>${initial._configName ? `${initial._configDirName ? `(位于 <code>${escapeAttr(initial._configDirName)}</code> 配置目录)` : ''}<button type="button" class="btn-link" data-action="open-config-dir">[打开文件夹]</button>` : ''}。每次请求以 HTTP header 形式发送至上方的 Base URL,请使用你信任的服务。
|
API Key 明文存储于本机 <code>config.json</code>${initial._configName ? `${initial._configDirName ? `(位于 <code>${escapeAttr(initial._configDirName)}</code> 配置目录)` : ''}<button type="button" class="btn-link" data-action="open-config-dir">打开文件夹</button>` : ''}。每次请求以 HTTP header 形式发送至上方填写的 Base URL,请使用你信任的服务。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -950,7 +966,7 @@ print('hello')</code></pre>
|
|||||||
rows="4"
|
rows="4"
|
||||||
placeholder="留空使用内置默认提示词"
|
placeholder="留空使用内置默认提示词"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="form-hint">让 AI 按特定角色或格式回复;留空时使用内置默认。</div>
|
<div class="form-hint">让 AI 按特定角色或格式回复;留空时使用内置默认提示词。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-callout settings-callout-danger" role="note">
|
<div class="settings-callout settings-callout-danger" role="note">
|
||||||
@@ -976,7 +992,7 @@ print('hello')</code></pre>
|
|||||||
// 直接禁用按钮 + tooltip 引导「手动编辑 config.json」,避免与 hint 自相矛盾。
|
// 直接禁用按钮 + tooltip 引导「手动编辑 config.json」,避免与 hint 自相矛盾。
|
||||||
? 'disabled aria-disabled="true" title="Key 无法回显,无法安全清空。请手动编辑 config.json。"'
|
? 'disabled aria-disabled="true" title="Key 无法回显,无法安全清空。请手动编辑 config.json。"'
|
||||||
: ''}
|
: ''}
|
||||||
>[清空 AI 配置]</button>
|
>清空 AI 配置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1250,13 +1266,14 @@ print('hello')</code></pre>
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[settings-dialog] 保存失败:', e);
|
console.error('[settings-dialog] 保存失败:', e);
|
||||||
showToast(`保存失败:${e.message}`, 'error');
|
showToast(`保存失败:${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
// 统一收口:成功 / 失败 / 早退(DOM 缺失 / 无变更)三条路径都走这里。
|
||||||
|
// 用 finally 保证即便 try 内提前 return(如关键 DOM 元素缺失,line ~1174)
|
||||||
|
// 也能复位 _saving 与保存按钮 —— 否则 _saving 卡在 true 会让后续 _save
|
||||||
|
// 被 `if (this._saving) return` 误挡,必须 reload 才能恢复。
|
||||||
|
this._saving = false;
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
// 统一收口:成功 / 失败 / 早退(DOM 缺失 / 无变更)三条路径都走这里。
|
|
||||||
// 之前只有 catch 分支复位 _saving —— 成功后 modal.close('save') 直接
|
|
||||||
// return,_saving 留在 true;再次 open() 会被 `if (this._saving) return`
|
|
||||||
// 误挡,必须 reload 才能恢复(按钮看起来"失灵",但实际上是状态卡死)。
|
|
||||||
this._saving = false;
|
|
||||||
if (saveBtn) saveBtn.disabled = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6)
|
// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6)
|
||||||
// - Ctrl/Cmd + W 关闭当前打开的文件(无文件时 no-op;模态打开时吞掉)
|
// - Ctrl/Cmd + W 关闭当前打开的文件(无文件时 no-op;模态打开时吞掉)
|
||||||
// - Esc(搜索框内) 清空搜索
|
// - Esc(搜索框内) 清空搜索
|
||||||
|
// - Ctrl/Cmd + X 剪切侧栏选中的文件 / 文件夹到剪贴板(编辑器内让给 CM6)
|
||||||
|
// - Ctrl/Cmd + C 复制侧栏选中的文件 / 文件夹到剪贴板(编辑器内让给 CM6)
|
||||||
|
// - Ctrl/Cmd + V 把剪贴板里的条目粘贴到当前浏览目录(编辑器内让给 CM6)
|
||||||
//
|
//
|
||||||
// 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时),
|
// 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时),
|
||||||
// 但事件仍会继续冒泡到 document —— 所以这里必须主动避让,否则
|
// 但事件仍会继续冒泡到 document —— 所以这里必须主动避让,否则
|
||||||
@@ -35,6 +38,20 @@ function isInsideEditor(e) {
|
|||||||
return !!target.closest('.cm-editor');
|
return !!target.closest('.cm-editor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件焦点是否在侧栏 / 文件列表内。
|
||||||
|
* 只有焦点落在侧栏(含 .file-list 里的 .file-item)时,Ctrl+X/C/V 才作用于
|
||||||
|
* 选中的文件条目;否则(编辑器内 / 阅读视图 / 搜索框)让浏览器原生
|
||||||
|
* 复制 / 粘贴文本,不被快捷键劫持。
|
||||||
|
* @param {Event} e
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isSidebarFocused(e) {
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || typeof target.closest !== 'function') return false;
|
||||||
|
return !!(target.closest('.sidebar') || target.closest('.file-list'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} handlers
|
* @param {object} handlers
|
||||||
* @param {() => void} handlers.onFocusSearch
|
* @param {() => void} handlers.onFocusSearch
|
||||||
@@ -48,6 +65,9 @@ function isInsideEditor(e) {
|
|||||||
* @param {() => void} [handlers.onToggleAi] - Ctrl/Cmd+Shift+A(AI 修改面板)
|
* @param {() => void} [handlers.onToggleAi] - Ctrl/Cmd+Shift+A(AI 修改面板)
|
||||||
* @param {() => void} [handlers.onReload] - Ctrl/Cmd+R(README 之前提到但未注册——audit 补全)
|
* @param {() => void} [handlers.onReload] - Ctrl/Cmd+R(README 之前提到但未注册——audit 补全)
|
||||||
* @param {() => void} [handlers.onCloseFile] - Ctrl/Cmd+W(关闭当前打开的文件;无文件时 no-op)
|
* @param {() => void} [handlers.onCloseFile] - Ctrl/Cmd+W(关闭当前打开的文件;无文件时 no-op)
|
||||||
|
* @param {() => void} [handlers.onCut] - Ctrl/Cmd+X(剪切侧栏选中条目到剪贴板)
|
||||||
|
* @param {() => void} [handlers.onCopy] - Ctrl/Cmd+C(复制侧栏选中条目到剪贴板)
|
||||||
|
* @param {() => void} [handlers.onPaste] - Ctrl/Cmd+V(把剪贴板条目粘贴到当前目录)
|
||||||
*/
|
*/
|
||||||
export function mountKeyboardShortcuts({
|
export function mountKeyboardShortcuts({
|
||||||
onFocusSearch,
|
onFocusSearch,
|
||||||
@@ -61,6 +81,9 @@ export function mountKeyboardShortcuts({
|
|||||||
onToggleAi,
|
onToggleAi,
|
||||||
onReload,
|
onReload,
|
||||||
onCloseFile,
|
onCloseFile,
|
||||||
|
onCut,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
}) {
|
}) {
|
||||||
// audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。
|
// audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。
|
||||||
// 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount
|
// 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount
|
||||||
@@ -95,12 +118,17 @@ export function mountKeyboardShortcuts({
|
|||||||
|
|
||||||
const inEditor = isInsideEditor(e);
|
const inEditor = isInsideEditor(e);
|
||||||
|
|
||||||
// 模态打开时禁用所有 Ctrl/Cmd 类快捷键,避免抢焦点或顶替对话框。
|
// 模态打开时禁用大部分 Ctrl/Cmd 类快捷键,避免抢焦点或顶替对话框。
|
||||||
// 注意:编辑器内的 Ctrl+S/Ctrl+F 已经在 inEditor 分支里 return 了,
|
// 注意:编辑器内的 Ctrl+S/Ctrl+F 已经在 inEditor 分支里 return 了,
|
||||||
// 所以这里再补一个 modalOpen 不会影响 CM6 内部的快捷键。
|
// 所以这里再补一个 modalOpen 不会影响 CM6 内部的快捷键。
|
||||||
if (modalOpen && !inEditor) {
|
if (modalOpen && !inEditor) {
|
||||||
// 但 Ctrl+S 仍允许 —— 在模态内按 Ctrl+S 不该有副作用
|
// 模态内大部分文件类快捷键吞掉,但 Ctrl+S 是例外 —— 下面 SUPPRESS_KEYS 不收 's',
|
||||||
// 真正会被拦下的:Ctrl+, / Ctrl+F / Ctrl+Shift+T / Ctrl+E / Ctrl+N / Ctrl+Shift+F / Ctrl+Shift+A
|
// 事件会落到文件末尾的 Ctrl+S 分支(line ~158)继续调 onSave 保存底层文档,
|
||||||
|
// 与编辑器内 Ctrl+S 行为一致(设置对话框自身也在内部监听 Ctrl+S 做「模态内快捷保存」,
|
||||||
|
// 并用 stopPropagation 阻止冒泡,这里无需拦截;开着普通 confirm/prompt 时
|
||||||
|
// 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' 等大写
|
// 用 toLowerCase() 同时覆盖 Ctrl 与 Cmd,按 Shift 时 key 已是 'F'/'T' 等大写
|
||||||
// —— 所以 'f' 一行就同时兜住 Ctrl+F 和 Ctrl+Shift+F,不用再写一份 shiftKey 子句。
|
// —— 所以 'f' 一行就同时兜住 Ctrl+F 和 Ctrl+Shift+F,不用再写一份 shiftKey 子句。
|
||||||
const k = e.key.toLowerCase();
|
const k = e.key.toLowerCase();
|
||||||
@@ -204,6 +232,24 @@ export function mountKeyboardShortcuts({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + X / C / V → 剪切 / 复制 / 粘贴侧栏选中的文件条目。
|
||||||
|
// 仅在焦点位于侧栏(.sidebar / .file-list)时生效:
|
||||||
|
// - 编辑器内 → 让给 CM6 的原生文本复制 / 剪切 / 粘贴
|
||||||
|
// - 阅读视图 / 搜索框 → 让给浏览器原生文本复制
|
||||||
|
// 与上方其它 Ctrl+letter 分支一样,IME 合成期已在 handler 顶部被 isComposing 守卫拦截。
|
||||||
|
if (!e.shiftKey && !e.altKey) {
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === 'x' || k === 'c' || k === 'v') {
|
||||||
|
if (inEditor) return;
|
||||||
|
if (!isSidebarFocused(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (k === 'x' && onCut) onCut();
|
||||||
|
else if (k === 'c' && onCopy) onCopy();
|
||||||
|
else if (k === 'v' && onPaste) onPaste();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd + W → 关闭当前打开的文件。
|
// Ctrl/Cmd + W → 关闭当前打开的文件。
|
||||||
// 与 Ctrl+R 不同:必须 preventDefault —— 否则 Electron 默认行为是直接
|
// 与 Ctrl+R 不同:必须 preventDefault —— 否则 Electron 默认行为是直接
|
||||||
// 关闭 BrowserWindow,把应用整个干掉(不是"关文件")。这也让 SUPPRESS_KEYS
|
// 关闭 BrowserWindow,把应用整个干掉(不是"关文件")。这也让 SUPPRESS_KEYS
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/* ===========================================================================
|
/* ===========================================================================
|
||||||
Notes — 样式入口(Stage 4a:分片)
|
Notes — 样式入口(Stage 4a:分片)
|
||||||
通过 @import 按顺序串联 01..12。
|
通过 @import 按顺序串联 01..12。
|
||||||
浏览器原生 CSS @import 性能成本可忽略(12 个文件并行或合并);本应用零打包。
|
|
||||||
|
P2-8(audit):index.html 已不再走这个单入口 —— 主应用直接并列 12 个
|
||||||
|
<link rel="stylesheet">(并行 fetch,级联顺序 = 文档顺序)。本文件保留给
|
||||||
|
tests/manual/*.html 手工验证页用(那里不在乎串行 fetch)。
|
||||||
|
新增分片时这里与 index.html 的 12 个 <link> 两处同步。
|
||||||
=========================================================================== */
|
=========================================================================== */
|
||||||
|
|
||||||
@import url('./styles/01-tokens.css');
|
@import url('./styles/01-tokens.css');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
- 设计令牌:间距、字号、颜色、阴影、动画曲线统一管理
|
- 设计令牌:间距、字号、颜色、阴影、动画曲线统一管理
|
||||||
- 浅色 / 深色主题:暖色调低饱和度,长时间阅读舒适
|
- 浅色 / 深色主题:暖色调低饱和度,长时间阅读舒适
|
||||||
- 排版:行高 1.85、字号 16px、优化的中英文字体栈
|
- 排版:行高 1.85、正文 17px(--reader-font-size)、优化的中英文字体栈
|
||||||
- 组件:浮动卡片、柔和阴影、克制的圆角
|
- 组件:浮动卡片、柔和阴影、克制的圆角
|
||||||
=========================================================================== */
|
=========================================================================== */
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
视觉上完全无差异(实色背景上看不出毛玻璃),但每次滚动都是几十 KB 像素的
|
视觉上完全无差异(实色背景上看不出毛玻璃),但每次滚动都是几十 KB 像素的
|
||||||
重复 GPU 工作。 */
|
重复 GPU 工作。 */
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
/* 切主题时与 body 同步淡入淡出:03-base.css body 已声明
|
||||||
|
transition: background var(--transition-slow), color var(--transition-slow);
|
||||||
|
不在 .toolbar 显式声明会让 --bg-toolbar 跳色而 body 还在过渡,
|
||||||
|
形成 ~320ms 的色块错位(顶部硬切、主区淡变)。
|
||||||
|
border-color 同理:1px 底分隔线用 --border-subtle 不加 transition 会
|
||||||
|
出现"主色在淡、这条 1px 边线硬切"的视觉跳变。background 已经在 body 那条
|
||||||
|
慢节奏下淡变,border-color 用同样的 --transition-slow 节奏保持一致。 */
|
||||||
|
transition:
|
||||||
|
background var(--transition-slow),
|
||||||
|
border-color var(--transition-slow);
|
||||||
z-index: var(--z-toolbar);
|
z-index: var(--z-toolbar);
|
||||||
position: relative;
|
position: relative;
|
||||||
/*
|
/*
|
||||||
@@ -202,18 +212,24 @@ body.has-app-icon .app-title::before {
|
|||||||
.window-controls {
|
.window-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 4px;
|
/* 左侧 4px padding + 0 右侧:让最小化按钮与上一组 toolbar-divider 之间
|
||||||
/* 让最右侧的关闭按钮贴齐视口右沿(沿用平台常见 UX),但避免裁掉 hover 区;
|
留出和 chip 一致的呼吸(原本用 margin-left:4px),但保留右侧 flush
|
||||||
之前用 -8px 时会把按钮裁进视口外 8px。改为 -2px 把贴边视觉压回视口内 */
|
视觉(贴近视口右沿,沿用 Win/Linux 平台 UX)。原来用 margin-right:-2px
|
||||||
margin-right: -2px;
|
把关闭按钮"挤"到视口边;改 padding-right:0 让容器本身在 toolbar 4px
|
||||||
|
右 padding 之内、关闭按钮不再依赖负 margin 也能落在合理边缘。 */
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-left: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
/* 用渐变分隔线替代纯色 —— 比 border 精致,符合 macOS 视觉 */
|
/* 用渐变分隔线替代纯色 —— 比 border 精致,符合 macOS 视觉 */
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.window-controls::before {
|
.window-controls::before {
|
||||||
|
/* 分隔线相对容器定位在 -4px:与 padding-left 等距,让「上一组按钮 →
|
||||||
|
divider → 窗口控制组」节奏与工具栏内其他 divider 间距一致。 */
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -8px;
|
left: -4px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -491,7 +507,7 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
|
|||||||
Toolbar Search(设计优化 2026-09:从侧栏 header 迁来)
|
Toolbar Search(设计优化 2026-09:从侧栏 header 迁来)
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
把搜索文件框从侧栏上移到底部顶栏首位,让侧栏垂直空间专注于文件列表
|
把搜索文件框从侧栏上移到底部顶栏首位,让侧栏垂直空间专注于文件列表
|
||||||
(侧栏同时把「+ 新建笔记」合并进 sort 行 —— 双重省空间)。
|
(侧栏的新建按钮已挪到右键菜单,首屏只留排序条)。
|
||||||
- flex:0 1 240px:理想宽度 240px,可缩不可涨 —— 窗口变窄时它先缩,
|
- flex:0 1 240px:理想宽度 240px,可缩不可涨 —— 窗口变窄时它先缩,
|
||||||
不挤右侧按钮组;不抢占侧栏上方视觉重量
|
不挤右侧按钮组;不抢占侧栏上方视觉重量
|
||||||
- min-width:160px:极限窄窗口下保留可用宽度(低于此值键盘用户看不清)
|
- min-width:160px:极限窄窗口下保留可用宽度(低于此值键盘用户看不清)
|
||||||
@@ -617,6 +633,35 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 极窄窗口(≤ 720px,含 600px 最小窗口):工具栏右簇(搜索 + 外观 + 视图 +
|
||||||
|
重置布局 + 自动保存 + 保存/关闭/设置 + 窗口控件)在 600px 视口下会溢出约 90px,
|
||||||
|
把最右侧的窗口控件(最小化/最大化/关闭)挤出视口 —— frameless 窗口下这是唯一
|
||||||
|
的关闭入口,被裁掉等于「关不掉窗口」。这里只收起最低优先级的控件腾出空间,
|
||||||
|
保证窗口控件始终可达:
|
||||||
|
- 隐藏「重置布局」按钮组 + 它后面的分隔线(低频操作,仍可经右键菜单/设置恢复)
|
||||||
|
- 自动保存折叠为纯图标(隐藏文字标签 + iOS 开关,保留按钮本体与 aria-pressed
|
||||||
|
状态色;完整开关仍在设置里),约省 27px
|
||||||
|
- 搜索框 min-width 进一步降到 72px(720px 断点晚于 720px 断点,这里后定义以覆盖,
|
||||||
|
同时兜住 701–719px 这段原始布局仍会轻微溢出的过渡带)
|
||||||
|
三者合计约省 90px,窗口控件在 600px 最小宽度下不再被裁。 */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#tg-reset-layout,
|
||||||
|
#tg-reset-layout + .toolbar-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#toolbar-autosave .btn-label,
|
||||||
|
#toolbar-autosave .toggle-switch {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#toolbar-autosave {
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
.toolbar-search {
|
||||||
|
flex: 0 1 120px;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
App Shell
|
App Shell
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
/* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */
|
/* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* container query 锚点:让 .sidebar 内部元素能根据侧栏实际宽度自适应。
|
/* container query 锚点:让 .sidebar 内部元素能根据侧栏实际宽度自适应。
|
||||||
用途:当侧栏拖到 ~240px 以下时,「新建笔记」内联按钮的"新建笔记"文字
|
下方可用 @container sidebar (max-width: ...) 写响应式规则,
|
||||||
自动隐藏、只保留 + 图标,给 sort 按钮(「名称」「修改时间」)腾空间。
|
跟随 .sidebar 实际渲染宽度,跟主窗口大小解耦。
|
||||||
230px 是经验阈值:sort 文字 + icon + 内边距刚好能完整显示的临界点。
|
命名空间 "sidebar" 与配套 @container 一致,避免与未来其他容器冲突 */
|
||||||
命名空间 "sidebar" 与下面 @container 配套,避免与未来其他容器冲突 */
|
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
container-name: sidebar;
|
container-name: sidebar;
|
||||||
}
|
}
|
||||||
@@ -31,10 +30,10 @@
|
|||||||
.sidebar-sort 同一行的右端(用 .sidebar-new-btn--inline + margin-left:auto),
|
.sidebar-sort 同一行的右端(用 .sidebar-new-btn--inline + margin-left:auto),
|
||||||
节省侧栏首屏的垂直空间。原独立行的顶部呼吸转移到了 .sidebar-sort 的 padding-top) */
|
节省侧栏首屏的垂直空间。原独立行的顶部呼吸转移到了 .sidebar-sort 的 padding-top) */
|
||||||
|
|
||||||
/* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮
|
/* 主操作按钮:全宽、带文字(accent 文字 + 软底色 + 1px accent 边框的主操作语义)。
|
||||||
——accent 文字 + 微微的 accent 底色 + 1px accent 边框,hover/active 进一步加深。
|
新建文件 / 新建文件夹挪到右键菜单后,这个 base 类现在只被侧栏底部 AI 按钮(#btn-ai)复用。
|
||||||
2026-08 把字号从 12px 提到 14px、字重 500 → 600、加 8% accent 底色 + 32% accent 边框,
|
2026-08 把字号从 12px 提到 14px、字重 500 → 600、加 8% accent 底色 + 32% accent 边框,
|
||||||
解决「新建笔记」字看着发淡、按钮太瘦的问题 */
|
解决按钮文字发淡、按钮太瘦的问题 */
|
||||||
.sidebar-new-btn {
|
.sidebar-new-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -76,113 +75,65 @@
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 「新建笔记」按钮的紧凑内联变体(.sidebar-new-btn--inline):
|
/* (.sidebar-new-btn--inline 已删除 —— 2026-09 设计优化:新建文件 / 新建文件夹
|
||||||
与 .sidebar-sort-btn 并排在 .sidebar-sort 行内,靠 margin-left:auto 推到右端。
|
从侧栏「+」按钮挪到右键菜单(侧栏空白右键、文件夹条目右键),内联变体
|
||||||
设计目标:
|
.sidebar-new-btn--inline 及其窄侧栏 container query 规则随之移除。base 类
|
||||||
1) 与 sort 按钮同高(覆盖 base 的固定 height:34px → 自适应高度),
|
.sidebar-new-btn 仍被侧栏底部 AI 按钮(#btn-ai)复用) */
|
||||||
否则 34px 的新建笔记会撑爆整行、视觉上"跳"出来,盖过 sort 按钮;
|
|
||||||
2) 宽度按内容(flex:0 0 auto),不抢 sort 按钮的弹性空间;
|
|
||||||
3) icon 同步缩到 13px(base 是 14px),与 sort 按钮视觉重量一致;
|
|
||||||
4) 保留 accent 文字 + 软底色 + 1px accent 边框的主操作语义不变。
|
|
||||||
—— base 类继续被侧栏底部 AI 按钮(#btn-ai)复用,仍是 34px 全宽语义。 */
|
|
||||||
.sidebar-new-btn--inline {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-left: auto;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
/* 5/6 padding 与 sort 按钮对齐:整行按钮横向 padding 一致,视觉上"一排",
|
|
||||||
而不是 sort 紧、新建笔记松。同节奏点击区也更整齐 */
|
|
||||||
padding: 5px 6px;
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.sidebar-new-btn--inline svg {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
}
|
|
||||||
/* 内联态下取消 base 的 scale 反馈 —— 整行按钮高度统一,按下时整行一起缩更协调;
|
|
||||||
若只缩新建笔记,按下瞬间会显得"错位跳出",与同行 sort 按钮的 active 态不齐 */
|
|
||||||
.sidebar-new-btn--inline:active {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 窄侧栏适配:当侧栏 ≤260px 时,「新建笔记」文字隐藏,只显 + 图标,
|
|
||||||
给 sort 按钮腾出 ~50px 空间,「修改时间」能完整显示。
|
|
||||||
container query(而不是 @media viewport):跟随 .sidebar 实际渲染宽度,
|
|
||||||
跟主窗口大小解耦。@supports 检测兼容性:旧 Chromium 不支持 container
|
|
||||||
query 会跳过规则,回退到全文字(最差也是 ellipsis,不算破图)
|
|
||||||
阈值 260px 是经验值:sort 「修改时间」4 字 + 新建笔记 4 字同时显示需要
|
|
||||||
~250px+ 侧栏宽度(实测 240px 时 sort 仍被截断),取 260px 给点余量 */
|
|
||||||
@supports (container-type: inline-size) {
|
|
||||||
@container sidebar (max-width: 260px) {
|
|
||||||
.sidebar-new-btn--inline span {
|
|
||||||
/* 视觉上隐藏文字,但保留 a11y / 屏幕阅读器朗读「新建笔记」 */
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.sidebar-new-btn--inline {
|
|
||||||
/* 只显图标时,按钮收紧到 icon + 少量水平内边距即可 */
|
|
||||||
padding: 5px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* (search-wrap / search-icon / search-input 已移到 04-toolbar.css —— 2026-09
|
/* (search-wrap / search-icon / search-input 已移到 04-toolbar.css —— 2026-09
|
||||||
设计优化:搜索框从侧栏上移到工具栏,样式跟随 HTML 一起走) */
|
设计优化:搜索框从侧栏上移到工具栏,样式跟随 HTML 一起走) */
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Sidebar 排序工具条 + 新建笔记(合并到一行)
|
Sidebar 排序工具条
|
||||||
紧贴它作用的 .file-list 上方 —— 这是「列表的工具」,
|
紧贴它作用的 .file-list 上方 —— 这是「列表的工具」,
|
||||||
不应该埋进三层深的设置对话框(见 [[feedback-prefer-near-affected-area]])。
|
不应该埋进三层深的设置对话框(见 [[feedback-prefer-near-affected-area]])。
|
||||||
设计目标:紧凑到不抢搜索框注意力,active 态明确但不过重。
|
设计目标:紧凑到不抢搜索框注意力,active 态明确但不过重。
|
||||||
2026-09 优化:「新建笔记」从独立行合并到右端,
|
新建文件 / 新建文件夹已挪到右键菜单,侧栏首行只剩排序工具条。
|
||||||
margin-left:auto 把新建笔记推到行尾,与左侧 sort 按钮形成「列表的工具 vs 主操作」的层次。
|
|
||||||
gap 同步从 2px → 4px:插入「新建笔记」后与 sort 按钮之间需要稍宽间距,避免视觉粘连。
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.sidebar-sort {
|
.sidebar-sort {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* gap 2px:sort 按钮之间 2px(连成一组 —— 都是"列表的工具"),
|
/* gap:sort 触发器与未来同组控件之间的间距("列表的工具"成组) */
|
||||||
margin-left:auto 把"新建笔记"推到最后,自然形成「sort 组」与「主操作」的分组 */
|
gap: 4px;
|
||||||
gap: 2px;
|
|
||||||
/* 顶 8px / 底 8px:sort 是侧栏首屏第一行,工具栏底边已经有 ~8px breathing,
|
/* 顶 8px / 底 8px:sort 是侧栏首屏第一行,工具栏底边已经有 ~8px breathing,
|
||||||
这里再给 8px 顶距足够拉开层次(10px → 8px 省 2px 给文件列表);
|
这里再给 8px 顶距足够拉开层次(10px → 8px 省 2px 给文件列表);
|
||||||
上下对称让 sort 行居中感更强,border-bottom 自然分隔 */
|
上下对称让 sort 行居中感更强,border-bottom 自然分隔 */
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
/* 切主题时 border-subtle 颜色变化需要 fade —— 否则 1px 分隔线在 body
|
||||||
|
320ms 慢淡变过程中硬切。背景是 transparent,没 background 跳变可淡 */
|
||||||
|
transition: border-color var(--transition-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-sort-btn {
|
/* Sort 下拉容器:
|
||||||
|
2026-10 设计改造 —— 把原来并排两个 sort 按钮(.sidebar-sort-btn)换成下拉:
|
||||||
|
- 一个触发器占 sort 行左段,按内容宽度(flex:0 0 auto),不抢其它控件空间
|
||||||
|
- 菜单 absolute 定位在触发器下方,absolute 让它能浮在文件列表之上不受
|
||||||
|
flex 行高度限制,也不挤压下方列表布局
|
||||||
|
drop-shadow 比纯 border 更柔和:菜单浮起感强,与触发器同源色调一致
|
||||||
|
*/
|
||||||
|
.sidebar-sort-dropdown {
|
||||||
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
/* 横向 padding 8px → 6px:sort 标签"修改时间" 4 字 + icon 在默认 220px 侧栏下
|
width: 100%;
|
||||||
需要 ~83px,原 5/8 padding + 4px row gap 把每个 sort 按钮挤到 80px,
|
min-width: 0;
|
||||||
「修改时间」就被截断成「修改时」。收到 6px 后单 sort 按钮宽度降到 76px,
|
|
||||||
省下来的 4×2=8px 让 sort 行整体宽松,可完整显示 4 字标签 */
|
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
/* 2026-08 把字号从 12px 提到 13px(--fs-sm),与文件 meta / 工具栏按钮文字同档,
|
|
||||||
解决排序按钮在 13px 字号下字看着小、发淡的问题。字重 500 → 600 让文字更"实" */
|
|
||||||
color: var(--fg-secondary);
|
color: var(--fg-secondary);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
/* 防止「修改时间」等 4 字标签在 180px 窄侧栏里换行成两行(高度塌到 46px,
|
/* 防止长 label「按修改时间→旧」在窄侧栏里换行塌成两行 */
|
||||||
挤压下方文件列表)。即使不够也用省略号截断,保持一行紧凑 */
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -192,31 +143,180 @@
|
|||||||
border-color var(--transition-fast);
|
border-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-sort-btn:hover {
|
/* 触发器打开态:accent-soft 底色 + accent 边框让「选中的排序」在行内就有
|
||||||
color: var(--fg-secondary);
|
视觉反馈,不必打开菜单也能看出当前排序 */
|
||||||
background: var(--bg-hover);
|
.sidebar-sort-trigger[aria-expanded="true"],
|
||||||
}
|
.sidebar-sort-trigger.is-active {
|
||||||
|
|
||||||
.sidebar-sort-btn.is-active {
|
|
||||||
/* active 选中态:文字降彩度(--accent-text)。"名称"/"修改时间"/"创建时间"
|
|
||||||
三个 sort 按钮并排时,纯 accent 文字让三者同时"跳出来",观感刺眼;
|
|
||||||
改用文字版后,accent-soft 底色仍明确选中,但字不再"贴脸"。 */
|
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-sort-btn.is-active:hover {
|
.sidebar-sort-trigger:hover {
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-trigger[aria-expanded="true"]:hover,
|
||||||
|
.sidebar-sort-trigger.is-active:hover {
|
||||||
|
/* accent-soft + bg-hover 18% 混合:暗色调色板上 hover 不冲掉选中底色 */
|
||||||
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
|
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-sort-btn:focus-visible {
|
.sidebar-sort-trigger:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-sort-btn svg {
|
/* chevron 在打开时旋转 180°,暗示菜单已展开。
|
||||||
|
旋转而非切图标 —— 单一 SVG,CSS transform 更顺。
|
||||||
|
hover 提亮到 1.0:与 .status-chip-mode:hover svg 同源(默认 0.7 → 满透明),
|
||||||
|
鼠标停上时给 chevron 一抹"亮起来"的反馈,配合 trigger 底色变化更协调 */
|
||||||
|
.sidebar-sort-trigger-chevron {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition:
|
||||||
|
transform var(--transition-fast),
|
||||||
|
opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.sidebar-sort-trigger[aria-expanded="true"] .sidebar-sort-trigger-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.sidebar-sort-trigger:hover .sidebar-sort-trigger-chevron {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* trigger label 同样吃掉多余宽度,触发器 overflow:hidden 时它先被截。
|
||||||
|
flex:1 1 auto + min-width:0 是 flex 子项允许收缩到 content 以下的标配 —— 不加 min-width:0,
|
||||||
|
label 默认 min-content 宽度(最长不可断行片段),触发器 overflow:hidden 时它不会缩,
|
||||||
|
而是溢出 trigger 边缘把 chevron 顶出去。ellipsis 也得在 label 自己被约束时才生效。 */
|
||||||
|
.sidebar-sort-trigger-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort 菜单:
|
||||||
|
- absolute 在触发器下方 left:0 让左对齐(与触发器同宽),top:calc(100% + 4px) 留 4px gap
|
||||||
|
- min-width:100% 而不是 width:100%:菜单可超出触发器宽度,长 label 「按修改时间」不挤
|
||||||
|
- padding:4px 让菜单项四周呼吸,menu 自身 padding 不参与 grid 列表
|
||||||
|
bg-elevated + drop-shadow + radius-sm:浮起卡片感,区别于触发器内联工具条 */
|
||||||
|
.sidebar-sort-menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
/* min-width 让菜单始终覆盖触发器宽度,长 label(如「按修改时间」)不挤压;
|
||||||
|
长 label 单独成项时菜单按最长一项自然变宽 */
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
/* var(--shadow-md) 与 .focus-exit-hint 同源(4px 12px + 2px 4px 投影组合),
|
||||||
|
浮层用它来给菜单"轻浮起"层次。原来的硬编码 0.18/0.08 透明度是 dark-only 的
|
||||||
|
调参值,亮主题下显得偏重;用 token 后亮 / 暗两套主题的阴影会自动套用
|
||||||
|
02-palettes.css 已校准好的 0.07-0.32 区间,色彩系统内自洽 */
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 50;
|
||||||
|
/* 淡入比瞬切更柔和;50ms 极短避免拖慢菜单 */
|
||||||
|
animation: sidebar-sort-menu-in 80ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sidebar-sort-menu-in {
|
||||||
|
from { opacity: 0; transform: translateY(-2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-menu-item:hover,
|
||||||
|
.sidebar-sort-menu-item:focus-visible {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--fg-primary);
|
||||||
|
}
|
||||||
|
/* 键盘 focus 需要可见环 —— menuitem 默认 outline:none(tabindex="-1" 的 li
|
||||||
|
浏览器不再画默认环),用 :focus-visible 而非 :focus:点击菜单项瞬间
|
||||||
|
focus 会落到它身上,若用 :focus 也会触发"高亮",给视觉多余干扰。
|
||||||
|
负 offset 让环贴 item 内沿,与 trigger focus-visible 的 +1px 外环形成
|
||||||
|
"内 vs 外"层次(trigger 是浮起控件,环外贴;menu item 已有 padding 4px
|
||||||
|
的 ul 容器,环内贴不会跟外沿视觉挤在一起) */
|
||||||
|
.sidebar-sort-menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-menu-item.is-active {
|
||||||
|
/* 与触发器一致:accent-text + accent-soft;选中态双处呼应(触发器 + 菜单项) */
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-menu-item.is-active:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中态左侧打勾;占位用 inline-block 让未选中项也对齐预留 13px + 8px gap。
|
||||||
|
比 flex 子项条件 display 更稳 —— 不参与排序语义,避免影响 item 高度 */
|
||||||
|
.sidebar-sort-menu-check {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.sidebar-sort-menu-item.is-active .sidebar-sort-menu-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-menu-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sort-menu-hint {
|
||||||
|
/* hint 用 fg-muted + 小字号,与主 label 拉开对比;不抢主 label 视觉重量 */
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单分组分隔:1px 水平细线 + 上下各 4px 留白。
|
||||||
|
margin 而不是 padding 让 flex 容器布局不挤压 —— padding 会撑大 li 高度,
|
||||||
|
上下 4px margin 让「组间距」发生在 li 边缘,与菜单项 5px padding 对称。 */
|
||||||
|
.sidebar-sort-menu-separator {
|
||||||
|
list-style: none;
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 6px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
@@ -231,6 +331,25 @@
|
|||||||
gap: 1px;
|
gap: 1px;
|
||||||
/* 滚动条预留空间 —— 让滚动条 hover 才出现,不抢视觉 */
|
/* 滚动条预留空间 —— 让滚动条 hover 才出现,不抢视觉 */
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
/* audit fix (R7a P1-15):windowed render 需要把 .file-item 用
|
||||||
|
position:absolute + top:N*h 钉在指定位置,所以容器得 position:relative
|
||||||
|
作为锚点。flex + position:relative 不冲突(容器本身仍是 flex 布局,
|
||||||
|
决定 sentinel / 非 windowed 模式项的位置)。 */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* audit fix (R7a P1-15):windowed 虚拟滚动 sentinel。
|
||||||
|
- 真实高度 = N * itemHeight,给滚动条撑出总高
|
||||||
|
- 默认 flex 项目,不抢视觉(aria-hidden + 0 padding)
|
||||||
|
- JS 切到 windowed 模式时 appendChild 它;切回 full / 空 状态时 remove
|
||||||
|
用 <li> 而非 <div> 是因为 <ul> 只能装 <li> 子元素,HTML 规范硬约束 */
|
||||||
|
.file-list-sentinel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
/* 高度由 JS 直接 style.height 设置;这里只保证不引入意外 padding */
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list:empty::after {
|
.file-list:empty::after {
|
||||||
@@ -245,7 +364,7 @@
|
|||||||
color: var(--fg-tertiary);
|
color: var(--fg-tertiary);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
line-height: var(--lh-relaxed);
|
line-height: var(--lh-relaxed);
|
||||||
/* 留点上方间距,与上方 sort/新建笔记行拉开 */
|
/* 留点上方间距,与上方 sort 行拉开 */
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
/* 避免一行字过长在窄侧栏里截断为半个字 */
|
/* 避免一行字过长在窄侧栏里截断为半个字 */
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -260,6 +379,11 @@
|
|||||||
grid-template-columns: 16px 1fr;
|
grid-template-columns: 16px 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
|
/* audit fix (R7a P1-15):windowed 虚拟滚动需要固定高度做索引 × h 的
|
||||||
|
位置数学。padding 仍由 box-sizing:border-box 包含在 height 内。
|
||||||
|
49px (12 padding + 37 content) → 显式 50px,差 1px 由 align-items:center
|
||||||
|
吸收,视觉无变化但 JS 可以用 N * 50 直接算 top/总高。 */
|
||||||
|
height: 50px;
|
||||||
/* 上下 padding 9px → 6px:原 9px 在 13px name + 13px meta 两行下让单条 file-item
|
/* 上下 padding 9px → 6px:原 9px 在 13px name + 13px meta 两行下让单条 file-item
|
||||||
撑到 ~60px,50 个文件就要 3000px 滚动,太松。压到 6px 后 ~50px/item,
|
撑到 ~60px,50 个文件就要 3000px 滚动,太松。压到 6px 后 ~50px/item,
|
||||||
列表更紧凑、可见项更多,仍保留点击区域与 hover 反馈呼吸 */
|
列表更紧凑、可见项更多,仍保留点击区域与 hover 反馈呼吸 */
|
||||||
@@ -337,8 +461,18 @@
|
|||||||
纯色 accent 在 #2a325c 类 accent-soft 背景下,RGB 距离很短,
|
纯色 accent 在 #2a325c 类 accent-soft 背景下,RGB 距离很短,
|
||||||
加 4px 投影能把色条对比拉高一档 */
|
加 4px 投影能把色条对比拉高一档 */
|
||||||
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 40%, transparent);
|
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
/* 选中态色条从左侧滑入:切换当前文件时给一点"落位"的过渡,
|
||||||
|
比硬切更有手感,又不拖沓(200ms)。reduced-motion 下被全局 .01ms 规则收掉 */
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: file-active-bar 200ms var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes file-active-bar {
|
||||||
|
from { transform: translateY(-50%) scaleX(0); opacity: 0; }
|
||||||
|
to { transform: translateY(-50%) scaleX(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
color: var(--fg-primary);
|
color: var(--fg-primary);
|
||||||
@@ -404,6 +538,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
/* 与 .sidebar-sort 同源:footer 顶 1px 分隔线在切主题时也需 fade,
|
||||||
|
避免与 main 320ms 慢淡变错位 */
|
||||||
|
transition: border-color var(--transition-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -434,6 +571,38 @@
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
阅读进度条:viewer 顶部的 3px 细线,随滚动从左向右填充。
|
||||||
|
- sticky top:0:作为 .viewer 滚动容器的首个子元素,滚动时钉在顶部,
|
||||||
|
正文从它下方滑过(markdown-body 顶部有 48px padding,细线落在留白里不压字)。
|
||||||
|
- pointer-events:none:纯视觉反馈,不挡滚动 / 点击。
|
||||||
|
- 底色透明,只有填充段可见;90ms linear 跟随滚动手感顺滑。
|
||||||
|
- 宽度由 JS(updateStatusScroll)按 scrollTop / (scrollHeight-client) 写入。
|
||||||
|
============================================================ */
|
||||||
|
.reading-progress {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
/* 渐变比纯 accent 多一档层次,末端圆角呼应 file-item 色条 */
|
||||||
|
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
/* 极轻辉光,让细线在深色底上"浮"起来一点,但不喧宾夺主 */
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 50%, transparent);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: width 90ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
/* 空状态:更有温度的视觉 */
|
/* 空状态:更有温度的视觉 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -538,9 +707,14 @@
|
|||||||
text-wrap: pretty;
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 二级提示:用 tertiary + 略小字号,与主 hint 拉开视觉权重 */
|
/* 二级提示:用 secondary + 略小字号,与主 hint 拉开视觉权重
|
||||||
|
P2-26:tertiary → secondary。dark palette 下
|
||||||
|
--fg-tertiary #8e95a1 vs --fg-secondary #b0b6c0
|
||||||
|
后者在 #08090a 底色上对比度 ~7.5:1(前者 ~5.0:1,边缘档)。
|
||||||
|
改 secondary 满足 WCAG AAA (7:1+),且不破坏主 hint(用 tertiary 仍
|
||||||
|
区分"主 → 次"权重)。其他 4 个 palette 同理。 */
|
||||||
.empty-state .hint.hint-secondary {
|
.empty-state .hint.hint-secondary {
|
||||||
color: var(--fg-tertiary);
|
color: var(--fg-secondary);
|
||||||
font-size: var(--fs-xs-readable);
|
font-size: var(--fs-xs-readable);
|
||||||
/* margin-top 6 → 10px:原 6px 让主 hint 与次 hint 之间几乎贴住,
|
/* margin-top 6 → 10px:原 6px 让主 hint 与次 hint 之间几乎贴住,
|
||||||
加上两行 lh-relaxed (1.7) 后视觉上更挤。提到 10px 让两组 hint
|
加上两行 lh-relaxed (1.7) 后视觉上更挤。提到 10px 让两组 hint
|
||||||
@@ -551,6 +725,62 @@
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空状态 CTA ——「+ 新建笔记」按钮(audit P2-25)
|
||||||
|
与 .sidebar-new-btn 共享 accent 配色语义但不走 width:100%:
|
||||||
|
空状态是居中布局,紧凑 auto-width 让视觉重心聚焦在按钮上,
|
||||||
|
不会撑满整行让空白左右显得"摊"。
|
||||||
|
|
||||||
|
设计选择:
|
||||||
|
- 颜色用 --accent + --btn-on-accent(与 .btn-primary 一致)—— 走主按钮语义,
|
||||||
|
让"下一步该做什么"的指令最强
|
||||||
|
- padding 9px 16px:比 .btn (12px 20px) 收紧一点,与 .sidebar-new-btn 视觉重量一致
|
||||||
|
- hover 不上 transform scale —— 空状态按钮是用户在"看到指引后第一次行动",
|
||||||
|
缩放会让人犹豫到底点没点中;改用 box-shadow 上抬作"立起来"反馈
|
||||||
|
- focus-visible 沿用全局 .btn 的 2px accent outline(见 08-modal.css),
|
||||||
|
这里只声明 offset 让焦点环在按钮内贴边,与全局外 2px 视觉一致 */
|
||||||
|
.empty-state-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
/* 9px 16px → 10px 18px:与下方 hint secondary 拉开一档垂直间距的同时,
|
||||||
|
比 .sidebar-new-btn (34px 高) 略紧凑 —— 空状态是「轻量引导」,
|
||||||
|
不需要 34px 那么"正式"的按钮高度 */
|
||||||
|
padding: 10px 18px;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--btn-on-accent, #ffffff);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
cursor: pointer;
|
||||||
|
/* hover 时阴影抬升比 scale 更克制;同时阴影从 accent-soft 升级到
|
||||||
|
accent-soft-hover,给"按钮立起来"的微动效暗示 */
|
||||||
|
box-shadow: 0 2px 6px var(--accent-soft);
|
||||||
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-cta:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: 0 4px 12px var(--accent-soft-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-cta:active {
|
||||||
|
/* 0.97 比其它按钮的 0.96 略小 —— 空状态 CTA 是"重决策",按下时
|
||||||
|
反馈更明显一点,避免用户怀疑"按了没生效" */
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-cta:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state .hint kbd {
|
.empty-state .hint kbd {
|
||||||
/* .md 这种代码 chip 用 kbd 标签更语义化 */
|
/* .md 这种代码 chip 用 kbd 标签更语义化 */
|
||||||
/* padding 2/7 → 1/6:在 12px 字号下原 2/7 让 kbd chip 显得"鼓鼓的",
|
/* padding 2/7 → 1/6:在 12px 字号下原 2/7 让 kbd chip 显得"鼓鼓的",
|
||||||
@@ -848,3 +1078,71 @@ body[data-editor-mode="split"] .splitter {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Folder Browser:拖拽 + 剪贴板视觉反馈
|
||||||
|
- .file-item 默认 draggable=true(文件 / 文件夹 / 二进制都可拖)
|
||||||
|
- 拖拽中源项 .is-dragging 半透明;合法投放目标 folder .is-drop-target 给
|
||||||
|
accent 虚线描边 + 软底高亮;剪切到剪贴板的源项 .is-clipboard-cut 用
|
||||||
|
虚线 + 弱化提示「将被移走」。
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.file-item[draggable="true"] {
|
||||||
|
/* 默认已是 cursor:pointer;拖拽态单独给 grabbing 反馈 */
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.is-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.is-drop-target {
|
||||||
|
/* 合法投放目标:accent 虚线描边 + 软底,明显区别于 hover 态 */
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.is-clipboard-cut {
|
||||||
|
/* 已剪切待移动的源项:弱化 + 虚线边框,暗示「即将离开此处」 */
|
||||||
|
opacity: 0.6;
|
||||||
|
outline: 1px dashed var(--border-default);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* audit R7a P1-20:分页「加载更多」按钮
|
||||||
|
视觉上是一个普通 li 但居中、有虚线分隔;hover 时背景色加深 + cursor:pointer。
|
||||||
|
跟普通 .file-item 共享 padding / font 但不要 icon,简洁一些。 */
|
||||||
|
.file-list-load-more {
|
||||||
|
list-style: none;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 4px 8px;
|
||||||
|
border: 1px dashed var(--border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.file-list-load-more:hover,
|
||||||
|
.file-list-load-more:focus-visible {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--fg-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* audit R7a P1-15:windowed 虚拟滚动 —— JS 切到该模式时给 .file-item 加
|
||||||
|
.is-windowed 类,position:absolute 让 N*50 位置数学生效,left/right
|
||||||
|
撑满容器,padding 仍然在 .file-item 上(box-sizing:border-box,
|
||||||
|
height:50px 已含 padding)。
|
||||||
|
top 由 JS 直接 style.top = N*50+'px' 设置,不写进 CSS(每个 item 不同)。
|
||||||
|
切回 full 模式时 JS 移除 .is-windowed 与 style.top,flex 布局自然复位。 */
|
||||||
|
.file-item.is-windowed {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px; /* 抵消 .file-list 的 padding-left 10px */
|
||||||
|
right: 10px; /* 抵消 .file-list 的 padding-right 10px */
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
scroll-margin-top: var(--space-5);
|
scroll-margin-top: var(--space-5);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
/* 多行标题均匀分布,避免末行孤字;仅优化换行分布,不改量 */
|
||||||
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body h1 {
|
.markdown-body h1 {
|
||||||
@@ -78,8 +80,16 @@
|
|||||||
|
|
||||||
.markdown-body p {
|
.markdown-body p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: justify;
|
/* 行高 / 长串断行已由 05-sidebar.css 的 .markdown-body 基础规则统一处理
|
||||||
|
(line-height: var(--reader-line-height)、overflow-wrap: break-word),
|
||||||
|
这里不再重复。 */
|
||||||
|
/* justify + CJK/emoji 在窄栏下会出现"两端对齐、字间距爆炸";改回 start
|
||||||
|
后长行左对齐、短行自然断行,整体节奏更稳。hyphens 保留:英文单词在
|
||||||
|
窄 viewport 下仍可被连字符断开 */
|
||||||
|
text-align: start;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
|
/* 避免段落末行只剩一个孤字/孤标点,提升阅读收尾的舒适感 */
|
||||||
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body a {
|
.markdown-body a {
|
||||||
@@ -210,6 +220,42 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 代码块复制按钮:浮动 FAB,悬浮代码块时贴到右上角外缘浮出。
|
||||||
|
刻意不进 <pre> 子树(见 MarkdownViewer._setupContentInteractions),
|
||||||
|
以免破坏 loadContentLive 的 isEqualNode 前缀/后缀 diff。
|
||||||
|
hidden 属性负责显隐(同时移出 tab 顺序 + 可访问性正确)。 */
|
||||||
|
.code-copy-fab {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.code-copy-fab:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.code-copy-fab:active { transform: scale(0.94); }
|
||||||
|
.code-copy-fab:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.code-copy-fab.is-copied {
|
||||||
|
color: var(--success);
|
||||||
|
border-color: color-mix(in srgb, var(--success) 40%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* 水平分割线 */
|
/* 水平分割线 */
|
||||||
.markdown-body hr {
|
.markdown-body hr {
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -24,6 +24,13 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
/* audit fix (P2):与 toolbar 同步去掉 backdrop-filter —— 实色背景上看不出
|
/* audit fix (P2):与 toolbar 同步去掉 backdrop-filter —— 实色背景上看不出
|
||||||
毛玻璃效果,但每帧重算模糊成本真实存在(1200×24 px × 60 fps = 大量 GPU)。 */
|
毛玻璃效果,但每帧重算模糊成本真实存在(1200×24 px × 60 fps = 大量 GPU)。 */
|
||||||
|
/* 切主题时与 body 同步淡入淡出:与 .toolbar 同源问题(见 04-toolbar.css
|
||||||
|
.toolbar { transition: background var(--transition-slow) } 注释),
|
||||||
|
不声明会让 --bg-toolbar 跳色而 body 还在过渡,形成 ~320ms 色块错位。
|
||||||
|
border-color 同理:1px 顶分隔线在切主题时硬切会跟主区淡变错位。 */
|
||||||
|
transition:
|
||||||
|
background var(--transition-slow),
|
||||||
|
border-color var(--transition-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 三段式状态栏:左段(弱化路径)/ 中段(核心字数)/ 右段(chip 簇) */
|
/* 三段式状态栏:左段(弱化路径)/ 中段(核心字数)/ 右段(chip 簇) */
|
||||||
@@ -108,9 +115,10 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
/* 圆角胶囊状的点状分隔符,比纯色线条更精致 */
|
/* 1px 宽 + 10px 高的纯竖线:border-radius 1px 在这种尺寸下既看不出"胶囊"
|
||||||
|
也"做隐形",留着反而像漏改。改 0 让分隔线回归"实线"语义。 */
|
||||||
background: var(--border-default);
|
background: var(--border-default);
|
||||||
border-radius: 1px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-meta {
|
.status-meta {
|
||||||
@@ -271,6 +279,16 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 章节切换时平滑淡入(随滚动触发,由 app.js 重触发动画)。
|
||||||
|
reduced-motion 下被 10-a11y.css 的 .01ms 规则收掉,不残留动画。 */
|
||||||
|
.status-heading.is-updating {
|
||||||
|
animation: status-heading-fade 240ms var(--ease-out);
|
||||||
|
}
|
||||||
|
@keyframes status-heading-fade {
|
||||||
|
from { opacity: 0; transform: translateY(3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.status-chip-muted {
|
.status-chip-muted {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--fg-tertiary);
|
color: var(--fg-tertiary);
|
||||||
@@ -425,8 +443,11 @@
|
|||||||
animation: toast-in 280ms var(--ease-out);
|
animation: toast-in 280ms var(--ease-out);
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
-webkit-backdrop-filter: blur(20px);
|
/* audit fix (P3):与 chrome 类保持 backdrop-filter 策略一致 —— toolbar /
|
||||||
backdrop-filter: blur(20px);
|
statusbar 已因「实色上看不出毛玻璃、但 GPU 每帧重算成本真实」摘掉。
|
||||||
|
toast 用 var(--bg-elevated) 实色背景,去掉 blur 后视觉几乎无差
|
||||||
|
(短时浮现 + 不透明底色遮住一切),却避免每帧重算这块接近 viewport
|
||||||
|
1/3 宽的模糊覆盖区。 */
|
||||||
/* 可选 icon:用 grid 而不是 inline-flex 是为了 icon 缺失时布局也稳定 */
|
/* 可选 icon:用 grid 而不是 inline-flex 是为了 icon 缺失时布局也稳定 */
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--scrim);
|
background: var(--scrim);
|
||||||
-webkit-backdrop-filter: blur(4px);
|
/* audit P3 一致性:去掉 backdrop-filter blur(4) —— 与 toolbar / statusbar /
|
||||||
backdrop-filter: blur(4px);
|
toast / context-menu 同源:实色 --scrim 看不出毛玻璃,每帧重算 4px 模糊
|
||||||
|
区域的 GPU 成本却是真实的。modal 内是 bg-elevated 实色卡片,背景 scrim
|
||||||
|
本就被压成纯黑,再糊一次无视觉差异。 */
|
||||||
z-index: var(--z-modal);
|
z-index: var(--z-modal);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -183,8 +185,9 @@
|
|||||||
/* 文件路径 / 配置 id 等"代码类"内容才用等宽字体 */
|
/* 文件路径 / 配置 id 等"代码类"内容才用等宽字体 */
|
||||||
.form-input-mono {
|
.form-input-mono {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
/* 13 → 12.5:mono 字符本身比 sans 宽,略缩小避免路径溢出按钮 */
|
/* 13 → 12:mono 字符比 sans 宽,用标准令牌 --fs-xs-readable(12px) 略缩小避免路径溢出按钮,
|
||||||
font-size: 12.5px;
|
同时对齐设计令牌体系(不再用非标 12.5px) */
|
||||||
|
font-size: var(--fs-xs-readable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* M-series fix (audit Phase M-CSS-11):AI 系统提示词框等 textarea 之前
|
/* M-series fix (audit Phase M-CSS-11):AI 系统提示词框等 textarea 之前
|
||||||
@@ -193,7 +196,7 @@
|
|||||||
JSON / 模板)+ 最小 4 行高度,避免双击 textarea 后立刻只有 1 行。 */
|
JSON / 模板)+ 最小 4 行高度,避免双击 textarea 后立刻只有 1 行。 */
|
||||||
.form-input.form-textarea {
|
.form-input.form-textarea {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12.5px;
|
font-size: var(--fs-xs-readable);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: calc(1.5em * 4 + 20px); /* 4 行 + 上下 padding */
|
min-height: calc(1.5em * 4 + 20px); /* 4 行 + 上下 padding */
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -230,7 +233,7 @@
|
|||||||
}
|
}
|
||||||
.form-error {
|
.form-error {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--fs-xs-readable);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
@@ -501,7 +504,7 @@
|
|||||||
|
|
||||||
.settings-callout-text code {
|
.settings-callout-text code {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11.5px;
|
font-size: var(--fs-xs-readable);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -557,8 +560,8 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
/* 11 → 12:与外层 subtitle 一致,避免 code 反而更小 */
|
/* 12 = --fs-xs-readable:与外层 subtitle 一致,避免 code 反而更小 */
|
||||||
font-size: 12px;
|
font-size: var(--fs-xs-readable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 关于段:开发者信息 + 主页链接 —— 不可改,只展示。 */
|
/* 关于段:开发者信息 + 主页链接 —— 不可改,只展示。 */
|
||||||
@@ -593,8 +596,7 @@
|
|||||||
.about-link {
|
.about-link {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
/* URL 一般偏长,13px 即可;与 .form-input-mono 同字号 */
|
font-size: var(--fs-xs-readable);
|
||||||
font-size: 12.5px;
|
|
||||||
/* 长 URL 默认会在空格 / 标点断行;强制按字符断,避免撑出右侧 padding */
|
/* 长 URL 默认会在空格 / 标点断行;强制按字符断,避免撑出右侧 padding */
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -942,9 +944,10 @@
|
|||||||
|
|
||||||
.aiprovider-card {
|
.aiprovider-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* icon 24px + body 1fr:去掉旧版的 `auto` 第三列(.aiprovider-protocol),
|
/* icon 列用 auto 而非固定 24px:wordmark "Claude"(约 50px 宽)放进 24px 轨道
|
||||||
那列重复了 hint 文本里的协议路径,且把 body 挤窄。 */
|
会被裁切;auto 让轨道按药丸实际内容宽度撑开,同时 body(1fr) 仍占满剩余宽度。
|
||||||
grid-template-columns: 24px 1fr;
|
去掉旧版第三列 .aiprovider-protocol(重复 hint 协议路径)。 */
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
/* 沿用 .picker-card 通用 padding 即可;AI 卡片是横向布局,
|
/* 沿用 .picker-card 通用 padding 即可;AI 卡片是横向布局,
|
||||||
@@ -957,8 +960,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aiprovider-icon {
|
.aiprovider-icon {
|
||||||
width: 24px;
|
/* 轨道改 auto 后,药丸按内容宽度撑开("Claude" 约 50px),不再被裁切;
|
||||||
|
高度仍 24px 与其余 picker 图标对齐,white-space:nowrap 保证字样不换行。 */
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
padding: 0 7px;
|
||||||
|
white-space: nowrap;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
@@ -48,8 +48,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
-webkit-backdrop-filter: blur(20px);
|
/* audit fix (P3):与 chrome 类保持 backdrop-filter 策略一致 —— 与 .toast
|
||||||
backdrop-filter: blur(20px);
|
同源(07-statusbar.css .toast 注释),实色背景上看不出毛玻璃,反而
|
||||||
|
每次滚动主区都强制 GPU 重算菜单覆盖区域的模糊。菜单小但触发频繁,
|
||||||
|
切目录 / 右键都会重新出现,累积 GPU 成本不容忽视。 */
|
||||||
animation: context-menu-in 120ms var(--ease-out);
|
animation: context-menu-in 120ms var(--ease-out);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
/* 阻止菜单自身的文本被选中 */
|
/* 阻止菜单自身的文本被选中 */
|
||||||
@@ -151,6 +153,119 @@ body[data-focus-mode="true"] .ai-chat-dock {
|
|||||||
transition: opacity var(--transition-fast, 120ms ease-out);
|
transition: opacity var(--transition-fast, 120ms ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
图片 lightbox(点击正文图片放大)
|
||||||
|
全局浮层,挂在 document.body(不进 <img>,避免破坏 live diff)
|
||||||
|
============================================================ */
|
||||||
|
.image-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-7);
|
||||||
|
}
|
||||||
|
.image-lightbox[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.image-lightbox-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, #000 72%, transparent);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .image-lightbox-backdrop {
|
||||||
|
background: color-mix(in srgb, #000 52%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-stage {
|
||||||
|
position: relative;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: image-lightbox-in 180ms var(--ease-out);
|
||||||
|
}
|
||||||
|
.image-lightbox-img {
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.image-lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
right: -12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--fg-primary);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.image-lightbox-close:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.image-lightbox-close:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
@keyframes image-lightbox-in {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
工具栏快捷键 chip(hover 主按钮时浮现,只读 data-shortcut,
|
||||||
|
不改动按钮 DOM。pointer-events:none → 纯视觉提示,不挡 hover)
|
||||||
|
============================================================ */
|
||||||
|
.shortcut-hint {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1100;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: shortcut-hint-in 140ms var(--ease-out);
|
||||||
|
}
|
||||||
|
.shortcut-hint[hidden] { display: none !important; }
|
||||||
|
.shortcut-hint kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-xs-readable);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-app);
|
||||||
|
color: var(--fg-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
box-shadow: 0 1px 0 var(--border-subtle);
|
||||||
|
}
|
||||||
|
.shortcut-hint-plus {
|
||||||
|
color: var(--fg-tertiary);
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
@keyframes shortcut-hint-in {
|
||||||
|
from { opacity: 0; transform: translateY(-3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
BUG-3 fix (audit):focus mode 下 .ai-chat-dock 即便被 opacity:0 隐藏,
|
BUG-3 fix (audit):focus mode 下 .ai-chat-dock 即便被 opacity:0 隐藏,
|
||||||
仍然占据 body grid row 3 的自然高度(~88px),把 app-shell 高度挤掉。
|
仍然占据 body grid row 3 的自然高度(~88px),把 app-shell 高度挤掉。
|
||||||
@@ -240,7 +355,10 @@ body[data-focus-mode="true"][data-editor-mode="split"] .splitter {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
/* 10 → 11(--fs-xs):focus exit hint 在右下角停留 ~600ms 后淡出,
|
||||||
|
10px 在低 DPI 屏易糊;提到 11px 与 .toolbar / .empty-state kbd 同档,
|
||||||
|
鼠标停在窗口里那一瞥就能看见。 */
|
||||||
|
font-size: var(--fs-xs);
|
||||||
margin: 0 1px;
|
margin: 0 1px;
|
||||||
}
|
}
|
||||||
body[data-focus-mode="true"]:hover .focus-exit-hint {
|
body[data-focus-mode="true"]:hover .focus-exit-hint {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: var(--fs-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
/* 默认视觉隐藏但保留可访问性 —— 用 transform 移到屏外,不影响
|
/* 默认视觉隐藏但保留可访问性 —— 用 transform 移到屏外,不影响
|
||||||
屏幕阅读器在第一个 Tab 焦点候选里看到它 */
|
屏幕阅读器在第一个 Tab 焦点候选里看到它 */
|
||||||
@@ -65,5 +65,11 @@
|
|||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-delay: 0 !important;
|
animation-delay: 0 !important;
|
||||||
}
|
}
|
||||||
|
/* 阅读进度条是 width 过渡(非 animation),上面的 .01ms 规则收不掉它。
|
||||||
|
关掉过渡让进度"瞬切",避免每帧滚动触发持续的位置变化动画(前庭敏感用户)。 */
|
||||||
|
.reading-progress-bar { transition: none; }
|
||||||
|
/* 代码块复制按钮的 hover/active 颜色过渡 + 按下缩放也一并收掉 */
|
||||||
|
.code-copy-fab { transition: none; }
|
||||||
|
.code-copy-fab:active { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ body[data-focus-mode="true"] .ai-splitter {
|
|||||||
font-size: var(--fs-xs-readable);
|
font-size: var(--fs-xs-readable);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-diff-apply-all:hover {
|
.ai-diff-apply-all:hover {
|
||||||
@@ -429,7 +429,7 @@ body[data-focus-mode="true"] .ai-splitter {
|
|||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition: background var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-diff-region-apply:hover:not([disabled]) {
|
.ai-diff-region-apply:hover:not([disabled]) {
|
||||||
@@ -466,7 +466,7 @@ body[data-focus-mode="true"] .ai-splitter {
|
|||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-diff-region-skip:hover {
|
.ai-diff-region-skip:hover {
|
||||||
@@ -645,6 +645,32 @@ body[data-focus-mode="true"] .ai-splitter {
|
|||||||
color: var(--fg-tertiary);
|
color: var(--fg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P2-23:「⚙ 设置」内联链 —— 未配置时显示在 input 右侧,提示用户去填 Base URL / API Key。
|
||||||
|
小尺寸 + accent 色,与 submit 按钮拉开视觉权重(链接 ≠ 操作)。hidden 时
|
||||||
|
[hidden] 默认 display:none 即可,无需额外 display 控制。 */
|
||||||
|
.ai-chat-configure-link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-configure-link:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-configure-link:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-chat-submit {
|
.ai-chat-submit {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
/* P2-6 fix (audit):min-width 锁定宽度,防止"生成修改"(4字)和
|
/* P2-6 fix (audit):min-width 锁定宽度,防止"生成修改"(4字)和
|
||||||
@@ -661,7 +687,7 @@ body[data-focus-mode="true"] .ai-splitter {
|
|||||||
文字在同一基线垂直居中。 */
|
文字在同一基线垂直居中。 */
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-submit:hover:not([disabled]) {
|
.ai-chat-submit:hover:not([disabled]) {
|
||||||
@@ -697,7 +723,7 @@ body[data-focus-mode="true"] .ai-splitter {
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
AI 按钮激活态(侧栏底部)
|
AI 按钮激活态(侧栏底部)
|
||||||
当 AI 面板展开时给 btn-ai 一个明显区别于"主操作描边态"的实心强调态:
|
当 AI 面板展开时给 btn-ai 一个明显区别于"主操作描边态"的实心强调态:
|
||||||
- 关闭:沿用 .sidebar-new-btn 的描边 + 8% accent 底(与「新建笔记」同款)
|
- 关闭:沿用 .sidebar-new-btn 的描边 + 8% accent 底(与基类同款)
|
||||||
- 打开:实心 accent 底 + 白字 + accent 边,让用户一眼看出 AI dock 已开
|
- 打开:实心 accent 底 + 白字 + accent 边,让用户一眼看出 AI dock 已开
|
||||||
用 #btn-ai.sidebar-new-btn 这种 ID+class 组合是为了 specificity 高过
|
用 #btn-ai.sidebar-new-btn 这种 ID+class 组合是为了 specificity 高过
|
||||||
.sidebar-new-btn:hover(0,2,0),保证 hover 时不会把"已打开"的视觉态冲掉。
|
.sidebar-new-btn:hover(0,2,0),保证 hover 时不会把"已打开"的视觉态冲掉。
|
||||||
|
|||||||
132
tests/unit/ai-chat-panel-placeholder.test.js
Normal file
132
tests/unit/ai-chat-panel-placeholder.test.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// P2-23(audit):AI 面板 placeholder 区分「无文件」vs「未配置」。
|
||||||
|
//
|
||||||
|
// 设计:纯函数 pickPlaceholder 决策三态文案,
|
||||||
|
// - !hasFile → 「请先打开一个 Markdown 文件」
|
||||||
|
// - hasFile + !configured → 「AI 未配置 — 点「⚙ 设置」填 Base URL 与 API Key」
|
||||||
|
// - hasFile + configured → 「描述你想如何修改当前文档...」
|
||||||
|
//
|
||||||
|
// 把决策抽成纯函数方便单测(避免起 jsdom 跑 DOM 副作用)。panel 内部
|
||||||
|
// setEnabled / setConfigured 走它。
|
||||||
|
//
|
||||||
|
// 另测:setEnabled / setConfigured 在 jsdom 下同步 DOM 状态(input.placeholder +
|
||||||
|
// .ai-chat-configure-link.hidden),保证「内联设置链」只在该显示时显示。
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { pickPlaceholder, AiChatPanel } from '../../src/ai/ai-chat-panel.js';
|
||||||
|
|
||||||
|
describe('pickPlaceholder —— P2-23 三态文案', () => {
|
||||||
|
it('无文件 → 提示先打开文件', () => {
|
||||||
|
expect(pickPlaceholder({ hasFile: false, configured: true }))
|
||||||
|
.toBe('请先打开一个 Markdown 文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有文件 + 未配置 → 提示去设置里配置', () => {
|
||||||
|
const text = pickPlaceholder({ hasFile: true, configured: false });
|
||||||
|
expect(text).toContain('AI 未配置');
|
||||||
|
expect(text).toContain('设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有文件 + 已配置 → 默认描述型 placeholder', () => {
|
||||||
|
expect(pickPlaceholder({ hasFile: true, configured: true }))
|
||||||
|
.toBe('描述你想如何修改当前文档...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('优先级:!hasFile 永远盖过 !configured(即便都没配)', () => {
|
||||||
|
expect(pickPlaceholder({ hasFile: false, configured: false }))
|
||||||
|
.toBe('请先打开一个 Markdown 文件');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AiChatPanel.setEnabled / setConfigured —— DOM 同步', () => {
|
||||||
|
/** @returns {HTMLElement} 重建一份 index.html 里的 ai-chat-dock 骨架 */
|
||||||
|
function buildDock() {
|
||||||
|
const dock = document.createElement('div');
|
||||||
|
dock.className = 'ai-chat-dock';
|
||||||
|
dock.innerHTML = `
|
||||||
|
<div class="ai-chat-form-row">
|
||||||
|
<button class="ai-chat-toggle" type="button">×</button>
|
||||||
|
<div class="ai-chat-input-wrap">
|
||||||
|
<input type="text" class="ai-chat-input" />
|
||||||
|
<button class="ai-chat-configure-link" type="button" hidden>⚙ 设置</button>
|
||||||
|
</div>
|
||||||
|
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return dock;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dock;
|
||||||
|
beforeEach(() => {
|
||||||
|
dock = buildDock();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未初始化时默认 configured=true(避免中间态被误判)', () => {
|
||||||
|
const panel = new AiChatPanel({
|
||||||
|
element: dock,
|
||||||
|
onSubmit: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
// 默认 _configured !== false === true
|
||||||
|
panel.setEnabled(false); // 无文件
|
||||||
|
expect(dock.querySelector('.ai-chat-input').placeholder)
|
||||||
|
.toBe('请先打开一个 Markdown 文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasFile + !configured → placeholder 切到「未配置」+ 内联链显示', () => {
|
||||||
|
const panel = new AiChatPanel({
|
||||||
|
element: dock,
|
||||||
|
onSubmit: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
panel.setEnabled(true); // 有文件
|
||||||
|
panel.setConfigured(false); // 未配置
|
||||||
|
const input = dock.querySelector('.ai-chat-input');
|
||||||
|
const link = dock.querySelector('.ai-chat-configure-link');
|
||||||
|
expect(input.placeholder).toContain('AI 未配置');
|
||||||
|
expect(link.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasFile + configured → placeholder 切回「描述修改」+ 内联链隐藏', () => {
|
||||||
|
const panel = new AiChatPanel({
|
||||||
|
element: dock,
|
||||||
|
onSubmit: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
panel.setEnabled(true);
|
||||||
|
panel.setConfigured(false);
|
||||||
|
panel.setConfigured(true); // 配置完成
|
||||||
|
const input = dock.querySelector('.ai-chat-input');
|
||||||
|
const link = dock.querySelector('.ai-chat-configure-link');
|
||||||
|
expect(input.placeholder).toBe('描述你想如何修改当前文档...');
|
||||||
|
expect(link.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('!hasFile 永远隐藏内联链(不论配置与否)', () => {
|
||||||
|
const panel = new AiChatPanel({
|
||||||
|
element: dock,
|
||||||
|
onSubmit: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
panel.setEnabled(false); // 无文件
|
||||||
|
panel.setConfigured(false); // 未配置
|
||||||
|
expect(dock.querySelector('.ai-chat-configure-link').hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击内联链调 onOpenSettings 回调(如果传了)', () => {
|
||||||
|
let called = 0;
|
||||||
|
const panel = new AiChatPanel({
|
||||||
|
element: dock,
|
||||||
|
onSubmit: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
onOpenSettings: () => { called += 1; },
|
||||||
|
});
|
||||||
|
panel.setEnabled(true);
|
||||||
|
panel.setConfigured(false);
|
||||||
|
const link = dock.querySelector('.ai-chat-configure-link');
|
||||||
|
link.click();
|
||||||
|
expect(called).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
56
tests/unit/app-static-imports.test.js
Normal file
56
tests/unit/app-static-imports.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// P2-9(audit C-4):app.js 顶层静态 import 不拉 CM6。
|
||||||
|
//
|
||||||
|
// 背景:main.js 用 show:false + ready-to-show,窗口出现时机 ≈ renderer 首绘。
|
||||||
|
// app.js 是 <script type="module">,模块图全部 fetch+eval 完才执行 —— 顶层
|
||||||
|
// 静态 import './markdown-editor.js'(basicSetup 拖整个 23 条 importmap 的
|
||||||
|
// CM6 图)意味着「窗口出现」要等 CM6 全部下载解析完。
|
||||||
|
//
|
||||||
|
// 契约(本测试锁死):
|
||||||
|
// 1. app.js 顶层不得静态 import 'codemirror' / '@codemirror/*' /
|
||||||
|
// './markdown-editor.js'(CM6 只能经 dynamic import 进 renderer)
|
||||||
|
// 2. bootstrap 路径里必须有 dynamic import('./markdown-editor.js')
|
||||||
|
// 3. editor 不得是模块级 const new MarkdownEditor(...) —— 必须是 let,
|
||||||
|
// 由 bootstrap 在 dynamic import 完成后实例化(模块 eval 时不建编辑器)
|
||||||
|
//
|
||||||
|
// 与 tests/unit/csp-importmap-hash.test.js 同为「源码静态分析契约测试」:
|
||||||
|
// jsdom 无法 import app.js(模块级副作用 + window.api 前置),只能读源码断言。
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const appPath = join(dirname(fileURLToPath(import.meta.url)), '../../src/app.js');
|
||||||
|
const src = readFileSync(appPath, 'utf8');
|
||||||
|
|
||||||
|
// 顶层静态 import 行(行首无缩进的 import ... from '...')
|
||||||
|
function staticImportSpecifiers(source) {
|
||||||
|
const specs = [];
|
||||||
|
for (const line of source.split('\n')) {
|
||||||
|
const m = line.match(/^import\s.*?\sfrom\s+'([^']+)'/);
|
||||||
|
if (m) specs.push(m[1]);
|
||||||
|
}
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('app.js 顶层不静态拉 CM6(audit P2-9)', () => {
|
||||||
|
it("无静态 import 'codemirror' / '@codemirror/*' / './markdown-editor.js'", () => {
|
||||||
|
const heavy = staticImportSpecifiers(src).filter((s) =>
|
||||||
|
s === 'codemirror' || s.startsWith('@codemirror/') || s.startsWith('@lezer/') || s === './markdown-editor.js'
|
||||||
|
);
|
||||||
|
expect(heavy).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bootstrap 路径存在 dynamic import('./markdown-editor.js')", () => {
|
||||||
|
expect(src).toMatch(/import\('\.\/markdown-editor\.js'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editor 是模块级 let(bootstrap 内实例化),不是顶层 const new', () => {
|
||||||
|
expect(src).not.toMatch(/^const editor = new MarkdownEditor/m);
|
||||||
|
expect(src).toMatch(/^let editor/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无静态 import { EditorView } from '@codemirror/view'(facet 访问走 bootstrap 注入)", () => {
|
||||||
|
expect(src).not.toMatch(/^import\s*\{\s*EditorView\s*\}\s*from\s*'@codemirror\/view'/m);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
tests/unit/audit-rate.test.js
Normal file
59
tests/unit/audit-rate.test.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// P2-7(audit):app:open-config-dir 高频调用检测。
|
||||||
|
//
|
||||||
|
// 设计:main/audit-rate.js 提供 createAuditRateTracker({ windowMs, threshold, now })
|
||||||
|
// 返回 hit(key) → { count, exceeded }。滑动窗口按 key 计数:
|
||||||
|
// - 窗口内 count ≤ threshold → exceeded=false
|
||||||
|
// - 窗口内 count > threshold → exceeded=true(调用方据此 console.warn)
|
||||||
|
// - 超过 windowMs 的旧记录自动过期
|
||||||
|
//
|
||||||
|
// now 可注入(Date.now 默认),方便单测推进时间。
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
const { createAuditRateTracker } = require('../../main/audit-rate.js');
|
||||||
|
|
||||||
|
describe('createAuditRateTracker —— P2-7 高频调用检测', () => {
|
||||||
|
it('首次调用 count=1,不 exceeded', () => {
|
||||||
|
const t = createAuditRateTracker({ windowMs: 60_000, threshold: 5 });
|
||||||
|
expect(t.hit('k')).toEqual({ count: 1, exceeded: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('窗口内累计到 threshold 不 exceeded,超过则 exceeded', () => {
|
||||||
|
let now = 1000;
|
||||||
|
const t = createAuditRateTracker({ windowMs: 60_000, threshold: 3, now: () => now });
|
||||||
|
expect(t.hit('k').exceeded).toBe(false); // 1
|
||||||
|
expect(t.hit('k').exceeded).toBe(false); // 2
|
||||||
|
expect(t.hit('k').exceeded).toBe(false); // 3
|
||||||
|
const fourth = t.hit('k'); // 4 > 3
|
||||||
|
expect(fourth.count).toBe(4);
|
||||||
|
expect(fourth.exceeded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同 key 独立计数', () => {
|
||||||
|
const t = createAuditRateTracker({ windowMs: 60_000, threshold: 1 });
|
||||||
|
t.hit('a');
|
||||||
|
expect(t.hit('b')).toEqual({ count: 1, exceeded: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超过 windowMs 的旧记录过期,重新计数', () => {
|
||||||
|
let now = 0;
|
||||||
|
const t = createAuditRateTracker({ windowMs: 1000, threshold: 2, now: () => now });
|
||||||
|
t.hit('k'); // t=0
|
||||||
|
t.hit('k'); // t=0, count=2
|
||||||
|
expect(t.hit('k').exceeded).toBe(true); // t=0, count=3
|
||||||
|
now = 1500; // 滑出窗口
|
||||||
|
expect(t.hit('k')).toEqual({ count: 1, exceeded: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('边界:恰好 windowMs 的时间点视为过期([now-window, now) 语义)', () => {
|
||||||
|
let now = 0;
|
||||||
|
const t = createAuditRateTracker({ windowMs: 1000, threshold: 1, now: () => now });
|
||||||
|
t.hit('k'); // t=0
|
||||||
|
now = 1000; // 恰好 1000ms 后
|
||||||
|
expect(t.hit('k')).toEqual({ count: 1, exceeded: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认 now=Date.now,不抛错', () => {
|
||||||
|
const t = createAuditRateTracker({ windowMs: 1000, threshold: 1 });
|
||||||
|
expect(() => t.hit('k')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
85
tests/unit/cleanup-epoch.test.js
Normal file
85
tests/unit/cleanup-epoch.test.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// main/ai.js cancelAll —— 顺序竞争 epoch 闸(audit P1-5)
|
||||||
|
//
|
||||||
|
// 背景:cancelAll 内部三步
|
||||||
|
// 1) for (controller of pending.values()) controller.abort();
|
||||||
|
// 2) pending.clear();
|
||||||
|
// 3) inFlightRequestIds.clear();
|
||||||
|
// 三步非原子。竞态:cancelAll 走到 step 1 刚 abort 完,runEdit 同步阶段
|
||||||
|
// 已经把「新 requestId + 新 controller」加进 pending + inFlightRequestIds →
|
||||||
|
// step 2/3 把这条新 entry 也清掉,runEdit 自以为还在跑,await 不到结果,
|
||||||
|
// 任何同 requestId 重试都找不到 controller。
|
||||||
|
//
|
||||||
|
// 修复:抽 makeCleanupEpoch() 工厂 → { snapshot, invalidate, current, isCurrent }。
|
||||||
|
// - cancelAll 入口 invalidate() → 序号 + 1(任何已 snapshot 的 in-flight 任务
|
||||||
|
// 之后 isCurrent() === false → 自行 bail)
|
||||||
|
// - runEdit 入口 snapshot() 拿当前序号;await 后 isCurrent() === false
|
||||||
|
// (说明本任务期间发生过 cancelAll)→ 不再 apply 任何副作用、直接 return
|
||||||
|
//
|
||||||
|
// 测试策略:纯函数工厂,无 fs / network / Electron 依赖。
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { makeCleanupEpoch } from '../../main/cleanup-epoch.js';
|
||||||
|
|
||||||
|
describe('cleanup epoch(audit P1-5)', () => {
|
||||||
|
it('初始:current = 0,snapshot(0) isCurrent 成立', () => {
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
expect(ep.current()).toBe(0);
|
||||||
|
expect(ep.snapshot()).toBe(0);
|
||||||
|
expect(ep.isCurrent(ep.snapshot())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidate() → current 自增,isCurrent 旧 snapshot 返 false', () => {
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
const oldSnap = ep.snapshot();
|
||||||
|
ep.invalidate();
|
||||||
|
expect(ep.current()).toBeGreaterThan(oldSnap);
|
||||||
|
expect(ep.isCurrent(oldSnap)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidate 之后再 snapshot 拿新序号,isCurrent 成立', () => {
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
ep.invalidate();
|
||||||
|
const newSnap = ep.snapshot();
|
||||||
|
expect(ep.isCurrent(newSnap)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多次 invalidate 序号单调递增', () => {
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
const seqs = Array.from({ length: 5 }, () => {
|
||||||
|
const before = ep.current();
|
||||||
|
ep.invalidate();
|
||||||
|
return ep.current() - before;
|
||||||
|
});
|
||||||
|
// 每次都增 1
|
||||||
|
expect(seqs).toEqual([1, 1, 1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runEdit 模式:A 拿快照 → cancelAll → A 的 isCurrent 返 false', () => {
|
||||||
|
// 模拟 P1-5 竞态:A 在 cancelAll 之前 snapshot,cancelAll 之后 A 检查 → 失效
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
const aSnap = ep.snapshot();
|
||||||
|
// ... await 期间 ...
|
||||||
|
ep.invalidate(); // cancelAll 触发
|
||||||
|
expect(ep.isCurrent(aSnap)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runEdit 模式:B 在 cancelAll 之后 snapshot → isCurrent 成立', () => {
|
||||||
|
// 新请求在 cancelAll 之后进入,snapshot 拿新序号 → isCurrent true
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
ep.invalidate();
|
||||||
|
const bSnap = ep.snapshot();
|
||||||
|
expect(ep.isCurrent(bSnap)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelAll 期间 burst:B1 → cancelAll → B2 都拿自己的快照,互不影响', () => {
|
||||||
|
const ep = makeCleanupEpoch();
|
||||||
|
const a1 = ep.snapshot();
|
||||||
|
ep.invalidate();
|
||||||
|
const b1 = ep.snapshot();
|
||||||
|
ep.invalidate();
|
||||||
|
const b2 = ep.snapshot();
|
||||||
|
expect(ep.isCurrent(a1)).toBe(false); // A1 被 cancelAll 失效
|
||||||
|
expect(ep.isCurrent(b1)).toBe(false); // B1 也被第二次 cancelAll 失效
|
||||||
|
expect(ep.isCurrent(b2)).toBe(true); // B2 仍有效
|
||||||
|
});
|
||||||
|
});
|
||||||
156
tests/unit/codemirror-history-depth.test.js
Normal file
156
tests/unit/codemirror-history-depth.test.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// src/markdown-editor.js —— CodeMirror history depth 上限(audit P0-3)
|
||||||
|
//
|
||||||
|
// 背景:basicSetup 默认带 history(),无 maxDepth 上限。打开 5MB 文件 + 长
|
||||||
|
// 时间编辑 → history stack 几百 MB,OOM 风险。
|
||||||
|
//
|
||||||
|
// 修复:extensions 列表里 basicSetup 之后加 Prec.high(history({ minEventTimeout: 200, maxDepth: 500 }))。
|
||||||
|
// 覆盖 basicSetup 的默认 history 配置:maxDepth 限制 undo 步数,minEventTimeout
|
||||||
|
// 把 200ms 内的连续 keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千
|
||||||
|
// 条 history)。
|
||||||
|
//
|
||||||
|
// 测试策略:
|
||||||
|
// - 1MB 文档 + 1000 次独立 transaction → undoDepth ≤ 500(maxDepth)
|
||||||
|
// - 500 次 transaction → undoDepth = 500(恰好满载)
|
||||||
|
// - undo 一次 → undoDepth 减 1,redoDepth 加 1
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { undoDepth, redoDepth, undo, isolateHistory } from '@codemirror/commands';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { MarkdownEditor } from '../../src/markdown-editor.js';
|
||||||
|
|
||||||
|
if (!HTMLElement.prototype.scrollIntoView) {
|
||||||
|
HTMLElement.prototype.scrollIntoView = function () {};
|
||||||
|
}
|
||||||
|
if (typeof window.requestAnimationFrame === 'undefined') {
|
||||||
|
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||||
|
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||||
|
}
|
||||||
|
if (typeof Range.prototype.getClientRects !== 'function') {
|
||||||
|
Range.prototype.getClientRects = function () {
|
||||||
|
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() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return new MarkdownEditor({
|
||||||
|
element: el,
|
||||||
|
onChange: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodeMirror history depth 上限(audit P0-3)', () => {
|
||||||
|
let editor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
editor = createEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('加载空 doc → undoDepth = 0', () => {
|
||||||
|
// MarkdownEditor 懒加载 view —— 必须先 loadFile
|
||||||
|
editor.loadFile({ path: '/empty.md', name: 'empty.md' }, '');
|
||||||
|
expect(undoDepth(editor.view.state)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1000 次独立 transaction → undoDepth ≤ 520(minDepth 500 + updateBranch +20 buffer)', () => {
|
||||||
|
// 注:audit P0-3 描述「1MB 文档 + 1000 次 transaction」—— 这里用 1KB
|
||||||
|
// 文档,理由:audit 关心的是 history 长度上限(minDepth 截断),不关心
|
||||||
|
// 文档大小本身。1KB 文档下跑 1000 次 dispatch 几秒内出结果,1MB 文档
|
||||||
|
// 要 46 秒(每次 dispatch 都做 change mapping + state 克隆)。
|
||||||
|
editor.loadFile({ path: '/big.md', name: 'big.md' }, 'x'.repeat(1024));
|
||||||
|
const view = editor.view;
|
||||||
|
|
||||||
|
// 1000 次独立 transaction:用 userEvent: 'test.step' 跳出 joinableUserEvent
|
||||||
|
// 正则(^(input\.type|delete)($|\.))—— 不匹配的 userEvent 不会与上一个
|
||||||
|
// event 合并,确保每个 transaction 各自成为 1 个 history step。
|
||||||
|
// 同步加 isolateHistory 标注,双重保险。
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: 1, insert: String.fromCharCode(65 + (i % 26)) },
|
||||||
|
userEvent: 'test.step',
|
||||||
|
annotations: [isolateHistory.of('test')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键断言:undoDepth 不会无限增长,超 minDepth(500) 即被截断
|
||||||
|
// updateBranch 内部 maxLen + 20 buffer,所以上限是 520
|
||||||
|
expect(undoDepth(view.state)).toBeLessThanOrEqual(520);
|
||||||
|
expect(undoDepth(view.state)).toBeGreaterThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500 次 transaction → undoDepth = 500(恰好满载,未截断)', () => {
|
||||||
|
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'hello world');
|
||||||
|
const view = editor.view;
|
||||||
|
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: 1, insert: String.fromCharCode(65 + (i % 26)) },
|
||||||
|
userEvent: 'test.step',
|
||||||
|
annotations: [isolateHistory.of('test')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(undoDepth(view.state)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo 一次 → undoDepth 减 1,redoDepth 加 1(minDepth 限制下 history 仍可用)', () => {
|
||||||
|
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'hello');
|
||||||
|
const view = editor.view;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: 0, insert: 'a' },
|
||||||
|
userEvent: 'test.step',
|
||||||
|
annotations: [isolateHistory.of('test')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(undoDepth(view.state)).toBe(3);
|
||||||
|
expect(redoDepth(view.state)).toBe(0);
|
||||||
|
|
||||||
|
undo(view);
|
||||||
|
expect(undoDepth(view.state)).toBe(2);
|
||||||
|
expect(redoDepth(view.state)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超 500 步后 undo 老的 history 被丢弃,只剩最新的 ~500 步可撤销', () => {
|
||||||
|
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'x');
|
||||||
|
const view = editor.view;
|
||||||
|
|
||||||
|
// 600 步独立 transaction
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: 0, insert: 'a' },
|
||||||
|
userEvent: 'test.step',
|
||||||
|
annotations: [isolateHistory.of('test')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 600 步后只剩 minDepth(500) + 20 buffer = 520
|
||||||
|
expect(undoDepth(view.state)).toBeLessThanOrEqual(520);
|
||||||
|
expect(undoDepth(view.state)).toBeGreaterThanOrEqual(500);
|
||||||
|
|
||||||
|
// 连续 undo 直到 history 空
|
||||||
|
while (undoDepth(view.state) > 0) {
|
||||||
|
undo(view);
|
||||||
|
}
|
||||||
|
expect(undoDepth(view.state)).toBe(0);
|
||||||
|
// redo 应有 ~500+ 条
|
||||||
|
expect(redoDepth(view.state)).toBeLessThanOrEqual(520);
|
||||||
|
expect(redoDepth(view.state)).toBeGreaterThanOrEqual(500);
|
||||||
|
|
||||||
|
// 再 undo:应失败(已无 history 可撤销)
|
||||||
|
const beforeUndo = undoDepth(view.state);
|
||||||
|
undo(view);
|
||||||
|
expect(undoDepth(view.state)).toBe(beforeUndo); // 没变化
|
||||||
|
});
|
||||||
|
});
|
||||||
120
tests/unit/config-store-quit-gate.test.js
Normal file
120
tests/unit/config-store-quit-gate.test.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// main/config-store.js —— saveConfig isQuitting 闸(audit P1-2)
|
||||||
|
//
|
||||||
|
// 背景:renderer 错误风暴(如 settingsStore.update 触发循环)会建无界
|
||||||
|
// Promise 链;进程退出时 IPC handler `await configStore.saveConfig(...)`
|
||||||
|
// 被卡在最末一个未完成 task,before-quit 等不到 save 全部完成 → quit 阻塞。
|
||||||
|
//
|
||||||
|
// 修复:加 isQuitting 闸。before-quit 触发时把闸置 true,新 saveConfig
|
||||||
|
// 立即返回 { ok: false, error: 'APP_QUITTING' }(同步值,不是 Promise,
|
||||||
|
// 避免 IPC await 永不 resolve)。in-flight 任务照常完成(已 fsync 的
|
||||||
|
// 文件 rename 完才返回 ok:true,强制 abort 会丢正在写的 tmp)—— 「
|
||||||
|
// 进程退出」语意下「最新一个 save 完成」即可接受。
|
||||||
|
//
|
||||||
|
// 测试策略:直接 import config-store 模块,用 _setIsQuitting 控制闸。
|
||||||
|
// saveConfig 内部写 tmp + rename 到真文件 → 测试用真 fs + tmpdir 隔离。
|
||||||
|
// 注意:每次测试前 _reset 清状态 + _setIsQuitting(false),避免 case 串扰。
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import * as configStore from '../../main/config-store.js';
|
||||||
|
|
||||||
|
let mockHomeDir;
|
||||||
|
let mockUserDataDir;
|
||||||
|
let home;
|
||||||
|
|
||||||
|
function makeIsolatedHome() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'notes-config-quit-'));
|
||||||
|
// 写入一个最小可用的 config.json(init 需要它存在)
|
||||||
|
writeFileSync(join(dir, 'config.json'), JSON.stringify({ alwaysOnTop: false }, null, 2), 'utf-8');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('config-store saveConfig isQuitting 闸(audit P1-2)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockHomeDir = mkdtempSync(join(tmpdir(), 'notes-cq-home-'));
|
||||||
|
mockUserDataDir = mkdtempSync(join(tmpdir(), 'notes-cq-ud-'));
|
||||||
|
configStore._setApp({
|
||||||
|
getPath: (name) => {
|
||||||
|
if (name === 'home') return mockHomeDir;
|
||||||
|
if (name === 'userData') return mockUserDataDir;
|
||||||
|
throw new Error(`unexpected getPath(${name})`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
configStore._reset();
|
||||||
|
configStore._setIsQuitting(false);
|
||||||
|
home = makeIsolatedHome();
|
||||||
|
await configStore.init({ homeDir: home });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认状态:isQuitting = false', () => {
|
||||||
|
expect(configStore._isQuitting()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_setIsQuitting(true) 后 isQuitting 反映为 true', () => {
|
||||||
|
configStore._setIsQuitting(true);
|
||||||
|
expect(configStore._isQuitting()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isQuitting=true 时 saveConfig 同步返 { ok:false, error:APP_QUITTING },不写盘', async () => {
|
||||||
|
configStore._setIsQuitting(true);
|
||||||
|
const before = readFileSync(join(home, 'config.json'), 'utf-8');
|
||||||
|
|
||||||
|
const result = await configStore.saveConfig({ alwaysOnTop: true });
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: 'APP_QUITTING' });
|
||||||
|
|
||||||
|
// 文件未被改写(before vs after 内容一致)
|
||||||
|
const after = readFileSync(join(home, 'config.json'), 'utf-8');
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isQuitting=true 时 saveConfig 返的 Promise 已 resolve(不等链尾)', async () => {
|
||||||
|
// 关键:闸开时返回的必须是「已 resolve 的值」,否则 IPC handler
|
||||||
|
// `await saveConfig(...)` 永远挂在链尾 → before-quit 阻塞。
|
||||||
|
configStore._setIsQuitting(true);
|
||||||
|
|
||||||
|
// 10 次并发调用 → 全部应立即 resolve
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 10 }, () => configStore.saveConfig({ alwaysOnTop: true }))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(10);
|
||||||
|
for (const r of results) {
|
||||||
|
expect(r).toEqual({ ok: false, error: 'APP_QUITTING' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isQuitting=true 时 in-flight 之前的 save 仍正常完成(不强制 abort)', async () => {
|
||||||
|
// 第一个 save 在闸开启前排进 queue,应正常完成
|
||||||
|
const p1 = configStore.saveConfig({ alwaysOnTop: true });
|
||||||
|
configStore._setIsQuitting(true);
|
||||||
|
const r1 = await p1;
|
||||||
|
|
||||||
|
expect(r1.ok).toBe(true);
|
||||||
|
expect(r1.value.alwaysOnTop).toBe(true);
|
||||||
|
|
||||||
|
// 闸开后后续 save 被拒
|
||||||
|
const r2 = await configStore.saveConfig({ alwaysOnTop: false });
|
||||||
|
expect(r2).toEqual({ ok: false, error: 'APP_QUITTING' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isQuitting=true → _setIsQuitting(false) 后 saveConfig 恢复工作', async () => {
|
||||||
|
configStore._setIsQuitting(true);
|
||||||
|
const r1 = await configStore.saveConfig({ alwaysOnTop: true });
|
||||||
|
expect(r1).toEqual({ ok: false, error: 'APP_QUITTING' });
|
||||||
|
|
||||||
|
configStore._setIsQuitting(false);
|
||||||
|
const r2 = await configStore.saveConfig({ alwaysOnTop: true });
|
||||||
|
expect(r2.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (home && existsSync(home)) {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
if (mockHomeDir) rmSync(mockHomeDir, { recursive: true, force: true });
|
||||||
|
if (mockUserDataDir) rmSync(mockUserDataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/unit/csp-importmap-hash.test.js
Normal file
123
tests/unit/csp-importmap-hash.test.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// index.html —— CSP 收紧(audit P0-2)
|
||||||
|
//
|
||||||
|
// 背景:原 CSP `script-src 'self' 'unsafe-inline'` —— 注释承认仅 importmap
|
||||||
|
// 需要 inline,但 'unsafe-inline' 留下 XSS 后续路径(任何位置注入的 inline
|
||||||
|
// script 都被放行)。
|
||||||
|
//
|
||||||
|
// 修复:给 importmap 内联块生成 SHA-256 hash;CSP 改
|
||||||
|
// script-src 'self' 'sha256-<hash>='
|
||||||
|
// 把 'unsafe-inline' 从 script-src 移除。hash 必须与 importmap 当前实际内容
|
||||||
|
// 匹配(任何 importmap 调整后必须重算 + 同步 CSP,否则 importmap 被 CSP
|
||||||
|
// 拦截,renderer 启动会「Failed to resolve module specifier」)。
|
||||||
|
//
|
||||||
|
// 注:style-src 的 'unsafe-inline' 保留 —— marked 渲染的代码块用到 inline
|
||||||
|
// style(CSP 收紧会破坏阅读视图)。本测试只盯 script-src。
|
||||||
|
//
|
||||||
|
// 测试策略:直接读 index.html 文件,解析 <meta http-equiv="Content-Security-Policy">
|
||||||
|
// 和 <script type="importmap"> 块,独立用 Node crypto 重算 hash,断言:
|
||||||
|
// - script-src 不含 'unsafe-inline'
|
||||||
|
// - script-src 含 'sha256-...' 形式
|
||||||
|
// - CSP 里的 hash == importmap inner content 的 SHA-256
|
||||||
|
// - importmap 改动 → hash 自动失效(防 drift)
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const INDEX_HTML = resolve(__dirname, '../../index.html');
|
||||||
|
|
||||||
|
function readIndexHtml() {
|
||||||
|
return readFileSync(INDEX_HTML, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCsp(html) {
|
||||||
|
// content 属性里含单引号 ('self') + 哈希等号,不能用 [^"']+ 简单排除;
|
||||||
|
// 用更稳的「非贪婪 + 显式终止」:content="..." 直到遇到 " 后接空白/>/换行。
|
||||||
|
const m = html.match(/<meta\s+http-equiv=["']Content-Security-Policy["']\s+content=(["'])([\s\S]*?)\1\s*>/i);
|
||||||
|
if (!m) throw new Error('未找到 <meta http-equiv="Content-Security-Policy">');
|
||||||
|
return m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCspDirectives(csp) {
|
||||||
|
// 简单解析:split by ';',每个 directive 按空格 split key / values。
|
||||||
|
// 值可能带单/双引号(CSP source-list 语法),解析时去掉外层引号便于后续比对。
|
||||||
|
/** @type {Record<string, string[]>} */
|
||||||
|
const out = {};
|
||||||
|
for (const raw of csp.split(';')) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
const key = parts.shift();
|
||||||
|
out[key] = parts.map((v) => {
|
||||||
|
// 去外层 ' 或 "(CSP value 内部不含嵌套引号,simple trim 安全)
|
||||||
|
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
|
||||||
|
return v.slice(1, -1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImportmapContent(html) {
|
||||||
|
const m = html.match(/<script\s+type=["']importmap["']\s*>([\s\S]*?)<\/script>/i);
|
||||||
|
if (!m) throw new Error('未找到 <script type="importmap">');
|
||||||
|
return m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Base64(content) {
|
||||||
|
// 浏览器 HTML tokenizer 在算 CSP inline-script hash 前,会把脚本内容里的
|
||||||
|
// \r\n / \r 归一化成 \n(这是 2026-10-16 importmap 被 CSP 拦截的真因:仓库
|
||||||
|
// 源文件是 CRLF,但 CSP 必须放 LF 归一化后的 hash)。这里先归一化再算,
|
||||||
|
// 与浏览器行为一致,否则测试会在「文件 CRLF」时算出错的 hash 还判定通过。
|
||||||
|
const normalized = content.replace(/\r\n?/g, '\n');
|
||||||
|
return createHash('sha256').update(normalized).digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('index.html CSP —— script-src 不含 unsafe-inline(audit P0-2)', () => {
|
||||||
|
it('CSP meta 标签存在', () => {
|
||||||
|
const html = readIndexHtml();
|
||||||
|
const csp = extractCsp(html);
|
||||||
|
expect(csp).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("script-src 不含 'unsafe-inline'", () => {
|
||||||
|
// parseCspDirectives 已剥外层引号 → 直接比对 'unsafe-inline'(无引号)。
|
||||||
|
// 用 .some 而非 .toContain:避免未来 CSP 拼成 host-source-list 误判。
|
||||||
|
const html = readIndexHtml();
|
||||||
|
const directives = parseCspDirectives(extractCsp(html));
|
||||||
|
expect(directives['script-src']).toBeDefined();
|
||||||
|
expect(directives['script-src'].some((v) => v === 'unsafe-inline')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('script-src 含 self + 至少一个 sha256-XXX=', () => {
|
||||||
|
const html = readIndexHtml();
|
||||||
|
const directives = parseCspDirectives(extractCsp(html));
|
||||||
|
const scriptSrc = directives['script-src'];
|
||||||
|
expect(scriptSrc).toContain('self');
|
||||||
|
const hashEntries = scriptSrc.filter((v) => /^sha256-/.test(v));
|
||||||
|
expect(hashEntries.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSP 里的 sha256 hash == importmap inner content 的 SHA-256', () => {
|
||||||
|
const html = readIndexHtml();
|
||||||
|
const directives = parseCspDirectives(extractCsp(html));
|
||||||
|
const importmapContent = extractImportmapContent(html);
|
||||||
|
const expectedHash = sha256Base64(importmapContent);
|
||||||
|
const expectedEntry = `sha256-${expectedHash}`;
|
||||||
|
|
||||||
|
const scriptSrc = directives['script-src'];
|
||||||
|
expect(scriptSrc).toContain(expectedEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importmap 内容变更 → hash 自动失效(防 drift,文档化契约)', () => {
|
||||||
|
// 这条不是失败用例,是「设计意图」文档:
|
||||||
|
// 任何 importmap 调整(新增/删除/改路径)后必须重算 SHA-256 并同步 CSP。
|
||||||
|
// 改完 importmap 不重算 hash → 上面那条测试失败,提示开发者。
|
||||||
|
// 不需要断言(已由上一条隐含覆盖),存在仅为了让读者明白这条测试的意图。
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
tests/unit/editor-mode-reset.test.js
Normal file
77
tests/unit/editor-mode-reset.test.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// src/editor-mode-reset.js —— splitRatio 重置决策的纯函数测试
|
||||||
|
//
|
||||||
|
// 背景(audit P0-4 + Memory-2):
|
||||||
|
// - 编辑双栏(splitRatio)每次「打开」都默认对半分,drag 比例仅本次会话内
|
||||||
|
// 有效,不跨应用重启保留。
|
||||||
|
// - 旧实现:applyEditorMode 内部用 `document.body.dataset.editorMode !== 'split'`
|
||||||
|
// 做闸 —— 但默认 editorMode='split' 冷启动时,dataset 已是 'split',闸永远为
|
||||||
|
// 假,上次拖的 70% 在冷启动被恢复。
|
||||||
|
// - 修复:applyEditorMode 内部无条件按 visualMode 决定 —— visualMode='split'
|
||||||
|
// 时调 settingsStore.update({ splitRatio: 0.5 }),其它不动。
|
||||||
|
// - 顺带:mountSplitter.onDragEnd 改为 noop(drag 不再持久化)。
|
||||||
|
//
|
||||||
|
// 这里只测纯函数;mountSplitter 那个 onDragEnd noop 改动是单行「删除
|
||||||
|
// settingsStore.update 调用」,没分支可测,留在 app.js commit 里人工 review。
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { applySplitRatioResetIfEnteringSplit } from '../../src/editor-mode-reset.js';
|
||||||
|
|
||||||
|
describe('applySplitRatioResetIfEnteringSplit', () => {
|
||||||
|
let updateMock;
|
||||||
|
let settingsStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
updateMock = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
settingsStore = { update: updateMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visualMode = split → 调 settingsStore.update({ splitRatio: 0.5 })', () => {
|
||||||
|
applySplitRatioResetIfEnteringSplit('split', settingsStore);
|
||||||
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateMock).toHaveBeenCalledWith({ splitRatio: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visualMode = edit → 不调 update', () => {
|
||||||
|
applySplitRatioResetIfEnteringSplit('edit', settingsStore);
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visualMode = preview → 不调 update', () => {
|
||||||
|
applySplitRatioResetIfEnteringSplit('preview', settingsStore);
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visualMode 是未知字符串(防御性)→ 不调 update', () => {
|
||||||
|
// 防止未来 schema 加新模式时这里误触 reset
|
||||||
|
applySplitRatioResetIfEnteringSplit('unknown', settingsStore);
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update reject → onError 被调,函数不抛', async () => {
|
||||||
|
updateMock.mockRejectedValue(new Error('disk full'));
|
||||||
|
const onError = vi.fn();
|
||||||
|
// 不 await:函数本身 fire-and-forget;测试用 microtask flush 等待 catch 跑完
|
||||||
|
applySplitRatioResetIfEnteringSplit('split', settingsStore, onError);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(onError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onError.mock.calls[0][0]).toMatch(/disk full/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update reject + 未传 onError → 不抛(用 console.warn 兜底,不让布局重置炸掉 app)', async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
updateMock.mockRejectedValue(new Error('disk full'));
|
||||||
|
expect(() => applySplitRatioResetIfEnteringSplit('split', settingsStore)).not.toThrow();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update 成功 → onError 不被调(成功路径不打扰用户)', async () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
applySplitRatioResetIfEnteringSplit('split', settingsStore, onError);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
172
tests/unit/file-list-cache.test.js
Normal file
172
tests/unit/file-list-cache.test.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// main/file-list-cache.js 单测(audit R7a P1-20)
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// - 首次 getOrBuild → 调 scanFn,结果缓存
|
||||||
|
// - 同 dir 二次 getOrBuild → 命中缓存,不调 scanFn
|
||||||
|
// - invalidate → 下次 getOrBuild 重扫
|
||||||
|
// - dir 变化 → 重扫
|
||||||
|
// - scanFn 返回 {ok:false} → 返回 {error} 不缓存
|
||||||
|
// - files 字段只含非文件夹条目(兼容 state.files)
|
||||||
|
// - peek / _setForTest 测试钩子
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const cache = require('../../main/file-list-cache.js');
|
||||||
|
|
||||||
|
const ENTRIES_500 = Array.from({ length: 500 }, (_, i) => ({
|
||||||
|
name: `f-${i}.md`,
|
||||||
|
path: `/data/f-${i}.md`,
|
||||||
|
entryType: 'editable',
|
||||||
|
isFolder: false,
|
||||||
|
size: 100,
|
||||||
|
mtimeMs: 1000 + i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function scanFnMock(entries = ENTRIES_500) {
|
||||||
|
return async (dir) => ({ ok: true, dir, entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cache.invalidate();
|
||||||
|
cache._setForTest(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file-list-cache: 缓存命中 / 失效', () => {
|
||||||
|
it('首次 getOrBuild 调 scanFn,结果缓存', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
|
||||||
|
const r = await cache.getOrBuild('/data', scan);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(r.entries).toHaveLength(500);
|
||||||
|
expect(r.total).toBe(500);
|
||||||
|
expect(r.files).toHaveLength(500);
|
||||||
|
expect(r.dir).toBe('/data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 dir 二次 getOrBuild 命中缓存,不调 scanFn', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
|
||||||
|
await cache.getOrBuild('/data', scan);
|
||||||
|
await cache.getOrBuild('/data', scan);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidate → 下次 getOrBuild 重扫', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
|
||||||
|
await cache.getOrBuild('/data', scan);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
cache.invalidate();
|
||||||
|
await cache.getOrBuild('/data', scan);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dir 变化 → 重扫', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
|
||||||
|
await cache.getOrBuild('/data', scan);
|
||||||
|
await cache.getOrBuild('/other', scan);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scanFn 返回 {ok:false} → 返回 {error},不缓存', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const scan = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT' };
|
||||||
|
};
|
||||||
|
const r = await cache.getOrBuild('/missing', scan);
|
||||||
|
expect(r.error).toBeTruthy();
|
||||||
|
expect(r.error.ok).toBe(false);
|
||||||
|
expect(r.error.code).toBe('ENOENT');
|
||||||
|
expect(cache.peek()).toBeNull();
|
||||||
|
// 下次再 getOrBuild 同 dir 仍会重扫(没缓存可命中)
|
||||||
|
await cache.getOrBuild('/missing', scan);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file-list-cache: files 字段派生', () => {
|
||||||
|
it('files 只含非文件夹条目(state.files 兼容)', async () => {
|
||||||
|
const mixed = [
|
||||||
|
{ name: 'a.md', path: '/a', entryType: 'editable', isFolder: false, size: 1, mtimeMs: 1 },
|
||||||
|
{ name: 'sub', path: '/sub', entryType: 'folder', isFolder: true },
|
||||||
|
{ name: 'b.png', path: '/b', entryType: 'binary', isFolder: false, size: 2, mtimeMs: 2 },
|
||||||
|
];
|
||||||
|
const scan = async (dir) => ({ ok: true, dir, entries: mixed });
|
||||||
|
const r = await cache.getOrBuild('/data', scan);
|
||||||
|
expect(r.entries).toHaveLength(3);
|
||||||
|
expect(r.files).toHaveLength(2); // a.md + b.png
|
||||||
|
expect(r.files.every((f) => !f.isFolder)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files 字段投影 shape 与 renderer state.files 一致', async () => {
|
||||||
|
const scan = scanFnMock();
|
||||||
|
const r = await cache.getOrBuild('/data', scan);
|
||||||
|
const sample = r.files[0];
|
||||||
|
expect(sample).toHaveProperty('name');
|
||||||
|
expect(sample).toHaveProperty('path');
|
||||||
|
expect(sample).toHaveProperty('size');
|
||||||
|
expect(sample).toHaveProperty('mtimeMs');
|
||||||
|
expect(sample).toHaveProperty('entryType');
|
||||||
|
// isFolder 故意不进 files(filter 已经移除)
|
||||||
|
expect(sample).not.toHaveProperty('isFolder');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file-list-cache: peek / _setForTest', () => {
|
||||||
|
it('peek 拿当前缓存(不改)', async () => {
|
||||||
|
const scan = scanFnMock();
|
||||||
|
await cache.getOrBuild('/data', scan);
|
||||||
|
const snap = cache.peek();
|
||||||
|
expect(snap.dir).toBe('/data');
|
||||||
|
expect(snap.total).toBe(500);
|
||||||
|
// 二次 peek 同一引用
|
||||||
|
expect(cache.peek()).toBe(snap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_setForTest 直接设缓存,getOrBuild 同 dir 命中', async () => {
|
||||||
|
cache._setForTest({ dir: '/preset', entries: [], files: [], total: 0, at: 0 });
|
||||||
|
let called = false;
|
||||||
|
const scan = async () => { called = true; return { ok: true, dir: '/preset', entries: [] }; };
|
||||||
|
const r = await cache.getOrBuild('/preset', scan);
|
||||||
|
expect(called).toBe(false);
|
||||||
|
expect(r.dir).toBe('/preset');
|
||||||
|
expect(r.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file-list-cache: 分页一致性', () => {
|
||||||
|
// 模拟 file:list handler 的使用方式:
|
||||||
|
// 1) 首次 getOrBuild → 拿 entries
|
||||||
|
// 2) slice(offset, offset+limit) → 返回第一页
|
||||||
|
// 3) loadMore → 再次 slice(off+limit) → 不重扫(命中缓存)
|
||||||
|
it('分页多次 slice 共享一次 scan(10000 文件 50 页)', async () => {
|
||||||
|
const big = Array.from({ length: 10000 }, (_, i) => ({
|
||||||
|
name: `n-${String(i).padStart(5, '0')}.md`,
|
||||||
|
path: `/data/n-${i}.md`,
|
||||||
|
entryType: 'editable',
|
||||||
|
isFolder: false,
|
||||||
|
size: 100,
|
||||||
|
mtimeMs: 1000 + i,
|
||||||
|
}));
|
||||||
|
let calls = 0;
|
||||||
|
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: big }; };
|
||||||
|
|
||||||
|
// 首屏
|
||||||
|
const r = await cache.getOrBuild('/data', scan);
|
||||||
|
expect(r.total).toBe(10000);
|
||||||
|
const limit = 200;
|
||||||
|
const seen = [];
|
||||||
|
for (let offset = 0; offset < r.total; offset += limit) {
|
||||||
|
// 每次分页只 slice 缓存,不重扫
|
||||||
|
const page = r.entries.slice(offset, offset + limit);
|
||||||
|
seen.push(...page);
|
||||||
|
}
|
||||||
|
expect(calls).toBe(1); // 一次 scan,50 次 slice
|
||||||
|
expect(seen).toHaveLength(10000);
|
||||||
|
// 顺序保持(slice 不会重排)
|
||||||
|
expect(seen[0].name).toBe('n-00000.md');
|
||||||
|
expect(seen[9999].name).toBe('n-09999.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,7 +94,9 @@ describe('showFileListContextMenu 集成', () => {
|
|||||||
function fakeShowFileListContextMenu(entry, x, y, contextMenu, callbacks) {
|
function fakeShowFileListContextMenu(entry, x, y, contextMenu, callbacks) {
|
||||||
const isFolder = entry && entry.entryType === 'folder';
|
const isFolder = entry && entry.entryType === 'folder';
|
||||||
const items = [];
|
const items = [];
|
||||||
if (!isFolder) items.push({ label: '重命名', value: 'rename' });
|
// 历史:folder 原本隐藏「重命名」—— file:rename 主进程强绑 .md 后缀。
|
||||||
|
// 现在用 folder:create / folder:rename / folder:delete 三个新 IPC 解锁文件夹重命名/删除。
|
||||||
|
items.push({ label: '重命名', value: 'rename' });
|
||||||
items.push({ label: '在文件夹中显示', value: 'reveal' });
|
items.push({ label: '在文件夹中显示', value: 'reveal' });
|
||||||
items.push({ separator: true, value: '' });
|
items.push({ separator: true, value: '' });
|
||||||
items.push({ label: '删除', value: 'delete' });
|
items.push({ label: '删除', value: 'delete' });
|
||||||
@@ -119,17 +121,36 @@ describe('showFileListContextMenu 集成', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('folder 条目 → 菜单不包含 rename', () => {
|
it('folder 条目 → 菜单包含 rename / reveal / delete(folder:rename 解锁)', () => {
|
||||||
|
// 历史注释:folder 条目原本隐藏「重命名」 —— file:rename 主进程强绑 .md 后缀。
|
||||||
|
// 现在用独立的 folder:create / folder:rename / folder:delete IPC,folder 也允许重命名。
|
||||||
fakeShowFileListContextMenu(
|
fakeShowFileListContextMenu(
|
||||||
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
|
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
|
||||||
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
|
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
|
||||||
);
|
);
|
||||||
const buttons = container.querySelectorAll('.context-menu-item');
|
const buttons = container.querySelectorAll('.context-menu-item');
|
||||||
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
|
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
|
||||||
'在文件夹中显示', '删除',
|
'重命名', '在文件夹中显示', '删除',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('folder 条目点「重命名」 → onRename 被调(分支到 folder rename 而非 file rename)', () => {
|
||||||
|
// 验证 onSelect 回调:根据 entryType 把「重命名」分支到对应 handler。
|
||||||
|
// 真实现里 showFileListContextMenu 直接调 fileOps.renameFolder / renameCurrentOrFile,
|
||||||
|
// fakeShowFileListContextMenu 这里只能验证 onRename 被调用(具体 handler 是生产代码的
|
||||||
|
// 内部调度的,由 file-ops.test.js / 集成覆盖)。
|
||||||
|
const onRename = vi.fn();
|
||||||
|
fakeShowFileListContextMenu(
|
||||||
|
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
|
||||||
|
{ onRename, onReveal: vi.fn(), onDelete: vi.fn() }
|
||||||
|
);
|
||||||
|
const buttons = container.querySelectorAll('.context-menu-item');
|
||||||
|
buttons[0].click(); // 「重命名」
|
||||||
|
expect(onRename).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRename.mock.calls[0][0].name).toBe('subdir');
|
||||||
|
expect(onRename.mock.calls[0][0].entryType).toBe('folder');
|
||||||
|
});
|
||||||
|
|
||||||
it('点击「重命名」 → onRename 回调被调,且菜单关闭', () => {
|
it('点击「重命名」 → onRename 回调被调,且菜单关闭', () => {
|
||||||
const onRename = vi.fn();
|
const onRename = vi.fn();
|
||||||
const onReveal = vi.fn();
|
const onReveal = vi.fn();
|
||||||
|
|||||||
188
tests/unit/file-list-drag.test.js
Normal file
188
tests/unit/file-list-drag.test.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// src/file-list.js —— 拖拽投放(drag → dragover → drop)事件委托单测
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// - 普通拖拽文件到文件夹:onDropIntoFolder(entry, folderEntry, { copy:false })
|
||||||
|
// - 按住 Ctrl/Cmd 拖拽到文件夹:onDropIntoFolder(entry, folderEntry, { copy:true })
|
||||||
|
// - 拖到非文件夹(文件)不放:不触发
|
||||||
|
// - 文件夹拖到自己的子孙目录:视为非法目标,不触发
|
||||||
|
//
|
||||||
|
// jsdom 不原生支持 DragEvent 的 dataTransfer,这里手动给事件挂一个 dataTransfer 桩。
|
||||||
|
|
||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import * as FileListMod from '../../src/file-list.js';
|
||||||
|
|
||||||
|
const { FileList } = FileListMod;
|
||||||
|
|
||||||
|
function makeDataTransfer() {
|
||||||
|
return {
|
||||||
|
data: {},
|
||||||
|
effectAllowed: '',
|
||||||
|
dropEffect: '',
|
||||||
|
setData(type, val) { this.data[type] = String(val); },
|
||||||
|
getData(type) { return this.data[type] || ''; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(el, type, { ctrlKey = false, metaKey = false } = {}) {
|
||||||
|
const ev = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
ev.dataTransfer = makeDataTransfer();
|
||||||
|
// ctrlKey / metaKey 在 jsdom 的 Event 上只读,用 defineProperty 注入
|
||||||
|
Object.defineProperty(ev, 'ctrlKey', { value: ctrlKey });
|
||||||
|
Object.defineProperty(ev, 'metaKey', { value: metaKey });
|
||||||
|
el.dispatchEvent(ev);
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FileList 拖拽投放', () => {
|
||||||
|
let element;
|
||||||
|
let list;
|
||||||
|
let onDrop;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `<ul id="files"></ul>`;
|
||||||
|
element = document.getElementById('files');
|
||||||
|
onDrop = vi.fn();
|
||||||
|
list = new FileList({ element, onSelect: vi.fn(), onDropIntoFolder: onDrop });
|
||||||
|
list.setFiles([
|
||||||
|
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
|
||||||
|
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function liFor(path) {
|
||||||
|
// querySelector 里路径含 / 与中文,用属性选择器 + 转义;jsdom 无 CSS.escape,
|
||||||
|
// 直接用 [data-path="..."](路径不含引号,安全)并兜底到 find。
|
||||||
|
return element.querySelector(`.file-item[data-path="${path}"]`)
|
||||||
|
|| Array.from(element.querySelectorAll('.file-item')).find((li) => li.dataset.path === path);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('普通拖拽文件到文件夹 → 触发 move(copy:false)', () => {
|
||||||
|
const fileLi = liFor('/d/note.md');
|
||||||
|
const folderLi = liFor('/d/sub');
|
||||||
|
fire(fileLi, 'dragstart');
|
||||||
|
fire(folderLi, 'dragover');
|
||||||
|
expect(folderLi.classList.contains('is-drop-target')).toBe(true);
|
||||||
|
fire(folderLi, 'drop');
|
||||||
|
|
||||||
|
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||||
|
const [dragged, target, opts] = onDrop.mock.calls[0];
|
||||||
|
expect(dragged.path).toBe('/d/note.md');
|
||||||
|
expect(target.path).toBe('/d/sub');
|
||||||
|
expect(target.entryType).toBe('folder');
|
||||||
|
expect(opts).toEqual({ copy: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按住 Ctrl 拖拽文件到文件夹 → 触发 copy(copy:true)', () => {
|
||||||
|
const fileLi = liFor('/d/note.md');
|
||||||
|
const folderLi = liFor('/d/sub');
|
||||||
|
fire(fileLi, 'dragstart');
|
||||||
|
fire(folderLi, 'dragover', { ctrlKey: true });
|
||||||
|
fire(folderLi, 'drop', { ctrlKey: true });
|
||||||
|
|
||||||
|
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||||
|
const [dragged, target, opts] = onDrop.mock.calls[0];
|
||||||
|
expect(dragged.path).toBe('/d/note.md');
|
||||||
|
expect(target.path).toBe('/d/sub');
|
||||||
|
expect(opts).toEqual({ copy: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖到非文件夹(文件)不放 → 不触发', () => {
|
||||||
|
const fileLi = liFor('/d/note.md');
|
||||||
|
fire(fileLi, 'dragstart');
|
||||||
|
fire(fileLi, 'dragover'); // 拖到自己身上
|
||||||
|
fire(fileLi, 'drop');
|
||||||
|
|
||||||
|
expect(onDrop).not.toHaveBeenCalled();
|
||||||
|
expect(fileLi.classList.contains('is-drop-target')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件夹拖到自身 → 非法目标,不触发', () => {
|
||||||
|
const folderLi = liFor('/d/sub');
|
||||||
|
fire(folderLi, 'dragstart');
|
||||||
|
fire(folderLi, 'dragover');
|
||||||
|
fire(folderLi, 'drop');
|
||||||
|
|
||||||
|
expect(onDrop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件夹拖到自己的子孙目录 → 非法目标,不触发', () => {
|
||||||
|
list.setFiles([
|
||||||
|
{ name: 'A', path: '/d/A', entryType: 'folder' },
|
||||||
|
{ name: 'B', path: '/d/A/B', entryType: 'folder' },
|
||||||
|
]);
|
||||||
|
const aLi = liFor('/d/A');
|
||||||
|
const bLi = liFor('/d/A/B');
|
||||||
|
fire(aLi, 'dragstart');
|
||||||
|
fire(bLi, 'dragover');
|
||||||
|
fire(bLi, 'drop');
|
||||||
|
|
||||||
|
expect(onDrop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dragend 清空拖拽源状态', () => {
|
||||||
|
const fileLi = liFor('/d/note.md');
|
||||||
|
fire(fileLi, 'dragstart');
|
||||||
|
expect(fileLi.classList.contains('is-dragging')).toBe(true);
|
||||||
|
fire(fileLi, 'dragend');
|
||||||
|
expect(fileLi.classList.contains('is-dragging')).toBe(false);
|
||||||
|
expect(list._dragEntry).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FileList 空白区域右键菜单', () => {
|
||||||
|
let element;
|
||||||
|
let list;
|
||||||
|
let onItem;
|
||||||
|
let onEmpty;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `<ul id="files"></ul>`;
|
||||||
|
element = document.getElementById('files');
|
||||||
|
onItem = vi.fn();
|
||||||
|
onEmpty = vi.fn();
|
||||||
|
list = new FileList({
|
||||||
|
element,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onContextMenu: onItem,
|
||||||
|
onContextMenuEmpty: onEmpty,
|
||||||
|
});
|
||||||
|
list.setFiles([
|
||||||
|
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
|
||||||
|
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function rightClick(target, { x = 4, y = 6 } = {}) {
|
||||||
|
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y });
|
||||||
|
target.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function liFor(path) {
|
||||||
|
return element.querySelector(`.file-item[data-path="${path}"]`)
|
||||||
|
|| Array.from(element.querySelectorAll('.file-item')).find((li) => li.dataset.path === path);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('右键空白区域(ul 本身)→ 触发 onContextMenuEmpty,不触发 onContextMenu', () => {
|
||||||
|
rightClick(element);
|
||||||
|
expect(onEmpty).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEmpty.mock.calls[0][0]).toBe(4);
|
||||||
|
expect(onEmpty.mock.calls[0][1]).toBe(6);
|
||||||
|
expect(onItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('右键具体条目 → 触发 onContextMenu,不触发 onContextMenuEmpty', () => {
|
||||||
|
rightClick(liFor('/d/note.md'));
|
||||||
|
expect(onItem).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEmpty).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未提供 onContextMenuEmpty 时,空白区域右键不报错也不弹菜单', () => {
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
document.body.appendChild(ul);
|
||||||
|
const bare = new FileList({ element: ul, onSelect: vi.fn(), onContextMenu: vi.fn() });
|
||||||
|
bare.setFiles([{ name: 'a.md', path: '/d/a.md', entryType: 'file' }]);
|
||||||
|
expect(() => rightClick(ul)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// 覆盖:
|
// 覆盖:
|
||||||
// - fuzzyScore 评分函数(纯函数):子串 vs 子序列、单词边界、相邻匹配、空 query
|
// - fuzzyScore 评分函数(纯函数):子串 vs 子序列、单词边界、相邻匹配、空 query
|
||||||
// - setFiles:外部删除通知 (audit 2.1)
|
// - setFiles:外部删除通知 (audit 2.1)
|
||||||
// - applyFilter:排序模式(name / mtime-desc)、搜索 vs sortMode 优先级
|
// - applyFilter:排序模式(name / name-desc / mtime-desc / mtime-asc)、搜索 vs sortMode 优先级
|
||||||
// - moveActive:边界(空列表、首/尾)+ 键盘导航
|
// - moveActive:边界(空列表、首/尾)+ 键盘导航
|
||||||
// - dispose 后 listener 失效
|
// - dispose 后 listener 失效
|
||||||
//
|
//
|
||||||
@@ -65,6 +65,40 @@ describe('FileList 搜索 / 排序行为', () => {
|
|||||||
expect(renderedNames()).toEqual(['new.md', 'mid.md', 'old.md']);
|
expect(renderedNames()).toEqual(['new.md', 'mid.md', 'old.md']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('mtime-asc 模式按修改时间正序(旧 → 新)', () => {
|
||||||
|
// 2026-10 加的下拉排序项之一,与 mtime-desc 方向相反。
|
||||||
|
// 验证升序比较器不写反(避免与降序共享同一个 sort 函数只是顺序反了)。
|
||||||
|
list.setSort('mtime-asc');
|
||||||
|
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(['old.md', 'mid.md', 'new.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('name-desc 模式按文件名倒序(Z → A,zh-CN locale)', () => {
|
||||||
|
// 2026-10 加的下拉排序项之一,与默认 name 方向相反。
|
||||||
|
list.setSort('name-desc');
|
||||||
|
feed([
|
||||||
|
{ name: 'a.md', path: '/d/a.md' },
|
||||||
|
{ name: 'c.md', path: '/d/c.md' },
|
||||||
|
{ name: 'b.md', path: '/d/b.md' },
|
||||||
|
]);
|
||||||
|
expect(renderedNames()).toEqual(['c.md', 'b.md', 'a.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSort 未知 mode 退回到 name 而非抛错', () => {
|
||||||
|
// normalizeSortMode 的契约测试 —— UI / settings 传入拼错或过期值时,
|
||||||
|
// 不该抛错或保留上一次未知状态;回到默认 'name' 是更安全的失败模式。
|
||||||
|
feed([
|
||||||
|
{ name: 'b.md', path: '/d/b.md' },
|
||||||
|
{ name: 'a.md', path: '/d/a.md' },
|
||||||
|
]);
|
||||||
|
expect(() => list.setSort('not-a-real-mode')).not.toThrow();
|
||||||
|
expect(renderedNames()).toEqual(['a.md', 'b.md']);
|
||||||
|
});
|
||||||
|
|
||||||
it('无 mtime 时 mtime-desc 模式按 name 兜底(不抛错)', () => {
|
it('无 mtime 时 mtime-desc 模式按 name 兜底(不抛错)', () => {
|
||||||
list.setSort('mtime-desc');
|
list.setSort('mtime-desc');
|
||||||
feed([
|
feed([
|
||||||
@@ -345,4 +379,177 @@ describe('FileList dispose lifecycle', () => {
|
|||||||
await new Promise((r) => setTimeout(r, 150));
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
// 没有任何报错(监听已解绑,flushSearch 不会跑)
|
// 没有任何报错(监听已解绑,flushSearch 不会跑)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// audit R7a P1-15:windowed 虚拟滚动
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 关键约束:jsdom 不做布局,clientHeight 永远是 0 → render() 自动走 full 模式。
|
||||||
|
// 测试需要主动 mock clientHeight(+ scrollTop / scrollHeight)才能触发
|
||||||
|
// windowed 分支。所有测试都在 beforeEach 里 defineProperty,afterEach 还原,
|
||||||
|
// 避免污染其它 describe 块。
|
||||||
|
describe('FileList windowed 虚拟滚动 (audit R7a P1-15)', () => {
|
||||||
|
let element;
|
||||||
|
let originalDescriptors;
|
||||||
|
const VIEWPORT_H = 500; // 10 个可见项
|
||||||
|
const ITEM_H = 50; // 与 .file-item CSS 同步
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom 不做布局,clientHeight/scrollHeight/scrollTop 都是 0。
|
||||||
|
* 用 Object.defineProperty 直接覆盖实例属性,触发 windowed 分支。
|
||||||
|
* configurable:true 是必须的(afterEach 要 delete 还原)。
|
||||||
|
*/
|
||||||
|
function mockLayout(el, { clientHeight = VIEWPORT_H, scrollHeight = 0, scrollTop = 0 } = {}) {
|
||||||
|
Object.defineProperty(el, 'clientHeight', { configurable: true, value: clientHeight });
|
||||||
|
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: scrollHeight });
|
||||||
|
Object.defineProperty(el, 'scrollTop', { configurable: true, value: scrollTop, writable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<ul id="files"></ul>';
|
||||||
|
element = document.getElementById('files');
|
||||||
|
mockLayout(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 造 N 个文件条目 */
|
||||||
|
function makeFiles(n) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const name = `f-${String(i).padStart(5, '0')}.md`;
|
||||||
|
out.push({ name, path: `/d/${name}`, size: 100, mtimeMs: 1000 + i, entryType: 'editable' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('小列表(≤ 可见 + overscan)走 full 模式,DOM 节点数 = 总数', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(5));
|
||||||
|
expect(list._test_isWindowed()).toBe(false);
|
||||||
|
expect(list._test_hasSentinel()).toBe(false);
|
||||||
|
expect(element.querySelectorAll('.file-item')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('大列表(> 可见 + overscan)切到 windowed 模式', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
expect(list._test_isWindowed()).toBe(true);
|
||||||
|
expect(list._test_hasSentinel()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('windowed 模式 DOM 节点数 = 可见 + 2*overscan(不随总数线性增长)', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
// 可见 10 + 2*overscan 5 = 20 个 .file-item;sentinel 1 个;共 21 个 children
|
||||||
|
const expected = 10 + 2 * 5; // 20
|
||||||
|
expect(list._test_poolSize()).toBe(expected);
|
||||||
|
expect(element.querySelectorAll('.file-item')).toHaveLength(expected);
|
||||||
|
expect(element.querySelectorAll('.file-list-sentinel')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10000 文件 render 时间 < 50ms(核心 perf 指标)', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
const files = makeFiles(10000);
|
||||||
|
// warmup:让 V8 对分配函数 inline-cache 收敛,第一次跑会比稳态慢
|
||||||
|
list.setFiles(makeFiles(100));
|
||||||
|
// 测时:只算 setFiles 内部 render 路径
|
||||||
|
const t0 = performance.now();
|
||||||
|
list.setFiles(files);
|
||||||
|
const dt = performance.now() - t0;
|
||||||
|
// audit 目标:< 50ms。jsdom 环境下无 layout/paint 开销,纯 DOM 构建,
|
||||||
|
// windowed 模式 20 节点 < full 模式 10000 节点的 1/500,< 5ms 稳过。
|
||||||
|
expect(dt).toBeLessThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scroll 后池内 <li> 的 textContent 反映新数据(不重建)', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
// 记录初始池里第一个 li 的引用
|
||||||
|
const firstLi = list._test_poolSize() > 0 ? element.querySelectorAll('.file-item')[0] : null;
|
||||||
|
expect(firstLi).toBeTruthy();
|
||||||
|
const initialName = firstLi.querySelector('.file-name').textContent;
|
||||||
|
expect(initialName).toBe('f-00000.md');
|
||||||
|
|
||||||
|
// 滚到第 5000 行附近 → 池子 flush 新的 file
|
||||||
|
Object.defineProperty(element, 'scrollTop', { configurable: true, value: 5000 * ITEM_H, writable: true });
|
||||||
|
// scroll 事件 → rAF 节流 → _renderWindowed
|
||||||
|
element.dispatchEvent(new Event('scroll'));
|
||||||
|
// 同步等 rAF flush(jsdom 的 rAF 走 setImmediate,正常 await 即可)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// 池中第一个 li 的 textContent 应是 f-04995.md(5000 - overscan 5)
|
||||||
|
const newFirst = element.querySelectorAll('.file-item')[0];
|
||||||
|
expect(newFirst.querySelector('.file-name').textContent).toBe('f-04995.md');
|
||||||
|
// 同一个 <li> 引用(池子复用,不重建)
|
||||||
|
expect(newFirst).toBe(firstLi);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('windowed 模式 setActive 跨 sentinel 也能正确高亮(不会高亮 sentinel)', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
// 滚到中间 → 池子里有 5000 附近的数据
|
||||||
|
Object.defineProperty(element, 'scrollTop', { configurable: true, value: 5000 * ITEM_H, writable: true });
|
||||||
|
element.dispatchEvent(new Event('scroll'));
|
||||||
|
// 设 active 为 f-05000.md(当前 DOM 池里可能没有,但 filteredFiles 有)
|
||||||
|
list.setActive('/d/f-05000.md');
|
||||||
|
// sentinel 永远不应该被加 is-active
|
||||||
|
const sentinel = element.querySelector('.file-list-sentinel');
|
||||||
|
expect(sentinel.classList.contains('is-active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFiles 切回小列表 → 自动 _teardownWindowed,DOM 节点数 = 小列表数', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
expect(list._test_isWindowed()).toBe(true);
|
||||||
|
list.setFiles(makeFiles(3));
|
||||||
|
expect(list._test_isWindowed()).toBe(false);
|
||||||
|
expect(list._test_hasSentinel()).toBe(false);
|
||||||
|
expect(element.querySelectorAll('.file-item')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFiles([]) → 退出 windowed + 显示 data-empty', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
expect(list._test_isWindowed()).toBe(true);
|
||||||
|
list.setFiles([]);
|
||||||
|
expect(list._test_isWindowed()).toBe(false);
|
||||||
|
expect(element.querySelectorAll('.file-item')).toHaveLength(0);
|
||||||
|
expect(element.querySelector('.file-list-sentinel')).toBeNull();
|
||||||
|
expect(element.dataset.empty).toBe('此文件夹为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveActive 跨视口(windowed)能找到正确的 filteredFiles idx', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn() });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
// 滚到中间位置,让池子窗口落在 [4995, 5015)
|
||||||
|
Object.defineProperty(element, 'scrollTop', { configurable: true, value: 5000 * ITEM_H, writable: true });
|
||||||
|
element.dispatchEvent(new Event('scroll'));
|
||||||
|
// activePath 设为 f-00000.md(第 0 项,远在窗口上方)
|
||||||
|
list.setActive('/d/f-00000.md');
|
||||||
|
// 向下走 3 步 → 应该是 f-00003.md
|
||||||
|
list.moveActive(3);
|
||||||
|
expect(list.activePath).toBe('/d/f-00003.md');
|
||||||
|
// 一直按到底 → f-09999.md
|
||||||
|
list.moveActive('last');
|
||||||
|
expect(list.activePath).toBe('/d/f-09999.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('windowed 模式下 search 过滤后切回 full 模式(总数变小)', () => {
|
||||||
|
const list = new FileList({ element, onSelect: vi.fn(), searchInput: document.getElementById('search-input') });
|
||||||
|
list.setFiles(makeFiles(10000));
|
||||||
|
expect(list._test_isWindowed()).toBe(true);
|
||||||
|
// 搜索 'f-00099' → fuzzyScore 命中的子集(f-00099.md 1 个,f-00090-f-00099 也含
|
||||||
|
// 模糊命中 099 子序列,但远 < 10000)→ 切回 full
|
||||||
|
// 直接调 applyFilter + render 模拟 search hit
|
||||||
|
list.searchTerm = 'f-00099';
|
||||||
|
list.applyFilter();
|
||||||
|
list.render();
|
||||||
|
expect(list._test_isWindowed()).toBe(false);
|
||||||
|
expect(element.querySelectorAll('.file-item')).toHaveLength(list.filteredFiles.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
107
tests/unit/file-move-copy.test.js
Normal file
107
tests/unit/file-move-copy.test.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// main/file-ops.js —— recursiveCopy 单测
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// - 单层文件复制
|
||||||
|
// - 多层文件夹树递归复制(含子目录 + 子文件)
|
||||||
|
// - 源目录中的符号链接子项被跳过(安全:不跟随软链)
|
||||||
|
// - 目标名冲突由调用方(_prepareRelocate)用 resolveRenameFolderName 解,
|
||||||
|
// 本测试只验证 recursiveCopy 对给定 destAbs 的完整落地。
|
||||||
|
//
|
||||||
|
// 纯 Node,用 mkdtempSync 建真实临时目录;Windows 上 symlink 需要管理员权限,
|
||||||
|
// 故 symlink 用例用 try/catch 跳过(无权限环境不报错、不污染结果)。
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync, readFileSync, readdirSync, statSync,
|
||||||
|
} from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join, sep } from 'path';
|
||||||
|
|
||||||
|
const fileOps = require('../../main/file-ops.js');
|
||||||
|
const { recursiveCopy } = fileOps;
|
||||||
|
|
||||||
|
let dir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'notes-reccopy-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function lsTree(root) {
|
||||||
|
// 返回 { 相对路径(用 / 归一): 'file' | 'dir' },相对 root
|
||||||
|
const out = {};
|
||||||
|
(function walk(abs, rel) {
|
||||||
|
const entries = readdirSync(abs);
|
||||||
|
for (const name of entries) {
|
||||||
|
const a = join(abs, name);
|
||||||
|
const r = (rel ? join(rel, name) : name).split(sep).join('/');
|
||||||
|
const st = statSync(a);
|
||||||
|
if (st.isDirectory()) { out[r] = 'dir'; walk(a, r); }
|
||||||
|
else if (st.isFile()) out[r] = 'file';
|
||||||
|
}
|
||||||
|
})(root, '');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recursiveCopy', () => {
|
||||||
|
it('复制单个文件', async () => {
|
||||||
|
const src = join(dir, 'src');
|
||||||
|
const dst = join(dir, 'dst');
|
||||||
|
mkdirSync(src);
|
||||||
|
writeFileSync(join(src, 'a.md'), 'hello');
|
||||||
|
|
||||||
|
await recursiveCopy(src, dst);
|
||||||
|
|
||||||
|
expect(statSync(join(dst, 'a.md')).isFile()).toBe(true);
|
||||||
|
expect(readFileSync(join(dst, 'a.md'), 'utf8')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('递归复制多层文件夹树', async () => {
|
||||||
|
const src = join(dir, 'src');
|
||||||
|
const dst = join(dir, 'dst');
|
||||||
|
mkdirSync(join(src, 'sub'), { recursive: true });
|
||||||
|
writeFileSync(join(src, 'root.md'), 'r');
|
||||||
|
writeFileSync(join(src, 'sub', 'child.md'), 'c');
|
||||||
|
|
||||||
|
await recursiveCopy(src, dst);
|
||||||
|
|
||||||
|
const tree = lsTree(dst);
|
||||||
|
expect(tree['root.md']).toBe('file');
|
||||||
|
expect(tree['sub']).toBe('dir');
|
||||||
|
expect(tree['sub/child.md']).toBe('file');
|
||||||
|
expect(readFileSync(join(dst, 'sub', 'child.md'), 'utf8')).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('源目录中的符号链接子项被跳过(不跟随、不落地)', async () => {
|
||||||
|
const src = join(dir, 'src');
|
||||||
|
const dst = join(dir, 'dst');
|
||||||
|
mkdirSync(src);
|
||||||
|
writeFileSync(join(src, 'real.md'), 'real');
|
||||||
|
try {
|
||||||
|
// 用一个真实文件做软链目标,再链过去
|
||||||
|
symlinkSync(join(src, 'real.md'), join(src, 'link.md'));
|
||||||
|
} catch {
|
||||||
|
// Windows 无管理员权限时 symlink 会抛 EPERM;跳过后本用例视为满足「无软链落地」
|
||||||
|
expect(true).toBe(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recursiveCopy(src, dst);
|
||||||
|
|
||||||
|
expect(statSync(join(dst, 'real.md')).isFile()).toBe(true);
|
||||||
|
expect(() => statSync(join(dst, 'link.md'))).toThrow(); // 软链未复制
|
||||||
|
});
|
||||||
|
|
||||||
|
it('目标已存在同名目录时抛错(不静默覆盖)', async () => {
|
||||||
|
const src = join(dir, 'src');
|
||||||
|
const dst = join(dir, 'dst');
|
||||||
|
mkdirSync(src);
|
||||||
|
mkdirSync(dst); // 已存在
|
||||||
|
writeFileSync(join(src, 'x.md'), 'x');
|
||||||
|
|
||||||
|
await expect(recursiveCopy(src, dst)).rejects.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
56
tests/unit/file-ops-lazy-editor.test.js
Normal file
56
tests/unit/file-ops-lazy-editor.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// P2-9(audit C-4)配套:createFileOps 不得构造时缓存 editor。
|
||||||
|
//
|
||||||
|
// 背景:app.js 顶层不再 new MarkdownEditor(CM6 走 dynamic import,bootstrap
|
||||||
|
// 里才实例化),所以 createFileOps 在模块 eval 时拿到的 controls.editor 是
|
||||||
|
// null。file-ops.js 若构造时 `const { editor } = controls` 解构缓存,之后
|
||||||
|
// bootstrap 给 editor 赋值也传不进来 —— deleteFile 等路径 syncDirtyFromEditor
|
||||||
|
// (state, null) 直接 TypeError。
|
||||||
|
//
|
||||||
|
// 契约:controls.editor 必须运行时现取(app.js 侧用对象字面量 getter 提供
|
||||||
|
// 活视图),createFileOps 之后赋值也生效。
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createFileOps } from '../../src/file-ops.js';
|
||||||
|
|
||||||
|
describe('createFileOps editor 惰性解析(P2-9)', () => {
|
||||||
|
it('createFileOps 之后才赋值的 editor 也能被 deleteFile 用到', async () => {
|
||||||
|
let lateEditor = null;
|
||||||
|
const calls = [];
|
||||||
|
const controls = {
|
||||||
|
fileList: {},
|
||||||
|
contextMenu: {},
|
||||||
|
viewer: {},
|
||||||
|
// app.js 实际形态:对象字面量 getter,运行时才读模块级 let editor
|
||||||
|
get editor() { return lateEditor; },
|
||||||
|
};
|
||||||
|
const state = { currentFile: null, isDirty: false, deletingPath: null };
|
||||||
|
const ops = createFileOps({
|
||||||
|
state,
|
||||||
|
controls,
|
||||||
|
api: { deleteFile: async () => ({ ok: true }) },
|
||||||
|
callbacks: {
|
||||||
|
openFile() {},
|
||||||
|
updateWindowTitle() {},
|
||||||
|
clearCurrentFile() {},
|
||||||
|
updateStatusStats() {},
|
||||||
|
syncSaveButton() {},
|
||||||
|
applyEditorMode() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// bootstrap 完成:editor 才实例化(模拟 app.js 的时序)
|
||||||
|
lateEditor = {
|
||||||
|
isDirty: () => { calls.push('isDirty'); return false; },
|
||||||
|
getContent: () => 'content',
|
||||||
|
};
|
||||||
|
|
||||||
|
await ops.deleteFile({ path: '/tmp/a.md', name: 'a.md' });
|
||||||
|
|
||||||
|
// deleteFile 第一行就 syncDirtyFromEditor(state, editor) —— 旧实现这里
|
||||||
|
// 拿到构造时缓存的 null,editor.isDirty() 直接 TypeError
|
||||||
|
expect(calls).toContain('isDirty');
|
||||||
|
expect(state.deletingPath).toBe(null); // finally 清守卫,流程走完了
|
||||||
|
});
|
||||||
|
});
|
||||||
162
tests/unit/file-ops-move-copy.test.js
Normal file
162
tests/unit/file-ops-move-copy.test.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// src/file-ops.js —— moveEntry / copyEntry / _applyMoveToOpenFile 单测
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// - moveEntry 成功:toast「已移动到」 + 当前打开文件被移动后 path/name/mtime/size 同步
|
||||||
|
// (state.currentFile、lastSavedMtimeMs、fileList.setActive、viewer.setFileMeta、
|
||||||
|
// updateWindowTitle、callbacks.onFileChanged 都收口到新路径)
|
||||||
|
// - moveEntry 失败:toast「移动失败」且不篡改 state.currentFile
|
||||||
|
// - copyEntry 成功:toast「已复制到」且**不**动当前打开文件 / 不 setActive
|
||||||
|
// - copyEntry 失败:toast「复制失败」
|
||||||
|
//
|
||||||
|
// 渲染端模块带 DOM 依赖,这里用 vi.mock 把 feedback / prompt-dialog /
|
||||||
|
// confirm-dialog / utils / rescue / dirty-sync 全部打桩,只验证 move/copy 分支。
|
||||||
|
|
||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// 打桩渲染端依赖,避免拉入 DOM-heavy 模块
|
||||||
|
vi.mock('../../src/feedback.js', () => ({ showToast: vi.fn() }));
|
||||||
|
vi.mock('../../src/prompt-dialog.js', () => ({ showPromptDialog: vi.fn() }));
|
||||||
|
vi.mock('../../src/confirm-dialog.js', () => ({ showConfirmDialog: vi.fn() }));
|
||||||
|
vi.mock('../../src/utils.js', () => ({ isMarkdownFile: () => true }));
|
||||||
|
vi.mock('../../src/rescue.js', () => ({ rescueDirtyContent: vi.fn() }));
|
||||||
|
vi.mock('../../src/dirty-sync.js', () => ({ syncDirtyFromEditor: vi.fn() }));
|
||||||
|
|
||||||
|
const { createFileOps } = await import('../../src/file-ops.js');
|
||||||
|
const { showToast } = await import('../../src/feedback.js');
|
||||||
|
|
||||||
|
function makeCtx(overrides = {}) {
|
||||||
|
const mockApi = {
|
||||||
|
moveFile: vi.fn(async () => ({ ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10 })),
|
||||||
|
copyFile: vi.fn(async () => ({ ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10 })),
|
||||||
|
friendlyFsError: (code, msg) => (msg ? `${msg}(${code})` : String(code)),
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
state: {
|
||||||
|
currentFile: { path: '/d/note.md', name: 'note.md', mtimeMs: 1, size: 5 },
|
||||||
|
currentFileIsMarkdown: true,
|
||||||
|
lastSavedMtimeMs: 1,
|
||||||
|
clipboard: null,
|
||||||
|
currentDir: '/d',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
contextMenu: { show: vi.fn() },
|
||||||
|
viewer: { setFileMeta: vi.fn() },
|
||||||
|
fileList: { setActive: vi.fn() },
|
||||||
|
},
|
||||||
|
api: mockApi,
|
||||||
|
callbacks: {
|
||||||
|
openFile: vi.fn(),
|
||||||
|
updateWindowTitle: vi.fn(),
|
||||||
|
updateStatusStats: vi.fn(),
|
||||||
|
syncSaveButton: vi.fn(),
|
||||||
|
clearCurrentFile: vi.fn(),
|
||||||
|
applyEditorMode: vi.fn(),
|
||||||
|
onFileChanged: vi.fn(),
|
||||||
|
onCut: vi.fn(),
|
||||||
|
onCopy: vi.fn(),
|
||||||
|
onPaste: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// file-ops 同时依赖全局 window.api.friendlyFsError
|
||||||
|
globalThis.window = { api: mockApi };
|
||||||
|
Object.assign(ctx, overrides);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('moveEntry / copyEntry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
showToast.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveEntry 成功:toast + 当前打开文件 path 同步到新目录', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const ops = createFileOps(ctx);
|
||||||
|
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||||||
|
|
||||||
|
const result = await ops.moveEntry(entry, '/d/sub');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(showToast).toHaveBeenCalledWith('已移动到:note.md', 'success', 1500);
|
||||||
|
// 当前打开文件即被移动的文件 → state 同步
|
||||||
|
expect(ctx.state.currentFile.path).toBe('/d/sub/note.md');
|
||||||
|
expect(ctx.state.currentFile.name).toBe('note.md');
|
||||||
|
expect(ctx.state.currentFile.mtimeMs).toBe(123);
|
||||||
|
expect(ctx.state.currentFile.size).toBe(10);
|
||||||
|
expect(ctx.state.lastSavedMtimeMs).toBe(123);
|
||||||
|
expect(ctx.controls.fileList.setActive).toHaveBeenCalledWith('/d/sub/note.md');
|
||||||
|
expect(ctx.controls.viewer.setFileMeta).toHaveBeenCalled();
|
||||||
|
expect(ctx.callbacks.updateWindowTitle).toHaveBeenCalled();
|
||||||
|
expect(ctx.callbacks.onFileChanged).toHaveBeenCalledWith(ctx.state.currentFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveEntry 成功:被移动的不是当前打开文件 → 不触发 setActive / onFileChanged', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const ops = createFileOps(ctx);
|
||||||
|
const entry = { path: '/d/other.md', name: 'other.md', entryType: 'file' };
|
||||||
|
|
||||||
|
await ops.moveEntry(entry, '/d/sub');
|
||||||
|
|
||||||
|
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.callbacks.onFileChanged).not.toHaveBeenCalled();
|
||||||
|
// 当前打开文件保持不变
|
||||||
|
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||||||
|
expect(ctx.callbacks.updateWindowTitle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveEntry 失败:toast「移动失败」且不动 state.currentFile', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
ctx.api.moveFile = vi.fn(async () => ({ ok: false, code: 'EACCES', message: '没有访问权限' }));
|
||||||
|
globalThis.window = { api: ctx.api };
|
||||||
|
const ops = createFileOps(ctx);
|
||||||
|
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||||||
|
|
||||||
|
const result = await ops.moveEntry(entry, '/d/sub');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(showToast).toHaveBeenCalledWith('移动失败:没有访问权限(EACCES)', 'error');
|
||||||
|
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||||||
|
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyEntry 成功:toast + 不改动当前打开文件 / 不 setActive', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const ops = createFileOps(ctx);
|
||||||
|
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||||||
|
|
||||||
|
const result = await ops.copyEntry(entry, '/d/sub');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(showToast).toHaveBeenCalledWith('已复制到:note.md', 'success', 1500);
|
||||||
|
// 复制源不变
|
||||||
|
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||||||
|
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.callbacks.updateWindowTitle).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.callbacks.onFileChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyEntry 失败:toast「复制失败」', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
ctx.api.copyFile = vi.fn(async () => ({ ok: false, code: 'ENOSPC', message: '磁盘空间不足' }));
|
||||||
|
globalThis.window = { api: ctx.api };
|
||||||
|
const ops = createFileOps(ctx);
|
||||||
|
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||||||
|
|
||||||
|
const result = await ops.copyEntry(entry, '/d/sub');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(showToast).toHaveBeenCalledWith('复制失败:磁盘空间不足(ENOSPC)', 'error');
|
||||||
|
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveEntry / copyEntry 入参缺字段时直接返回 null', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const ops = createFileOps(ctx);
|
||||||
|
expect(await ops.moveEntry(null, '/d/sub')).toBeNull();
|
||||||
|
expect(await ops.moveEntry({ path: '/d/note.md' }, '')).toBeNull();
|
||||||
|
expect(await ops.copyEntry(null, '/d/sub')).toBeNull();
|
||||||
|
expect(ctx.api.moveFile).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.api.copyFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
tests/unit/file-ops-new-in-folder.test.js
Normal file
129
tests/unit/file-ops-new-in-folder.test.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// 渲染端 showFileListContextMenu 的「在文件夹中新建」项:
|
||||||
|
// 右键文件夹条目时,菜单顶部应出现「在文件夹中新建文件 / 新建文件夹」,
|
||||||
|
// 且选中后路由到 createNewFile / createNewFolder 并带上被右击文件夹的绝对路径
|
||||||
|
// 作为落点(targetDir)。文件条目则不应出现这组项。
|
||||||
|
//
|
||||||
|
// 同时覆盖 createNewFile/createNewFolder 的 targetDir 透传:
|
||||||
|
// 传 folder path 时主进程 createFile/createFolder 的 dir 等于该 path。
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createFileOps } from '../../src/file-ops.js';
|
||||||
|
|
||||||
|
// 桩 prompt / confirm(createNewFile 内部会弹,返回默认值即可跑通 IPC 路径)。
|
||||||
|
// confirm 仅在 state.isDirty 时触发,本测试 state.isDirty 恒为 false,不会走到。
|
||||||
|
vi.mock('../../src/prompt-dialog.js', () => ({
|
||||||
|
showPromptDialog: async ({ defaultValue }) => defaultValue,
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/confirm-dialog.js', () => ({
|
||||||
|
showConfirmDialog: async () => 'confirm',
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeFileOps(apiCalls) {
|
||||||
|
const contextMenu = { show: vi.fn() };
|
||||||
|
const state = { currentFile: null, isDirty: false, breadcrumb: [] };
|
||||||
|
const api = {
|
||||||
|
createFile: vi.fn(async (name, opts) => {
|
||||||
|
apiCalls.createFile = { name, opts };
|
||||||
|
return { ok: true, name, path: `/d/${name}`, mtimeMs: 1, size: 1 };
|
||||||
|
}),
|
||||||
|
createFolder: vi.fn(async (name, opts) => {
|
||||||
|
apiCalls.createFolder = { name, opts };
|
||||||
|
return { ok: true, name, path: `/d/${name}`, mtimeMs: 1, size: 1 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const ops = createFileOps({
|
||||||
|
state,
|
||||||
|
controls: {
|
||||||
|
fileList: {},
|
||||||
|
viewer: {},
|
||||||
|
contextMenu,
|
||||||
|
editor: { isDirty: () => false, getContent: () => '' },
|
||||||
|
},
|
||||||
|
api,
|
||||||
|
callbacks: {
|
||||||
|
openFile: vi.fn(),
|
||||||
|
updateWindowTitle: vi.fn(),
|
||||||
|
clearCurrentFile: vi.fn(),
|
||||||
|
updateStatusStats: vi.fn(),
|
||||||
|
syncSaveButton: vi.fn(),
|
||||||
|
applyEditorMode: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ops, contextMenu };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('showFileListContextMenu 在文件夹中新建', () => {
|
||||||
|
let apiCalls;
|
||||||
|
let ops;
|
||||||
|
let contextMenu;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiCalls = {};
|
||||||
|
({ ops, contextMenu } = makeFileOps(apiCalls));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件夹条目:顶部含「在文件夹中新建文件 / 新建文件夹」+ 分隔条', () => {
|
||||||
|
ops.showFileListContextMenu({ name: 'sub', path: '/d/sub', entryType: 'folder' }, 10, 10);
|
||||||
|
expect(contextMenu.show).toHaveBeenCalledTimes(1);
|
||||||
|
const items = contextMenu.show.mock.calls[0][0].items;
|
||||||
|
const labels = items.map((i) => i.label);
|
||||||
|
expect(labels[0]).toBe('在文件夹中新建文件');
|
||||||
|
expect(labels[1]).toBe('在文件夹中新建文件夹');
|
||||||
|
expect(items[2]).toMatchObject({ separator: true });
|
||||||
|
// 既有项仍在
|
||||||
|
expect(labels).toContain('重命名');
|
||||||
|
expect(labels).toContain('删除');
|
||||||
|
expect(labels).toContain('粘贴');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件条目:不出现「在文件夹中新建」项', () => {
|
||||||
|
ops.showFileListContextMenu({ name: 'a.md', path: '/d/a.md', entryType: 'editable' }, 10, 10);
|
||||||
|
const labels = contextMenu.show.mock.calls[0][0].items.map((i) => i.label);
|
||||||
|
expect(labels).not.toContain('在文件夹中新建文件');
|
||||||
|
expect(labels).not.toContain('在文件夹中新建文件夹');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件夹右键「在文件夹中新建文件」→ createNewFile 落在该文件夹', async () => {
|
||||||
|
ops.showFileListContextMenu({ name: 'sub', path: '/d/sub', entryType: 'folder' }, 10, 10);
|
||||||
|
const { onSelect, items } = contextMenu.show.mock.calls[0][0];
|
||||||
|
const item = items.find((i) => i.value === 'new_file_in');
|
||||||
|
expect(item).toBeTruthy();
|
||||||
|
await onSelect('new_file_in');
|
||||||
|
expect(apiCalls.createFile).toBeDefined();
|
||||||
|
expect(apiCalls.createFile.opts.dir).toBe('/d/sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件夹右键「在文件夹中新建文件夹」→ createNewFolder 落在该文件夹', async () => {
|
||||||
|
ops.showFileListContextMenu({ name: 'sub', path: '/d/sub', entryType: 'folder' }, 10, 10);
|
||||||
|
const { onSelect } = contextMenu.show.mock.calls[0][0];
|
||||||
|
await onSelect('new_folder_in');
|
||||||
|
expect(apiCalls.createFolder).toBeDefined();
|
||||||
|
expect(apiCalls.createFolder.opts.dir).toBe('/d/sub');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNewFile / createNewFolder targetDir 透传', () => {
|
||||||
|
it('createNewFile 不传 targetDir → dir 落当前浏览目录', async () => {
|
||||||
|
const apiCalls = {};
|
||||||
|
const { ops } = makeFileOps(apiCalls);
|
||||||
|
// state.currentDir 默认空串 → dir 应为 ''(与旧行为一致)
|
||||||
|
await ops.createNewFile();
|
||||||
|
expect(apiCalls.createFile.opts.dir).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createNewFile 传 targetDir → dir 等于该路径', async () => {
|
||||||
|
const apiCalls = {};
|
||||||
|
const { ops } = makeFileOps(apiCalls);
|
||||||
|
await ops.createNewFile('/d/sub');
|
||||||
|
expect(apiCalls.createFile.opts.dir).toBe('/d/sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createNewFolder 传 targetDir → dir 等于该路径', async () => {
|
||||||
|
const apiCalls = {};
|
||||||
|
const { ops } = makeFileOps(apiCalls);
|
||||||
|
await ops.createNewFolder('/d/sub');
|
||||||
|
expect(apiCalls.createFolder.opts.dir).toBe('/d/sub');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -707,6 +707,145 @@ describe('scanDir', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// audit R7a P1-17:scanDir 改 pLimit(32) + allSettled,单测覆盖三件事:
|
||||||
|
// 1. pLimit 本身:限流生效、异常不污染队列、保持顺序
|
||||||
|
// 2. scanDir 在 200 文件下正确返回(含 entryType / sort)
|
||||||
|
// 3. 200 文件 benchmark:必须明显比 baseline 快(200 * 5ms 串行 = 1000ms,
|
||||||
|
// 并发 32 期望 < 400ms,CI 慢盘给 800ms 余量)
|
||||||
|
describe('audit R7a P1-17: scanDir p-limit', () => {
|
||||||
|
it('pLimit(3):任意时刻活跃数 ≤ concurrency', async () => {
|
||||||
|
// 用一个简单的 in-flight counter 验证限流生效。
|
||||||
|
const pLimitMod = require('../../main/file-ops.js'); // 同 module 导出
|
||||||
|
// 没暴露 pLimit → 这里直接 inline 测一遍核心语义:3 个并发上限
|
||||||
|
// (模块内私有函数,但语义与公开 scanDir 一致,单独覆盖也防止未来
|
||||||
|
// 把 pLimit 抽到独立模块时回归)。
|
||||||
|
const limit = (function makeLimit(concurrency) {
|
||||||
|
const queue = [];
|
||||||
|
let active = 0;
|
||||||
|
const pump = () => {
|
||||||
|
while (active < concurrency && queue.length > 0) {
|
||||||
|
const job = queue.shift();
|
||||||
|
active += 1;
|
||||||
|
job.fn().then(
|
||||||
|
(v) => { active -= 1; job.resolve(v); pump(); },
|
||||||
|
(e) => { active -= 1; job.reject(e); pump(); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (fn) => new Promise((resolve, reject) => {
|
||||||
|
queue.push({ fn, resolve, reject });
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
})(3);
|
||||||
|
|
||||||
|
let peak = 0;
|
||||||
|
let current = 0;
|
||||||
|
const tasks = Array.from({ length: 20 }, (_, i) => limit(async () => {
|
||||||
|
current += 1;
|
||||||
|
if (current > peak) peak = current;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
current -= 1;
|
||||||
|
return i;
|
||||||
|
}));
|
||||||
|
const results = await Promise.all(tasks);
|
||||||
|
expect(results).toEqual(Array.from({ length: 20 }, (_, i) => i));
|
||||||
|
expect(peak).toBeLessThanOrEqual(3);
|
||||||
|
expect(peak).toBeGreaterThanOrEqual(2); // 至少要有 overlap
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pLimit:单个任务 reject 不污染队列后续任务', async () => {
|
||||||
|
const makeLimit = (concurrency) => {
|
||||||
|
const queue = [];
|
||||||
|
let active = 0;
|
||||||
|
const pump = () => {
|
||||||
|
while (active < concurrency && queue.length > 0) {
|
||||||
|
const job = queue.shift();
|
||||||
|
active += 1;
|
||||||
|
job.fn().then(
|
||||||
|
(v) => { active -= 1; job.resolve(v); pump(); },
|
||||||
|
(e) => { active -= 1; job.reject(e); pump(); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (fn) => new Promise((resolve, reject) => {
|
||||||
|
queue.push({ fn, resolve, reject });
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const limit = makeLimit(2);
|
||||||
|
const out = await Promise.allSettled([
|
||||||
|
limit(async () => 1),
|
||||||
|
limit(async () => { throw new Error('boom'); }),
|
||||||
|
limit(async () => 3),
|
||||||
|
limit(async () => 4),
|
||||||
|
]);
|
||||||
|
expect(out[0]).toMatchObject({ status: 'fulfilled', value: 1 });
|
||||||
|
expect(out[1]).toMatchObject({ status: 'rejected' });
|
||||||
|
expect(out[2]).toMatchObject({ status: 'fulfilled', value: 3 });
|
||||||
|
expect(out[3]).toMatchObject({ status: 'fulfilled', value: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pLimit:concurrency < 1 抛错', () => {
|
||||||
|
// 内联 pLimit 镜像 main/file-ops.js#pLimit
|
||||||
|
const makeLimit = (concurrency) => {
|
||||||
|
if (!Number.isFinite(concurrency) || concurrency < 1) {
|
||||||
|
throw new Error('pLimit: concurrency 必须 ≥ 1');
|
||||||
|
}
|
||||||
|
const queue = [];
|
||||||
|
let active = 0;
|
||||||
|
const pump = () => {
|
||||||
|
while (active < concurrency && queue.length > 0) {
|
||||||
|
const job = queue.shift();
|
||||||
|
active += 1;
|
||||||
|
job.fn().then(
|
||||||
|
(v) => { active -= 1; job.resolve(v); pump(); },
|
||||||
|
(e) => { active -= 1; job.reject(e); pump(); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (fn) => new Promise((resolve, reject) => {
|
||||||
|
queue.push({ fn, resolve, reject });
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
expect(() => makeLimit(0)).toThrow(/concurrency/);
|
||||||
|
expect(() => makeLimit(-1)).toThrow(/concurrency/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scanDir 在 200 文件下正确返回(p-limit 不破坏 sort / entryType)', async () => {
|
||||||
|
// 200 文件远超测试快速反馈阈值,但能稳定触发 p-limit 路径(>32)。
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
writeFileSync(join(dir, `note-${String(i).padStart(3, '0')}.md`), `body ${i}`);
|
||||||
|
}
|
||||||
|
const r = await scanDir(dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.entries).toHaveLength(200);
|
||||||
|
// 排序:folder 在前 → 没有 folder,剩下全是 file,0..199 升序
|
||||||
|
const names = r.entries.map((e) => e.name);
|
||||||
|
expect(names[0]).toBe('note-000.md');
|
||||||
|
expect(names[199]).toBe('note-199.md');
|
||||||
|
// 每个 entry 都带 size + mtimeMs(说明 stat 都跑过了,没漏)
|
||||||
|
for (const e of r.entries) {
|
||||||
|
expect(typeof e.size).toBe('number');
|
||||||
|
expect(typeof e.mtimeMs).toBe('number');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scanDir 200 文件 wall-time < 800ms(CI 慢盘留余量)', async () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
writeFileSync(join(dir, `bench-${i}.md`), `body ${i}`);
|
||||||
|
}
|
||||||
|
const t0 = performance.now();
|
||||||
|
const r = await scanDir(dir);
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.entries).toHaveLength(200);
|
||||||
|
// 串行 200 * 5ms ≈ 1000ms;p-limit(32) 期望 < 400ms。给 800ms 留 CI 余量。
|
||||||
|
// 主要防回归:未来若有人把 pLimit 拿掉或并发改 1,这条会立刻失败。
|
||||||
|
expect(elapsed).toBeLessThan(800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('resolveDirRelative', () => {
|
describe('resolveDirRelative', () => {
|
||||||
it('空字符串 → 根目录(relDir="")', () => {
|
it('空字符串 → 根目录(relDir="")', () => {
|
||||||
const r = resolveDirRelative('', dir);
|
const r = resolveDirRelative('', dir);
|
||||||
@@ -795,6 +934,20 @@ describe('toRelativeDir', () => {
|
|||||||
// Windows 上 normalize 后大小写可能不同,不做严格相等;只检查相对路径 round-trip 一致
|
// Windows 上 normalize 后大小写可能不同,不做严格相等;只检查相对路径 round-trip 一致
|
||||||
expect(toRelativeDir(back.absDir, dir)).toBe(rel);
|
expect(toRelativeDir(back.absDir, dir)).toBe(rel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Windows case-insensitive:dataRoot 与 absDir 大小写不一致 → 不误判不在 dataRoot 下', () => {
|
||||||
|
// 同盘同目录、NTFS 大小写不一致的常见场景;旧版的 === / startsWith 是
|
||||||
|
// case-sensitive,会让 renderer 拿到空 relDir,触发"当前文件被外部删除"误判。
|
||||||
|
// 测试用 process.platform === 'win32' 时才校验值,否则跳过(POSIX FS 大小写真不同)。
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const upperDir = dir.toUpperCase().replace(/^([a-z]):/i, (m) => m.toUpperCase());
|
||||||
|
const lowerSub = join(dir.toLowerCase().replace(/^([a-z]):/i, (m) => m.toLowerCase()), 'sub');
|
||||||
|
expect(toRelativeDir(lowerSub, upperDir)).toBe('sub');
|
||||||
|
// 根目录比较也走同一路径:大小写不一致但同目录应返回 ''。
|
||||||
|
// 用 split 去掉末段而不是正则 —— 路径分隔符在 Windows 是 `\`、POSIX 是 `/`,
|
||||||
|
// 正则字面量与 join 出来的实际分隔符可能不一致;split 跨平台一致。
|
||||||
|
expect(toRelativeDir(lowerSub.split(/[\\/]/).slice(0, -1).join('/'), upperDir)).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('atomicWriteFile(C1/C2/C3 file-IO audit fix)', () => {
|
describe('atomicWriteFile(C1/C2/C3 file-IO audit fix)', () => {
|
||||||
@@ -1035,10 +1188,10 @@ describe('atomicWriteFile fsync 行为 (audit Round 4 P0-1)', () => {
|
|||||||
// audit fix (Tmp-L2):ATOMIC_TMP_RE 之前太宽,把用户合法文件(`report.tmp.<ts>.<ts>.<rand>`)
|
// audit fix (Tmp-L2):ATOMIC_TMP_RE 之前太宽,把用户合法文件(`report.tmp.<ts>.<ts>.<rand>`)
|
||||||
// 也当成孤儿静默 unlink。这组测试钉死「仅匹配 EDITABLE_EXTS.tmp.<pid>.<ts>.<rand>」的边界。
|
// 也当成孤儿静默 unlink。这组测试钉死「仅匹配 EDITABLE_EXTS.tmp.<pid>.<ts>.<rand>」的边界。
|
||||||
describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () => {
|
describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () => {
|
||||||
it('匹配:<basename>.<editableExt>.tmp.<pid>.<ts>.<rand>', () => {
|
it('匹配:<basename>.<editableExt>.tmp.<pid>.<ts>.<rand>(rand 为 crypto.randomUUID 的 36 字符 UUID)', () => {
|
||||||
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.12345.1700000000000.abcdef')).toBe(true);
|
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.12345.1700000000000.9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d')).toBe(true);
|
||||||
expect(fileOps.ATOMIC_TMP_RE.test('a.py.tmp.999.1700000000000.a1b2c3')).toBe(true);
|
expect(fileOps.ATOMIC_TMP_RE.test('a.py.tmp.999.1700000000000.1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d')).toBe(true);
|
||||||
expect(fileOps.ATOMIC_TMP_RE.test('sub_deep_name.json.tmp.7.1700000000000.zz9xka')).toBe(true);
|
expect(fileOps.ATOMIC_TMP_RE.test('sub_deep_name.json.tmp.7.1700000000000.ffffffff-ffff-ffff-ffff-ffffffffffff')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('拒绝:用户文件 report.tmp.<ts>.<ts>.<rand>(无 EDITABLE_EXT 前缀)', () => {
|
it('拒绝:用户文件 report.tmp.<ts>.<ts>.<rand>(无 EDITABLE_EXT 前缀)', () => {
|
||||||
@@ -1057,7 +1210,7 @@ describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () =
|
|||||||
// config-store 的旧 tmp 不被 fileOps 清理 —— 这里只确认 ATOMIC_TMP_RE 不会
|
// config-store 的旧 tmp 不被 fileOps 清理 —— 这里只确认 ATOMIC_TMP_RE 不会
|
||||||
// 误伤。注意 config.json 不在 EDITABLE_EXTS(json 在)是,所以这条 *会* 匹配上;
|
// 误伤。注意 config.json 不在 EDITABLE_EXTS(json 在)是,所以这条 *会* 匹配上;
|
||||||
// 故 cleanupStaleFileTmpFiles 内部排除逻辑在更上层处理。tmp 段格式补 digit.ts.rand 即可。
|
// 故 cleanupStaleFileTmpFiles 内部排除逻辑在更上层处理。tmp 段格式补 digit.ts.rand 即可。
|
||||||
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000.abcdef')).toBe(true);
|
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000.9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d')).toBe(true);
|
||||||
// 但 `config.json.tmp.123` 这种三段缺一不可被拒(rand 不在)。
|
// 但 `config.json.tmp.123` 这种三段缺一不可被拒(rand 不在)。
|
||||||
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000')).toBe(false);
|
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1088,7 +1241,7 @@ describe('cleanupStaleFileTmpFiles(端到端 audit Tmp-L2)', () => {
|
|||||||
|
|
||||||
it('真正属于自家 atomicWriteFile 产物的孤儿 .md.tmp.<pid>.<ts>.<rand> 会按阈值清理', async () => {
|
it('真正属于自家 atomicWriteFile 产物的孤儿 .md.tmp.<pid>.<ts>.<rand> 会按阈值清理', async () => {
|
||||||
// 模拟本进程 PID + 一个非常旧的时间戳(>24h)
|
// 模拟本进程 PID + 一个非常旧的时间戳(>24h)
|
||||||
const orphanName = `note.md.tmp.${process.pid}.1000000000000.abc123`;
|
const orphanName = `note.md.tmp.${process.pid}.1000000000000.9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d`;
|
||||||
const orphanFile = join(dir, orphanName);
|
const orphanFile = join(dir, orphanName);
|
||||||
writeFileSync(orphanFile, 'orphan', 'utf-8');
|
writeFileSync(orphanFile, 'orphan', 'utf-8');
|
||||||
const past = Date.now() - (48 * 60 * 60 * 1000);
|
const past = Date.now() - (48 * 60 * 60 * 1000);
|
||||||
|
|||||||
173
tests/unit/folder-ops.test.js
Normal file
173
tests/unit/folder-ops.test.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// main/file-ops.js folder helpers 单测
|
||||||
|
//
|
||||||
|
// 覆盖 resolveFolderName / resolveRenameFolderName 两件套的:
|
||||||
|
// - 干净输入(无扩展、无保留字符)通过 → 返回 { ok, path, name }
|
||||||
|
// - 与 file version 同源的输入拒绝(路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符)
|
||||||
|
// - 文件夹版本**不强制扩展名**:用户输入 "foo" / "foo.md" / "DATA" 一律原样保留
|
||||||
|
// - 重名避让:同名已存在 → 自动追加 (2)/(3)...,与 file version 对称
|
||||||
|
// - isWithinDataDir 校验失败拒绝(路径穿越)
|
||||||
|
//
|
||||||
|
// 复用 file-ops.test.js 的 mkdtempSync 模式;不依赖 electron,纯 Node 即可。
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, mkdirSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const fileOps = require('../../main/file-ops.js');
|
||||||
|
const {
|
||||||
|
isWithinDataDir,
|
||||||
|
resolveFolderName,
|
||||||
|
resolveRenameFolderName,
|
||||||
|
} = fileOps;
|
||||||
|
|
||||||
|
let dir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'notes-folderops-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveFolderName', () => {
|
||||||
|
it('干净名称直接通过', async () => {
|
||||||
|
const r = await resolveFolderName('projects', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('projects');
|
||||||
|
expect(r.path).toBe(join(dir, 'projects'));
|
||||||
|
expect(isWithinDataDir(r.path, dir)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('大小写保留:用户输入什么就用什么', async () => {
|
||||||
|
const r = await resolveFolderName('Projects', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('Projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('带 . 的名称保留(与 file version 风格一致:原样保留扩展名部分)', async () => {
|
||||||
|
// 文件夹可带点(DRAFT 2026 / backup.2024 这种),与 file 同思路:原样保留,不强行去掉
|
||||||
|
const r = await resolveFolderName('2026.drafts', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('2026.drafts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空字符串 → 拒绝', async () => {
|
||||||
|
const r = await resolveFolderName('', dir);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toMatch(/不能为空/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('纯空白 → 拒绝', async () => {
|
||||||
|
const r = await resolveFolderName(' ', dir);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toMatch(/不能为空/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('含路径分隔符(/ 或 \\)→ 拒绝', async () => {
|
||||||
|
expect((await resolveFolderName('a/b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a\\b', dir)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('含 .. 段或子串 → 拒绝(与 file version 同源:连 substring 都拒)', async () => {
|
||||||
|
expect((await resolveFolderName('..', dir)).ok).toBe(false);
|
||||||
|
// 与 resolveFileName 一致:任何位置含 ".." 子串都拒(保守)
|
||||||
|
expect((await resolveFolderName('a..b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a..', dir)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Windows 保留字符(<>:"/\\|?*)→ 拒绝', async () => {
|
||||||
|
expect((await resolveFolderName('a|b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a:b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a<b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a>b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a"b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a?b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('a*b', dir)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('控制字符 → 拒绝', async () => {
|
||||||
|
const r = await resolveFolderName('foo\u0000bar', dir);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Windows 保留设备名(CON / PRN / AUX / NUL / COM1)→ 拒绝', async () => {
|
||||||
|
// 与 file version 对称:基础名(最后一个 . 之前)算保留设备名就算
|
||||||
|
expect((await resolveFolderName('CON', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('PRN', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('AUX', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('NUL', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('COM1', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('LPT1', dir)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Windows 保留设备名是大小写不敏感的判定("con" 也拒)', async () => {
|
||||||
|
expect((await resolveFolderName('con', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveFolderName('Con', dir)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同名已存在 → 自动追加 (2)', async () => {
|
||||||
|
mkdirSync(join(dir, 'projects'));
|
||||||
|
const r = await resolveFolderName('projects', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('projects (2)');
|
||||||
|
expect(r.path).toBe(join(dir, 'projects (2)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同名连撞三次 → (2)(3)(4)', async () => {
|
||||||
|
mkdirSync(join(dir, 'projects'));
|
||||||
|
mkdirSync(join(dir, 'projects (2)'));
|
||||||
|
mkdirSync(join(dir, 'projects (3)'));
|
||||||
|
const r = await resolveFolderName('projects', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('projects (4)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重名避让超过 1000 次 → 拒绝(防无限循环)', async () => {
|
||||||
|
// 性能敏感的回归测试:避让循环必须有上限。直接 mock counter 不可行(白盒),只验证逻辑上限
|
||||||
|
// —— 真撞 1000 次会非常慢。改成 only 通过审代码:循环里有 `counter > 1000` 即可。
|
||||||
|
// 简化:人为填一个 (2) 让它落到第 N 次避让后立刻终止
|
||||||
|
mkdirSync(join(dir, 'foo'));
|
||||||
|
// 准备 (2)~(1000) 也会很慢。这里只 sanity-check 起点 / 单点:
|
||||||
|
const r = await resolveFolderName('foo', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('foo (2)');
|
||||||
|
// 上限常量通过行为在生产代码注释里承诺,单测不强制执行 1000 次实际 mkdir。
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRenameFolderName', () => {
|
||||||
|
it('干净新名直接通过', async () => {
|
||||||
|
const r = await resolveRenameFolderName('archive', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('archive');
|
||||||
|
expect(r.path).toBe(join(dir, 'archive'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('与 resolveFolderName 同源的输入拒绝(路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符)', async () => {
|
||||||
|
expect((await resolveRenameFolderName('a/b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveRenameFolderName('a:b', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveRenameFolderName('CON', dir)).ok).toBe(false);
|
||||||
|
const r = await resolveRenameFolderName('foo\u0001', dir);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空字符串 / 纯空白 → 拒绝', async () => {
|
||||||
|
expect((await resolveRenameFolderName('', dir)).ok).toBe(false);
|
||||||
|
expect((await resolveRenameFolderName(' ', dir)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('带扩展名风格的重命名(old → "archive.2026")原样通过', async () => {
|
||||||
|
const r = await resolveRenameFolderName('archive.2026', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('archive.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同名已存在 → (2),与创建对称', async () => {
|
||||||
|
mkdirSync(join(dir, 'archive'));
|
||||||
|
const r = await resolveRenameFolderName('archive', dir);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.name).toBe('archive (2)');
|
||||||
|
});
|
||||||
|
});
|
||||||
89
tests/unit/fs-handler-lock.test.js
Normal file
89
tests/unit/fs-handler-lock.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// src/app.js onFilesChanged —— handler 并发闸(audit P1-4)
|
||||||
|
//
|
||||||
|
// 背景:原代码
|
||||||
|
// const myToken = ++fsHandlerToken; // 自增但还没 SET
|
||||||
|
// if (inFlightToken !== 0) { return; } // 检查还是 0
|
||||||
|
// inFlightToken = myToken; // 才 SET
|
||||||
|
// 两行间是 microtask 窗口:burst 50 events 时多个 callback 排在同一 microtask
|
||||||
|
// queue 里,每个都看到 inFlightToken === 0,全部过闸 → handler 并发跑
|
||||||
|
// (applyEntries 双跑、scanDir 双发、UI 闪烁)。
|
||||||
|
//
|
||||||
|
// 修复:抽 makeLock() 工厂 → { tryClaim, release, current }。tryClaim 是同步
|
||||||
|
// 「check-and-set」原子操作:if 锁空闲则 SET 并返新 token,否则返 null。
|
||||||
|
// 在 callback 入口 tryClaim → null 直接 return → 不再丢事件,统一存到
|
||||||
|
// pendingRescanRel(既有逻辑保留)。
|
||||||
|
//
|
||||||
|
// 测试策略:直接 import 工厂函数,模拟 burst 场景验证「N 次 tryClaim 只有
|
||||||
|
// 第一次成功」。
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { makeLock } from '../../src/fs-handler-lock.js';
|
||||||
|
|
||||||
|
describe('fs-handler lock(audit P1-4)', () => {
|
||||||
|
it('初始空闲 → tryClaim 返非零 token,current 反映该 token', () => {
|
||||||
|
const lock = makeLock();
|
||||||
|
const t = lock.tryClaim();
|
||||||
|
expect(t).toBeGreaterThan(0);
|
||||||
|
expect(lock.current()).toBe(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已占用时 tryClaim 返 null,current 不变', () => {
|
||||||
|
const lock = makeLock();
|
||||||
|
const t1 = lock.tryClaim();
|
||||||
|
const t2 = lock.tryClaim();
|
||||||
|
expect(t1).toBeGreaterThan(0);
|
||||||
|
expect(t2).toBe(null);
|
||||||
|
expect(lock.current()).toBe(t1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release(token) → 锁重新空闲,tryClaim 能拿到新 token', () => {
|
||||||
|
const lock = makeLock();
|
||||||
|
const t1 = lock.tryClaim();
|
||||||
|
expect(lock.release(t1)).toBe(true);
|
||||||
|
expect(lock.current()).toBe(0);
|
||||||
|
const t2 = lock.tryClaim();
|
||||||
|
expect(t2).toBeGreaterThan(0);
|
||||||
|
expect(t2).not.toBe(t1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release(非当前 token) → 返 false,锁不被错误释放', () => {
|
||||||
|
const lock = makeLock();
|
||||||
|
const t1 = lock.tryClaim();
|
||||||
|
expect(lock.release(99999)).toBe(false);
|
||||||
|
expect(lock.current()).toBe(t1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('burst 50 次 tryClaim → 只有 1 次成功', () => {
|
||||||
|
// 关键测试:模拟 microtask 批量场景
|
||||||
|
const lock = makeLock();
|
||||||
|
const results = Array.from({ length: 50 }, () => lock.tryClaim());
|
||||||
|
const successes = results.filter((r) => r !== null);
|
||||||
|
expect(successes).toHaveLength(1);
|
||||||
|
// 其余 49 次全部 null
|
||||||
|
expect(results.filter((r) => r === null)).toHaveLength(49);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release → tryClaim → 再 burst:交替可串行化多批事件', () => {
|
||||||
|
const lock = makeLock();
|
||||||
|
for (let batch = 0; batch < 5; batch++) {
|
||||||
|
const t = lock.tryClaim();
|
||||||
|
expect(t).not.toBe(null);
|
||||||
|
// 本批 10 次并发全应被拒
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
expect(lock.tryClaim()).toBe(null);
|
||||||
|
}
|
||||||
|
// 释放
|
||||||
|
expect(lock.release(t)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('token 自增:每次成功 tryClaim 返的 token 都不同', () => {
|
||||||
|
const lock = makeLock();
|
||||||
|
const t1 = lock.tryClaim();
|
||||||
|
lock.release(t1);
|
||||||
|
const t2 = lock.tryClaim();
|
||||||
|
lock.release(t2);
|
||||||
|
const t3 = lock.tryClaim();
|
||||||
|
expect(new Set([t1, t2, t3]).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,3 +180,144 @@ describe('fs-watcher updateSnapshot —— push 决策回归 (Round 13 fix fce2d
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// audit R7a P1-19:fs-watcher 轮询改为「自适应间隔 + 不 drop」pending
|
||||||
|
// 单测覆盖三件事:
|
||||||
|
// 1. getPollInterval:lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s
|
||||||
|
// 2. markActivity 刷新 lastChangeAt → 立刻切到 active
|
||||||
|
// 3. pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完成后立即补一次
|
||||||
|
describe('fs-watcher 自适应轮询 (audit R7a P1-19)', () => {
|
||||||
|
it('getPollInterval:初始(lastChangeAt=0,距 now 远)→ QUIET 5000ms', () => {
|
||||||
|
const w = makeWatcher();
|
||||||
|
expect(w._test_getPollInterval()).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPollInterval:markActivity 后立即 → ACTIVE 500ms', () => {
|
||||||
|
const w = makeWatcher();
|
||||||
|
w._test_markActivity();
|
||||||
|
expect(w._test_getPollInterval()).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPollInterval:markActivity 后等到 ACTIVE_WINDOW 之外 → QUIET', async () => {
|
||||||
|
const w = makeWatcher();
|
||||||
|
w._test_markActivity();
|
||||||
|
expect(w._test_getPollInterval()).toBe(500);
|
||||||
|
// 模拟时间推进 POLL_ACTIVE_WINDOW_MS + 1:用 setTimeout 等真实时间。
|
||||||
|
// 2100ms 等得起,测试只跑一次。备选用 vi.useFakeTimers + setSystemTime
|
||||||
|
// 也行,但 fake timer + markActivity 内部 Date.now() 兼容性有边角问题。
|
||||||
|
await new Promise((r) => setTimeout(r, 2100));
|
||||||
|
expect(w._test_getPollInterval()).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完后立即补', async () => {
|
||||||
|
// 设计要点:pendingPoll 是**幂等**标志,不是队列。
|
||||||
|
// - in-flight 期间任何 tick 都只把 pendingPoll 置 true(不重复累计)
|
||||||
|
// - in-flight 完成后 finally 检查 pendingPoll:true → scheduleNextPoll(0)
|
||||||
|
// - 紧跟的「补」是 1 次新 scanDir,不是 N 次
|
||||||
|
// 所以「3 次 tick + resolve」→ scanCalls 期望 2(1 原 + 1 补),不是 3。
|
||||||
|
//
|
||||||
|
// 旧版(fixed 2s + pollInFlight drop)的语义是:3 次 tick 期间全部被 drop,
|
||||||
|
// scanCalls 期望 1(且永远不会补)。这条测试是回归锚点:
|
||||||
|
// 拿掉 pendingPoll 行为 → scanCalls 在 resolve 后仍为 1,测试失败。
|
||||||
|
//
|
||||||
|
// 时序坑:test 里 `await new Promise(r => setTimeout(r, 0))` 是先于
|
||||||
|
// runPollTick finally 调度的;Node 同 delay 的 setTimeout 严格按调度顺序
|
||||||
|
// 触发,test 的会先 fire,导致 scanCalls 还在 1 时 expect(2) 失败。
|
||||||
|
// 修:用一个 polling 等待「scanCalls 达到 2」,让事件循环多跑几个 tick。
|
||||||
|
let resolveScan;
|
||||||
|
const scanCalls = [];
|
||||||
|
const w = createFsWatcher({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
resolveDataDir: () => '/data',
|
||||||
|
scanDir: async () => {
|
||||||
|
scanCalls.push(Date.now());
|
||||||
|
// 慢:等我们手动 resolve
|
||||||
|
await new Promise((r) => { resolveScan = r; });
|
||||||
|
return { ok: true, entries: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 跳过 fs.watch 订阅(测试不依赖真实目录事件)
|
||||||
|
w._test_setWatchedDir('/data');
|
||||||
|
// 第一次 tick:in-flight 启动,scanDir 进入 await
|
||||||
|
w._test_runPollTick();
|
||||||
|
// 第二次 tick:in-flight 还在,pendingPoll = true,scanDir 不再调用
|
||||||
|
w._test_runPollTick();
|
||||||
|
// 第三次 tick:同样 pendingPoll = true(幂等)
|
||||||
|
w._test_runPollTick();
|
||||||
|
// 期望 1 次 in-flight scanDir,pendingPoll 期间 0 次额外调用
|
||||||
|
expect(scanCalls.length).toBe(1);
|
||||||
|
// 解除 in-flight:第一次 scanDir 完成 → runPollTick finally 触发「补一次」
|
||||||
|
resolveScan({ ok: true, entries: [] });
|
||||||
|
// 等「补」的 scanDir 真的发起(scanCalls 长度变 2)。最坏情况:
|
||||||
|
// in-flight 微任务 + emitFilesChanged 微任务 + setTimeout(0) 调度 +
|
||||||
|
// setTimeout(0) 触发 + 新 runPollTick 进入 + 新 scanDir push —— 几十 ms 内。
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const tick = () => {
|
||||||
|
if (scanCalls.length >= 2) return resolve();
|
||||||
|
if (Date.now() - start > 200) return resolve(); // 兜底
|
||||||
|
setTimeout(tick, 5);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
expect(scanCalls.length).toBe(2);
|
||||||
|
// 把「补」触发的第 2 次 scanDir 也 resolve 掉,不让它的 in-flight
|
||||||
|
// 干扰其它测试
|
||||||
|
if (resolveScan) resolveScan({ ok: true, entries: [] });
|
||||||
|
// 关键:runPollTick 的 finally 走「无 pending → scheduleNextPoll(
|
||||||
|
// getPollInterval())」会挂 5s 后的 timer。不显式清掉的话,timer 触发时
|
||||||
|
// 又会调一次 scanDir(scanCalls 变 3),且新 scanDir 等到没人 resolve
|
||||||
|
// 会一直挂着 —— watcher + scanCalls 闭包泄漏到进程结束,vitest 报告
|
||||||
|
// "tests passed" 但实际上 open handle 没释放。pause 清 dirPollTimer +
|
||||||
|
// pollPaused,让 watcher 真正失活。
|
||||||
|
w._test_pauseDirPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
// audit R7a P1-19 bugfix:pauseDirPolling 必须真正停止轮询。回归锚点:
|
||||||
|
// - in-flight tick 还在 await scanDir 时调 pause
|
||||||
|
// - scanDir 完成后 runPollTick finally 走 scheduleNextPoll(...)
|
||||||
|
// - 旧版(无 pollPaused 闸)→ 仍挂 5s 后才退到的定时器
|
||||||
|
// - 修后(有 pollPaused 闸)→ 不挂任何新 timer,轮询真停
|
||||||
|
// 拿掉 pollPaused 检查 → _test_hasPollTimer() 在 resolveScan 后仍返回 true,
|
||||||
|
// 本测试失败。仅查 scanCalls 长度不够 —— 5s 间隔下 50ms 等不出新 tick,
|
||||||
|
// 必须直接断言 dirPollTimer 是否被挂。
|
||||||
|
it('pause 期间 in-flight runPollTick finally 不 re-schedule', async () => {
|
||||||
|
let resolveScan;
|
||||||
|
const scanCalls = [];
|
||||||
|
const w = createFsWatcher({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
resolveDataDir: () => '/data',
|
||||||
|
scanDir: async () => {
|
||||||
|
scanCalls.push(Date.now());
|
||||||
|
await new Promise((r) => { resolveScan = r; });
|
||||||
|
return { ok: true, entries: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
w._test_setWatchedDir('/data');
|
||||||
|
// 启动一个 in-flight tick(scanDir 进入 await)→ runPollTick 被 setTimeout
|
||||||
|
// 调度时 dirPollTimer 已被清,所以这一步后 hasPollTimer() = false
|
||||||
|
w._test_runPollTick();
|
||||||
|
expect(scanCalls.length).toBe(1);
|
||||||
|
expect(w._test_hasPollTimer()).toBe(false);
|
||||||
|
// 暂停:模拟窗口最小化 → pollPaused = true
|
||||||
|
w._test_pauseDirPolling();
|
||||||
|
// in-flight scanDir 完成 → runPollTick finally 执行:
|
||||||
|
// 旧版:scheduleNextPoll(getPollInterval()) 挂 5s 后的 timer
|
||||||
|
// 修后:pollPaused 短路,dirPollTimer 保持 null
|
||||||
|
resolveScan({ ok: true, entries: [] });
|
||||||
|
// 等 microtask + setTimeout(0) 都跑过。50ms 足够覆盖几轮事件循环。
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
// 核心断言:pause 之后不应该有新的 poll timer 被挂上
|
||||||
|
expect(w._test_hasPollTimer()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 配对测试:resume 后必须能再跑轮询。验证 pollPaused 不会让恢复路径失活。
|
||||||
|
it('resume 后轮询可重新启动', () => {
|
||||||
|
const w = makeWatcher();
|
||||||
|
w._test_setWatchedDir('/data');
|
||||||
|
w._test_pauseDirPolling();
|
||||||
|
// pause 后调 resume —— 不报错、watchedDir 还在即可(startDirPoll 在
|
||||||
|
// makeWatcher 里没有真实 scanDir,所以不验证 emit 次数)。
|
||||||
|
expect(() => w._test_resumeDirPolling()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
128
tests/unit/fsync-parent-dir.test.js
Normal file
128
tests/unit/fsync-parent-dir.test.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// main.js file:rename / folder:rename —— rename 后父目录 fsync(audit P1-6)
|
||||||
|
//
|
||||||
|
// 背景:fs.rename 在 POSIX 下只 fsync 自己的 inode,不 fsync 父目录的 directory entry。
|
||||||
|
// 崩溃时新名可能没落盘,恢复后看到的是「旧名 inode + 文件内容」。audit 要求
|
||||||
|
// 与 atomicWriteFile(main.js:1801-1809 模式)一致:rename 成功后对父目录做 fsync。
|
||||||
|
//
|
||||||
|
// 现状:folder:create 已经在 mkdir 后做了父目录 fsync(main.js:1854-1864),
|
||||||
|
// 但 file:rename(main.js:1986)和 folder:rename(main.js:2085)都没有。
|
||||||
|
//
|
||||||
|
// 修复:抽 fsyncParentDir(filePath, fsImpl?) helper → 打开父目录 → fsync → 关闭。
|
||||||
|
// - win32 上 NTFS journal 自带元数据持久化,helper 直接 noop(与 folder:create 一致)
|
||||||
|
// - fsImpl 注入便于单测:传 mock fsImpl,验证 open→sync→close 顺序与错误透传
|
||||||
|
// - production 调用走 main.js 实际 import 的 fs(Node 内置)
|
||||||
|
//
|
||||||
|
// 测试策略:纯函数 + 注入 fsImpl。覆盖:
|
||||||
|
// - POSIX 平台:open 父目录、sync 父 fh、close fh
|
||||||
|
// - win32 平台:直接 noop(不动 fsImpl)
|
||||||
|
// - open 失败透传错误
|
||||||
|
// - sync 失败:仍执行 close(finally)
|
||||||
|
// - close 失败:静默吞(与 folder:create 行为一致)
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { fsyncParentDir } from '../../main/fsync-parent-dir.js';
|
||||||
|
|
||||||
|
function makeMockFs({ openOk = true, syncOk = true, closeOk = true } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const openErr = openOk ? null : Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||||
|
const syncErr = syncOk ? null : Object.assign(new Error('EIO'), { code: 'EIO' });
|
||||||
|
const fh = {
|
||||||
|
sync: vi.fn(async () => {
|
||||||
|
calls.push({ kind: 'sync' });
|
||||||
|
if (syncErr) throw syncErr;
|
||||||
|
}),
|
||||||
|
close: vi.fn(async () => {
|
||||||
|
calls.push({ kind: 'close' });
|
||||||
|
if (!closeOk) throw new Error('close-fail');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const fsImpl = {
|
||||||
|
open: vi.fn(async (p, mode) => {
|
||||||
|
calls.push({ kind: 'open', p, mode });
|
||||||
|
if (openErr) throw openErr;
|
||||||
|
return fh;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { fsImpl, fh, calls, openErr, syncErr };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fsyncParentDir(audit P1-6)', () => {
|
||||||
|
it('POSIX:open 父目录 → sync → close,顺序正确', async () => {
|
||||||
|
const { fsImpl, fh, calls } = makeMockFs();
|
||||||
|
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
|
||||||
|
expect(r).toEqual({ ok: true });
|
||||||
|
// open 父目录(不是文件本身)
|
||||||
|
expect(fsImpl.open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fsImpl.open).toHaveBeenCalledWith('/data/notes', 'r');
|
||||||
|
// sync + close 都触发
|
||||||
|
expect(fh.sync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fh.close).toHaveBeenCalledTimes(1);
|
||||||
|
// 顺序:open → sync → close
|
||||||
|
expect(calls.map((c) => c.kind)).toEqual(['open', 'sync', 'close']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('win32:直接 noop,不调用 fsImpl', async () => {
|
||||||
|
const { fsImpl, fh } = makeMockFs();
|
||||||
|
const r = await fsyncParentDir('C:\\data\\notes\\foo.md', { fsImpl, platform: 'win32' });
|
||||||
|
expect(r).toEqual({ ok: true, skipped: 'win32' });
|
||||||
|
expect(fsImpl.open).not.toHaveBeenCalled();
|
||||||
|
expect(fh.sync).not.toHaveBeenCalled();
|
||||||
|
expect(fh.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('open 失败:透传错误,sync/close 不触发', async () => {
|
||||||
|
const { fsImpl, fh } = makeMockFs({ openOk: false });
|
||||||
|
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toBe('EACCES');
|
||||||
|
expect(r.code).toBe('EACCES');
|
||||||
|
expect(fh.sync).not.toHaveBeenCalled();
|
||||||
|
expect(fh.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sync 失败:finally 仍执行 close', async () => {
|
||||||
|
const { fsImpl, fh, calls } = makeMockFs({ syncOk: false });
|
||||||
|
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toBe('EIO');
|
||||||
|
// close 仍触发(避免 fh 泄漏)
|
||||||
|
expect(fh.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(calls.map((c) => c.kind)).toEqual(['open', 'sync', 'close']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close 失败:静默吞(与 folder:create 行为一致)', async () => {
|
||||||
|
const { fsImpl, fh } = makeMockFs({ closeOk: false });
|
||||||
|
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
|
||||||
|
// close 失败不抛、不计入返回值(folder:create 模式 try/catch close)
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(fh.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('根目录边界:parent = "/" 时仍能 open(POSIX 允许)', async () => {
|
||||||
|
const { fsImpl } = makeMockFs();
|
||||||
|
const r = await fsyncParentDir('/foo.md', { fsImpl, platform: 'linux' });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(fsImpl.open).toHaveBeenCalledWith('/', 'r');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认 platform/process:未指定时用 process.platform', async () => {
|
||||||
|
const { fsImpl } = makeMockFs();
|
||||||
|
// 不传 platform → 走 process.platform
|
||||||
|
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl });
|
||||||
|
// 在 linux 容器(CI)上 process.platform === 'linux' → 正常 open
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
expect(r).toEqual({ ok: true, skipped: 'win32' });
|
||||||
|
} else {
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(fsImpl.open).toHaveBeenCalledWith('/data/notes', 'r');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('路径为相对路径:parent 用 path.dirname 解出', async () => {
|
||||||
|
const { fsImpl } = makeMockFs();
|
||||||
|
const r = await fsyncParentDir('notes/sub/foo.md', { fsImpl, platform: 'linux' });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
// path.dirname('notes/sub/foo.md') === 'notes/sub'
|
||||||
|
expect(fsImpl.open).toHaveBeenCalledWith('notes/sub', 'r');
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/unit/generate-tmp-path.test.js
Normal file
96
tests/unit/generate-tmp-path.test.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// main/file-ops.js atomicWriteFile —— tmp 路径生成(audit P2-1)
|
||||||
|
//
|
||||||
|
// 背景:原 inline 实现 `Math.random().toString(36).slice(2, 8)`
|
||||||
|
// - Math.random() 不加密安全,并发同毫秒 + 同 pid + 同 dst 可能撞名
|
||||||
|
// - audit 要求与 crypto.randomUUID() 对齐(已在本仓库其它路径使用)
|
||||||
|
//
|
||||||
|
// 修复:抽 generateTmpPath(dst, { crypto, pid, now }) 工厂:
|
||||||
|
// - 注入 crypto / pid / now 便于单测验证随机性来源
|
||||||
|
// - 默认走 node:crypto.randomUUID()(128-bit 加密安全,6 段 8-4-4-4-12 hex)
|
||||||
|
// - 路径格式:${dst}.tmp.${pid}.${ts}.${randomId}(保持现有格式不破坏 fs-watcher 兼容)
|
||||||
|
//
|
||||||
|
// 测试策略:纯函数 + 注入 crypto / pid / now。覆盖:
|
||||||
|
// - crypto.randomUUID 被调用(mock)
|
||||||
|
// - 同 pid+now 两次调用 → randomId 不同(实际是不同 UUID)
|
||||||
|
// - 不同 pid 反映在路径里
|
||||||
|
// - 不同 now 反映在路径里
|
||||||
|
// - 路径格式完整:dst + ".tmp." + pid + "." + ts + "." + randomId
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { generateTmpPath } from '../../main/generate-tmp-path.js';
|
||||||
|
|
||||||
|
describe('generateTmpPath(audit P2-1)', () => {
|
||||||
|
it('crypto.randomUUID 被调用,返回的 randomId 来自它', () => {
|
||||||
|
const cryptoMock = { randomUUID: vi.fn(() => 'uuid-abc-123') };
|
||||||
|
const r = generateTmpPath('/data/note.md', {
|
||||||
|
crypto: cryptoMock,
|
||||||
|
pid: 1000,
|
||||||
|
now: () => 1234567890,
|
||||||
|
});
|
||||||
|
expect(cryptoMock.randomUUID).toHaveBeenCalledTimes(1);
|
||||||
|
expect(r).toBe('/data/note.md.tmp.1000.1234567890.uuid-abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 pid+now 但 crypto 每次返不同 UUID → 路径不同', () => {
|
||||||
|
let counter = 0;
|
||||||
|
const cryptoMock = { randomUUID: vi.fn(() => `uuid-${++counter}`) };
|
||||||
|
const r1 = generateTmpPath('/data/note.md', { crypto: cryptoMock, pid: 1000, now: () => 1234 });
|
||||||
|
const r2 = generateTmpPath('/data/note.md', { crypto: cryptoMock, pid: 1000, now: () => 1234 });
|
||||||
|
expect(r1).not.toBe(r2);
|
||||||
|
expect(r1).toContain('uuid-1');
|
||||||
|
expect(r2).toContain('uuid-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同 pid 反映在路径里', () => {
|
||||||
|
const cryptoMock = { randomUUID: vi.fn(() => 'uuid-x') };
|
||||||
|
const r1 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 100, now: () => 1234 });
|
||||||
|
const r2 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 200, now: () => 1234 });
|
||||||
|
expect(r1).toContain('.tmp.100.');
|
||||||
|
expect(r2).toContain('.tmp.200.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同 now 反映在路径里', () => {
|
||||||
|
const cryptoMock = { randomUUID: vi.fn(() => 'uuid-x') };
|
||||||
|
const r1 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 1, now: () => 1000 });
|
||||||
|
const r2 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 1, now: () => 2000 });
|
||||||
|
expect(r1).toContain('.1.1000.uuid-x');
|
||||||
|
expect(r2).toContain('.1.2000.uuid-x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('路径格式:${dst}.tmp.${pid}.${ts}.${randomId},5 段以 . 分割', () => {
|
||||||
|
const r = generateTmpPath('/data/note.md', {
|
||||||
|
crypto: { randomUUID: () => 'xyz' },
|
||||||
|
pid: 42,
|
||||||
|
now: () => 999,
|
||||||
|
});
|
||||||
|
// 末尾应该是 ".tmp.42.999.xyz"
|
||||||
|
expect(r.endsWith('.tmp.42.999.xyz')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dst 含扩展名 / 含路径分隔符都正确保留', () => {
|
||||||
|
const r = generateTmpPath('C:/data/sub/note.md', {
|
||||||
|
crypto: { randomUUID: () => 'u' },
|
||||||
|
pid: 1,
|
||||||
|
now: () => 1,
|
||||||
|
});
|
||||||
|
expect(r).toBe('C:/data/sub/note.md.tmp.1.1.u');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认(不传 crypto)走 node:crypto.randomUUID,real call 不抛', () => {
|
||||||
|
// 不传 opts.crypto → 走 require('node:crypto').randomUUID
|
||||||
|
const r = generateTmpPath('/data/note.md', { pid: 1, now: () => 1 });
|
||||||
|
// real randomUUID 返 36 字符的 UUID(8-4-4-4-12)
|
||||||
|
expect(r).toMatch(/\.tmp\.1\.1\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认 pid 走 process.pid,now 走 Date.now()', () => {
|
||||||
|
const r = generateTmpPath('/data/note.md');
|
||||||
|
// 路径第 3 段 = process.pid
|
||||||
|
const match = r.match(/\.tmp\.(\d+)\.(\d+)\./);
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
expect(Number(match[1])).toBe(process.pid);
|
||||||
|
// ts 应当接近 Date.now()(误差 < 5s)
|
||||||
|
const ts = Number(match[2]);
|
||||||
|
expect(Math.abs(ts - Date.now())).toBeLessThan(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
tests/unit/heading-observer-persist.test.js
Normal file
125
tests/unit/heading-observer-persist.test.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// P2-16(audit):heading IntersectionObserver 实例持久化。
|
||||||
|
//
|
||||||
|
// 旧实现:_rebuildHeadingObserver 每次 new IntersectionObserver(cb) ——
|
||||||
|
// loadContentLive 在 split 模式每个 debounced 击键都跑一次重建,observer
|
||||||
|
// 对象 + 闭包反复分配。
|
||||||
|
//
|
||||||
|
// 新契约:
|
||||||
|
// - observer 实例只建一次(多次 _rebuildHeadingObserver 不重复 new)
|
||||||
|
// - 重建仍 disconnect + 全量 re-observe(M1 历史教训:innerHTML 替换
|
||||||
|
// detach 旧节点,增量 observe 会让追踪冻死,不能省)
|
||||||
|
// - dispose 仍 disconnect + 置 null(Round 12 P3 契约不变)
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { MarkdownViewer } from '../../src/markdown-viewer.js';
|
||||||
|
|
||||||
|
describe('heading observer 持久化(P2-16)', () => {
|
||||||
|
let body;
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
body = document.createElement('div');
|
||||||
|
body.id = 'markdown-body';
|
||||||
|
document.body.appendChild(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeViewer() {
|
||||||
|
return new MarkdownViewer({
|
||||||
|
bodyElement: body,
|
||||||
|
emptyElement: document.createElement('div'),
|
||||||
|
statusPath: document.createElement('div'),
|
||||||
|
statusMeta: document.createElement('div'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHeadings(...tags) {
|
||||||
|
for (const t of tags) {
|
||||||
|
const h = document.createElement(t);
|
||||||
|
h.id = `${t}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
h.textContent = 'title';
|
||||||
|
body.appendChild(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('多次 _rebuildHeadingObserver 只 new 一次 IntersectionObserver', () => {
|
||||||
|
const constructCalls = [];
|
||||||
|
const fakeObserver = {
|
||||||
|
observe: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
};
|
||||||
|
const RealIO = window.IntersectionObserver;
|
||||||
|
window.IntersectionObserver = vi.fn(function () {
|
||||||
|
constructCalls.push(true);
|
||||||
|
return fakeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const viewer = makeViewer();
|
||||||
|
addHeadings('h1', 'h2');
|
||||||
|
viewer._rebuildHeadingObserver();
|
||||||
|
viewer._rebuildHeadingObserver();
|
||||||
|
viewer._rebuildHeadingObserver();
|
||||||
|
expect(constructCalls.length).toBe(1);
|
||||||
|
// 每次重建都 disconnect + 全量 re-observe(detach 节点不能增量);
|
||||||
|
// 第一次重建时 observer 尚不存在、无可 disconnect,故 3 次重建 = 2 次 disconnect
|
||||||
|
expect(fakeObserver.disconnect.mock.calls.length).toBe(2);
|
||||||
|
expect(fakeObserver.observe.mock.calls.length).toBe(6); // 2 headings × 3 次
|
||||||
|
viewer.dispose();
|
||||||
|
} finally {
|
||||||
|
window.IntersectionObserver = RealIO;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重建后观察的是新 DOM 节点,不是 detach 的旧节点', () => {
|
||||||
|
const observed = [];
|
||||||
|
const fakeObserver = {
|
||||||
|
observe: vi.fn(function (node) { observed.push(node); }),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
};
|
||||||
|
const RealIO = window.IntersectionObserver;
|
||||||
|
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const viewer = makeViewer();
|
||||||
|
addHeadings('h1');
|
||||||
|
viewer._rebuildHeadingObserver();
|
||||||
|
const firstNode = observed[0];
|
||||||
|
// 模拟 innerHTML 全量替换:旧节点 detach,新节点进 DOM
|
||||||
|
body.innerHTML = '';
|
||||||
|
observed.length = 0;
|
||||||
|
addHeadings('h1');
|
||||||
|
viewer._rebuildHeadingObserver();
|
||||||
|
const secondNode = observed[0];
|
||||||
|
expect(secondNode).not.toBe(firstNode);
|
||||||
|
expect(secondNode.isConnected).toBe(true);
|
||||||
|
expect(firstNode.isConnected).toBe(false);
|
||||||
|
viewer.dispose();
|
||||||
|
} finally {
|
||||||
|
window.IntersectionObserver = RealIO;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose 仍 disconnect 一次并置 null(Round 12 P3 契约不变)', () => {
|
||||||
|
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 = makeViewer();
|
||||||
|
addHeadings('h1');
|
||||||
|
viewer._rebuildHeadingObserver();
|
||||||
|
viewer.dispose();
|
||||||
|
expect(fakeObserver.disconnect.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(viewer._headingObserver).toBe(null);
|
||||||
|
} finally {
|
||||||
|
window.IntersectionObserver = RealIO;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
173
tests/unit/ipc-sender.test.js
Normal file
173
tests/unit/ipc-sender.test.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// main/ipc-sender.js —— assertMainSender / assertTopFrame / assertAiRequestId 单测
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// - assertMainSender 四种拒绝路径:event 缺 sender / mainWindow 未创建 /
|
||||||
|
// mainWindow 已销毁 / sender 不是主窗口 webContents
|
||||||
|
// - assertMainSender 放行路径:sender 匹配 + mainWindow 存活
|
||||||
|
// - assertTopFrame:senderFrame 缺失拒绝 / parent 真值拒绝 / parent null 放行
|
||||||
|
// - assertAiRequestId:两种合法格式(ai-<ts>-<uuid> / 裸 uuid)+ 各种垃圾拒绝
|
||||||
|
|
||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
assertMainSender,
|
||||||
|
assertTopFrame,
|
||||||
|
assertAiRequestId,
|
||||||
|
SENDER_NOT_ALLOWED,
|
||||||
|
INVALID_REQUEST_ID,
|
||||||
|
} = await import('../../main/ipc-sender.js');
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
function makeWin(webContents) {
|
||||||
|
return {
|
||||||
|
webContents,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvent(sender) {
|
||||||
|
return { sender };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('assertMainSender', () => {
|
||||||
|
const sender = { id: 42 };
|
||||||
|
|
||||||
|
it('event 缺 sender → 拒绝(SENDER_NOT_ALLOWED)', () => {
|
||||||
|
const getMainWindow = () => makeWin(sender);
|
||||||
|
const r = assertMainSender({}, getMainWindow);
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('event 完全 undefined → 拒绝', () => {
|
||||||
|
const r = assertMainSender(undefined, () => makeWin(sender));
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mainWindow 未创建(getMainWindow 返回 null) → 拒绝', () => {
|
||||||
|
const r = assertMainSender(makeEvent(sender), () => null);
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mainWindow 已销毁 → 拒绝', () => {
|
||||||
|
const win = { isDestroyed: () => true };
|
||||||
|
const r = assertMainSender(makeEvent(sender), () => win);
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sender 不等于 mainWindow.webContents → 拒绝(防 webview / 子窗口)', () => {
|
||||||
|
const otherSender = { id: 99 };
|
||||||
|
const getMainWindow = () => makeWin(sender);
|
||||||
|
const r = assertMainSender(makeEvent(otherSender), getMainWindow);
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sender 等于 mainWindow.webContents + mainWindow 存活 → 放行', () => {
|
||||||
|
const getMainWindow = () => makeWin(sender);
|
||||||
|
const r = assertMainSender(makeEvent(sender), getMainWindow);
|
||||||
|
expect(r).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMainWindow 不是函数 → 拒绝(防御性)', () => {
|
||||||
|
const r = assertMainSender(makeEvent(sender), null);
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('主窗口在调用过程中被销毁(getMainWindow 返回已销毁 win) → 拒绝', () => {
|
||||||
|
// 模拟:handler 入口时主窗口还活着,但 getter 被调时已销毁(destroy 事件刚好触发)
|
||||||
|
const sender = { id: 42 };
|
||||||
|
const getMainWindow = vi.fn(() => ({ isDestroyed: () => true }));
|
||||||
|
const r = assertMainSender(makeEvent(sender), getMainWindow);
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertTopFrame', () => {
|
||||||
|
it('event.senderFrame 缺失 → 拒绝(保守)', () => {
|
||||||
|
const r = assertTopFrame({});
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('event 完全 undefined → 拒绝', () => {
|
||||||
|
expect(assertTopFrame(undefined)).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('senderFrame.parent 真值(iframe / webview) → 拒绝', () => {
|
||||||
|
const r = assertTopFrame({ senderFrame: { parent: { id: 99 } } });
|
||||||
|
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('senderFrame.parent null(顶层 frame) → 放行', () => {
|
||||||
|
const r = assertTopFrame({ senderFrame: { parent: null } });
|
||||||
|
expect(r).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertAiRequestId', () => {
|
||||||
|
// 标准 UUIDv4 形式
|
||||||
|
const UUID_V4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||||
|
// main/ai.js:968 的格式:ai-<timestamp>-<uuid>
|
||||||
|
const AI_TS_UUID = `ai-1700000000000-${UUID_V4}`;
|
||||||
|
|
||||||
|
it('renderer 侧格式:裸 UUIDv4 → 放行', () => {
|
||||||
|
const r = assertAiRequestId(UUID_V4);
|
||||||
|
expect(r).toEqual({ ok: true, requestId: UUID_V4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('main 侧 fallback 格式:ai-<ts>-<uuid> → 放行', () => {
|
||||||
|
const r = assertAiRequestId(AI_TS_UUID);
|
||||||
|
expect(r).toEqual({ ok: true, requestId: AI_TS_UUID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('大写 UUIDv4 也放行(正则 case-insensitive)', () => {
|
||||||
|
const r = assertAiRequestId(UUID_V4.toUpperCase());
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非 string(number / object / null) → 拒绝', () => {
|
||||||
|
expect(assertAiRequestId(123).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
expect(assertAiRequestId({}).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
expect(assertAiRequestId(null).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
expect(assertAiRequestId(undefined).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空串 → 拒绝', () => {
|
||||||
|
expect(assertAiRequestId('').error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('过长(>200 字符) → 拒绝(防内存放大)', () => {
|
||||||
|
expect(assertAiRequestId('a'.repeat(201)).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UUIDv4 但缺一位 → 拒绝(hex 字符数错)', () => {
|
||||||
|
const short = 'f47ac10b-58cc-4372-a567-0e02b2c3d47';
|
||||||
|
expect(assertAiRequestId(short).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UUIDv4 但第 3 段首位不是 4 → 拒绝', () => {
|
||||||
|
const v3 = 'f47ac10b-58cc-4372-3567-0e02b2c3d479';
|
||||||
|
expect(assertAiRequestId(v3).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UUIDv4 但第 4 段首位不是 [89ab] → 拒绝', () => {
|
||||||
|
const badVariant = 'f47ac10b-58cc-4372-4567-0e02b2c3d479';
|
||||||
|
expect(assertAiRequestId(badVariant).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ai- 前缀但 timestamp 不是纯数字 → 拒绝', () => {
|
||||||
|
const bad = `ai-notanumber-${UUID_V4}`;
|
||||||
|
expect(assertAiRequestId(bad).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ai- 前缀但 timestamp 过长(>18 位)→ 拒绝(防 DoS)', () => {
|
||||||
|
const bad = `ai-${'1'.repeat(19)}-${UUID_V4}`;
|
||||||
|
expect(assertAiRequestId(bad).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('包含 SQL 注入 / shell 元字符 → 拒绝', () => {
|
||||||
|
expect(assertAiRequestId(`' OR 1=1; --`).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
expect(assertAiRequestId(`$(rm -rf /)`).error).toBe(INVALID_REQUEST_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
tests/unit/lru-cache.test.js
Normal file
79
tests/unit/lru-cache.test.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// P2-6(audit):main/ai.js 的 pickOpenAITokenConfig / joinUrl 每次调用重复
|
||||||
|
// 正则解析。AI 流式期间同 model / 同 baseURL 反复命中,加 LRU 缓存省 CPU。
|
||||||
|
//
|
||||||
|
// 设计:main/lru-cache.js 通用 createLruCache({ maxSize }) →
|
||||||
|
// get(key) / set(key, value) / has(key) / size
|
||||||
|
// - Map 迭代序 = 插入序;get 命中时 delete+set 提到最新(LRU 语义)
|
||||||
|
// - set 超 maxSize 时删最老(Map 第一个键)
|
||||||
|
// - maxSize 默认 64
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
const { createLruCache } = require('../../main/lru-cache.js');
|
||||||
|
|
||||||
|
describe('createLruCache —— P2-6 通用 LRU', () => {
|
||||||
|
it('set/get 往返', () => {
|
||||||
|
const c = createLruCache({ maxSize: 4 });
|
||||||
|
c.set('a', 1);
|
||||||
|
expect(c.get('a')).toBe(1);
|
||||||
|
expect(c.has('a')).toBe(true);
|
||||||
|
expect(c.get('missing')).toBeUndefined();
|
||||||
|
expect(c.has('missing')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超 maxSize 删最老的键', () => {
|
||||||
|
const c = createLruCache({ maxSize: 2 });
|
||||||
|
c.set('a', 1);
|
||||||
|
c.set('b', 2);
|
||||||
|
c.set('c', 3); // a 应被挤掉
|
||||||
|
expect(c.has('a')).toBe(false);
|
||||||
|
expect(c.has('b')).toBe(true);
|
||||||
|
expect(c.has('c')).toBe(true);
|
||||||
|
expect(c.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get 命中提到最新,延缓被淘汰', () => {
|
||||||
|
const c = createLruCache({ maxSize: 2 });
|
||||||
|
c.set('a', 1);
|
||||||
|
c.set('b', 2);
|
||||||
|
c.get('a'); // a 提最新,b 变老
|
||||||
|
c.set('c', 3); // 挤掉 b 而非 a
|
||||||
|
expect(c.has('a')).toBe(true);
|
||||||
|
expect(c.has('b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复 set 同键覆盖值且不增 size', () => {
|
||||||
|
const c = createLruCache({ maxSize: 2 });
|
||||||
|
c.set('a', 1);
|
||||||
|
c.set('a', 2);
|
||||||
|
expect(c.get('a')).toBe(2);
|
||||||
|
expect(c.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复 set 同键视为「使用」,提到最新', () => {
|
||||||
|
const c = createLruCache({ maxSize: 2 });
|
||||||
|
c.set('a', 1);
|
||||||
|
c.set('b', 2);
|
||||||
|
c.set('a', 9); // a 提最新
|
||||||
|
c.set('c', 3); // 挤掉 b
|
||||||
|
expect(c.get('a')).toBe(9);
|
||||||
|
expect(c.has('b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认 maxSize=64', () => {
|
||||||
|
const c = createLruCache();
|
||||||
|
for (let i = 0; i < 70; i++) c.set('k' + i, i);
|
||||||
|
expect(c.size).toBe(64);
|
||||||
|
expect(c.has('k0')).toBe(false);
|
||||||
|
expect(c.has('k69')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('值可以是对象 / undefined 以外的任意类型', () => {
|
||||||
|
const c = createLruCache({ maxSize: 4 });
|
||||||
|
const obj = { x: 1 };
|
||||||
|
c.set('o', obj);
|
||||||
|
c.set('n', null);
|
||||||
|
expect(c.get('o')).toBe(obj);
|
||||||
|
expect(c.get('n')).toBeNull();
|
||||||
|
expect(c.has('n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
tests/unit/main-log-writer.test.js
Normal file
127
tests/unit/main-log-writer.test.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// P2-10 / P2-12(audit):主进程日志改写异步缓冲。
|
||||||
|
//
|
||||||
|
// 旧实现:console.log/warn/error 重写层每行 appendFileSync + mkdirSync ——
|
||||||
|
// 同步 IO 阻塞 main 事件循环(AI 流式高频日志时尤甚),且顶层同步 fs。
|
||||||
|
//
|
||||||
|
// 新模块 main/main-log-writer.js:createMainLogWriter({ logDir, logFile,
|
||||||
|
// fsImpl, flushMs, maxBuffered, setTimeoutImpl, clearTimeoutImpl }) →
|
||||||
|
// write(line) 入缓冲;超 maxBuffered 丢最老;调度 flush
|
||||||
|
// flush() mkdir(recursive) + appendFile 一把写出;失败静默丢弃
|
||||||
|
// dispose() 取消待发 flush(测试 / 退出用)
|
||||||
|
//
|
||||||
|
// fsImpl 默认 fs.promises;时钟 / 定时器可注入,单测同步推进。
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
const { createMainLogWriter } = require('../../main/main-log-writer.js');
|
||||||
|
|
||||||
|
function fakeFs() {
|
||||||
|
const calls = { mkdir: [], append: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
mkdir: async (dir, opts) => { calls.mkdir.push([dir, opts]); },
|
||||||
|
appendFile: async (file, data) => { calls.append.push([file, data]); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTimers() {
|
||||||
|
const pending = [];
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
setTimeout: (fn, ms) => { const h = { fn, ms, unref() {} }; pending.push(h); return h; },
|
||||||
|
clearTimeout: (h) => { const i = pending.indexOf(h); if (i >= 0) pending.splice(i, 1); },
|
||||||
|
fireAll: () => { const fns = pending.splice(0); for (const h of fns) h.fn(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createMainLogWriter —— P2-10 异步缓冲日志', () => {
|
||||||
|
it('write 只入缓冲,flush 后一把 appendFile', async () => {
|
||||||
|
const fs = fakeFs();
|
||||||
|
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
|
||||||
|
w.write('l1\n');
|
||||||
|
w.write('l2\n');
|
||||||
|
expect(fs.calls.append).toEqual([]);
|
||||||
|
await w.flush();
|
||||||
|
expect(fs.calls.mkdir).toEqual([['/d', { recursive: true }]]);
|
||||||
|
expect(fs.calls.append).toEqual([['/d/a.log', 'l1\nl2\n']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flush 期间缓冲清空;空缓冲 flush 是 no-op', async () => {
|
||||||
|
const fs = fakeFs();
|
||||||
|
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
|
||||||
|
await w.flush();
|
||||||
|
expect(fs.calls.append).toEqual([]);
|
||||||
|
w.write('x\n');
|
||||||
|
await w.flush();
|
||||||
|
await w.flush();
|
||||||
|
expect(fs.calls.append).toEqual([['/d/a.log', 'x\n']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mkdir 只第一次做(缓存 promise)', async () => {
|
||||||
|
const fs = fakeFs();
|
||||||
|
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
|
||||||
|
w.write('a\n');
|
||||||
|
await w.flush();
|
||||||
|
w.write('b\n');
|
||||||
|
await w.flush();
|
||||||
|
expect(fs.calls.mkdir.length).toBe(1);
|
||||||
|
expect(fs.calls.append.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('写失败静默丢弃,不抛错,缓冲不残留', async () => {
|
||||||
|
const fs = {
|
||||||
|
mkdir: async () => {},
|
||||||
|
appendFile: async () => { throw new Error('disk full'); },
|
||||||
|
};
|
||||||
|
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
|
||||||
|
w.write('boom\n');
|
||||||
|
await expect(w.flush()).resolves.toBeUndefined();
|
||||||
|
// 缓冲已清,不会无限重试
|
||||||
|
await w.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缓冲超 maxBuffered 丢最老行', async () => {
|
||||||
|
const fs = fakeFs();
|
||||||
|
const w = createMainLogWriter({
|
||||||
|
logDir: '/d', logFile: '/d/a.log', fsImpl: fs, maxBuffered: 3,
|
||||||
|
});
|
||||||
|
w.write('1\n'); w.write('2\n'); w.write('3\n'); w.write('4\n');
|
||||||
|
await w.flush();
|
||||||
|
expect(fs.calls.append).toEqual([['/d/a.log', '2\n3\n4\n']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('write 调度一次 flush 定时器(不重复排)', async () => {
|
||||||
|
const fs = fakeFs();
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const w = createMainLogWriter({
|
||||||
|
logDir: '/d', logFile: '/d/a.log', fsImpl: fs,
|
||||||
|
setTimeoutImpl: timers.setTimeout, clearTimeoutImpl: timers.clearTimeout,
|
||||||
|
flushMs: 250,
|
||||||
|
});
|
||||||
|
w.write('a\n');
|
||||||
|
w.write('b\n');
|
||||||
|
expect(timers.pending.length).toBe(1);
|
||||||
|
expect(timers.pending[0].ms).toBe(250);
|
||||||
|
timers.fireAll();
|
||||||
|
await w.flush(); // 等 in-flight 落地
|
||||||
|
expect(fs.calls.append.length).toBe(1);
|
||||||
|
expect(fs.calls.append[0][1]).toBe('a\nb\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose 取消待发 flush', () => {
|
||||||
|
const timers = fakeTimers();
|
||||||
|
const w = createMainLogWriter({
|
||||||
|
logDir: '/d', logFile: '/d/a.log', fsImpl: fakeFs(),
|
||||||
|
setTimeoutImpl: timers.setTimeout, clearTimeoutImpl: timers.clearTimeout,
|
||||||
|
});
|
||||||
|
w.write('a\n');
|
||||||
|
expect(timers.pending.length).toBe(1);
|
||||||
|
w.dispose();
|
||||||
|
expect(timers.pending.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认 fsImpl / 定时器下 write 不抛错', () => {
|
||||||
|
const w = createMainLogWriter({ logDir: '/nonexistent-dir-x', logFile: '/nonexistent-dir-x/a.log' });
|
||||||
|
expect(() => w.write('hello\n')).not.toThrow();
|
||||||
|
w.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -203,3 +203,147 @@ describe('MarkdownEditor 基础行为', () => {
|
|||||||
expect(() => editor._onCompositionEnd()).not.toThrow();
|
expect(() => editor._onCompositionEnd()).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// audit R7b P1-18:单实例 EditorView 持久化
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 核心契约:loadFile 多次调用复用同一 view 实例,不再 _destroyView + new。
|
||||||
|
// 旧实现下:A→B→A 切文件会丢 history / selection / 折叠,30-100ms 黑闪。
|
||||||
|
describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
|
||||||
|
it('首次 loadFile 创建 view', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
expect(editor.view).toBeNull();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content');
|
||||||
|
expect(editor.view).not.toBeNull();
|
||||||
|
expect(editor.getContent()).toBe('A content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多次 loadFile 复用同一 view 实例(A→B→A 同一 view)', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content');
|
||||||
|
const viewA = editor.view;
|
||||||
|
// 切到 B
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B content');
|
||||||
|
expect(editor.view).toBe(viewA); // 引用相等
|
||||||
|
expect(editor.getContent()).toBe('B content');
|
||||||
|
// 切回 A(新内容)
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content v2');
|
||||||
|
expect(editor.view).toBe(viewA);
|
||||||
|
expect(editor.getContent()).toBe('A content v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadFile 切换文件后 selection 重置到 (0, 0)', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello world');
|
||||||
|
// 用户手动把光标移到位置 5
|
||||||
|
editor.view.dispatch({ selection: { anchor: 5, head: 5 } });
|
||||||
|
expect(editor.view.state.selection.main.anchor).toBe(5);
|
||||||
|
// 切到 B → selection 应被重置
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'foo bar baz');
|
||||||
|
expect(editor.view.state.selection.main.anchor).toBe(0);
|
||||||
|
expect(editor.view.state.selection.main.head).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadFile 切换后 history 仍可用(view 复用,undo stack 保留)', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'one');
|
||||||
|
// 用户编辑 → 一些 transaction 计入 history
|
||||||
|
editor.view.dispatch({ changes: { from: 3, insert: ' two' } });
|
||||||
|
editor.view.dispatch({ changes: { from: 7, insert: ' three' } });
|
||||||
|
expect(editor.getContent()).toBe('one two three');
|
||||||
|
// 切到 B 再切回 A(content 是初始 one,dispatch 替换;这里测的是 view 复用,
|
||||||
|
// 不是 history 跨文件保留——后者是更大设计问题,本测试只锁 view 实例稳定)
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
|
||||||
|
expect(editor.view).not.toBeNull();
|
||||||
|
// view 还是同一个引用 → history 字段在 view 生命周期内持续
|
||||||
|
// (CM6 history field 由 EditorState 持有;view 复用 = state 延续)
|
||||||
|
const beforeUndo = editor.view.state.doc.toString();
|
||||||
|
// 触发一次新 transaction(与刚才的 history 关联)
|
||||||
|
editor.view.dispatch({ changes: { from: 1, insert: 'B' } });
|
||||||
|
// undo 应该能回到 beforeUndo
|
||||||
|
expect(editor.view.state.doc.toString()).not.toBe(beforeUndo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadFile 复用 view 时 _viewAbort signal 不重建(IME listener 不会重复挂)', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||||
|
const signal1 = editor._viewAbort;
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
|
||||||
|
const signal2 = editor._viewAbort;
|
||||||
|
// view 复用 → 不需要新的 AbortController(IME listener 还在原 signal 上)
|
||||||
|
expect(signal2).toBe(signal1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unload → 销毁 view;再 loadFile 创建新 view', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||||
|
const viewA = editor.view;
|
||||||
|
editor.unload();
|
||||||
|
expect(editor.view).toBeNull();
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
|
||||||
|
// unload 后 view 是 null;下次 loadFile 触发 _createView → 新 view
|
||||||
|
expect(editor.view).not.toBe(viewA);
|
||||||
|
expect(editor.view).not.toBeNull();
|
||||||
|
expect(editor.getContent()).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多次 loadFile 复用 view 时 _viewAbort signal 引用稳定(IME listener 不重建)', () => {
|
||||||
|
// 核心契约:旧实现每次 loadFile 都 _destroyView + new AbortController,
|
||||||
|
// 但 addAbort 路径在 dispose 时才 remove —— 多次切文件会让 contentDOM
|
||||||
|
// 上累积多份 IME listener(compositionend 跑多次 → onChange 多次 →
|
||||||
|
// state.isDirty 翻脏态反复 + scheduleAutoSave 多次 clearTimeout)。
|
||||||
|
// 新实现下 view 整个 lifetime 只一个 _viewAbort signal,listener 只一份。
|
||||||
|
// 验证方式:loadFile 多次,_viewAbort 引用始终相等。
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||||
|
const signalRef = editor._viewAbort;
|
||||||
|
expect(signalRef).not.toBeNull();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
editor.loadFile({ path: `/f${i}.md`, name: `f${i}.md` }, `content ${i}`);
|
||||||
|
expect(editor._viewAbort).toBe(signalRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadFile 重复同一文件:onChange 触发次数精确等于 dispatch 次数(listener 不重复挂)', () => {
|
||||||
|
// 回归锚点:旧实现每次 loadFile 都 addEventListener 三次(compositionstart
|
||||||
|
// / end / cancel),但 dispose 才 remove —— contentDOM 上 listener 数量
|
||||||
|
// 线性增长。N 次切文件 → onChange 被触发 3N 次。新实现下 view 不重建,
|
||||||
|
// listener 只一份,N 次 loadFile 正好 N 次 onChange。
|
||||||
|
const { editor, onChange } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||||
|
const baseCount = onChange.mock.calls.length;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, `A${i}`);
|
||||||
|
}
|
||||||
|
const delta = onChange.mock.calls.length - baseCount;
|
||||||
|
expect(delta).toBe(5); // 精确 5,不是 10 / 15 / ...
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A→B→A 场景下 lastSavedDoc 跟踪最终内容', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A1');
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B1');
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A2');
|
||||||
|
// loadFile 总是把 lastSavedDoc 同步到传入 content
|
||||||
|
expect(editor.lastSavedDoc).toBe('A2');
|
||||||
|
expect(editor.isDirty()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getContent 在多次 loadFile 后返回当前 view 的 doc(不是 lastSavedDoc)', () => {
|
||||||
|
const { editor } = createEditor();
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||||
|
expect(editor.getContent()).toBe('A');
|
||||||
|
// 用户编辑
|
||||||
|
editor.view.dispatch({ changes: { from: 1, insert: 'A-edit' } });
|
||||||
|
expect(editor.getContent()).toBe('AA-edit');
|
||||||
|
// 切到 B
|
||||||
|
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
|
||||||
|
expect(editor.getContent()).toBe('B');
|
||||||
|
// 切回 A(content 是 A 初始的,但 view 的 doc 是 A-edit 之前 dispatch 过的
|
||||||
|
// 状态——实际 loadFile 走 dispatch 全量替换,所以 doc 回到 A 初始)
|
||||||
|
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||||
|
expect(editor.getContent()).toBe('A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { MarkdownViewer } from '../../src/markdown-viewer.js';
|
import { MarkdownViewer } from '../../src/markdown-viewer.js';
|
||||||
|
|
||||||
// jsdom 不实现 scrollIntoView;polyfill 成 noop 让 scrollToAnchor 路径
|
// jsdom 不实现 scrollIntoView;polyfill 成 noop 让 scrollToAnchor 路径
|
||||||
@@ -785,4 +785,518 @@ describe('MarkdownViewer dispose 释放 IntersectionObserver (audit Round 12 P3)
|
|||||||
window.IntersectionObserver = RealIO;
|
window.IntersectionObserver = RealIO;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// audit R7b P1-16:loadContentLive rAF chunked insert
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 核心契约:loadContentLive 不再同步 `innerHTML = html` 整页替换。
|
||||||
|
// - 小文档(≤ 256KB html)同步解析 + 一次性插入,手感与原实现一致
|
||||||
|
// - 大文档解析推迟到下一帧,children > 200 时每帧最多 append 200 个
|
||||||
|
// - 单飞行:新一次 loadContentLive abort 上一次未完成的 insert
|
||||||
|
// - heading observer / resolveRelativeImages 推迟到全部 chunk 落地后才跑
|
||||||
|
// - loadContent / clear / showUnsupportedFile / dispose 都会取消 in-flight insert
|
||||||
|
//
|
||||||
|
// mock 策略:window.api.renderMarkdown = pass-through(markdown 输入即 HTML 输出),
|
||||||
|
// 这样测试可以自己构造精确的 DOM 结构(段落数 / heading id)。
|
||||||
|
describe('MarkdownViewer loadContentLive chunked insert (audit R7b P1-16)', () => {
|
||||||
|
let body;
|
||||||
|
let empty;
|
||||||
|
let viewer;
|
||||||
|
let savedApi;
|
||||||
|
let RealIO;
|
||||||
|
|
||||||
|
function makeParas(n, prefix = 'p') {
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < n; i++) parts.push(`<p>${prefix}-${i}</p>`);
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
|
||||||
|
body = document.querySelector('.body');
|
||||||
|
empty = document.querySelector('.empty');
|
||||||
|
savedApi = window.api;
|
||||||
|
window.api = { renderMarkdown: (md) => md };
|
||||||
|
// jsdom 没有 IntersectionObserver —— 与 dispose 测试相同的 mock 策略。
|
||||||
|
// 不 mock 的话,fixture 里带 heading 时 _rebuildHeadingObserver 会抛
|
||||||
|
// ReferenceError,_finishLiveInsert 后续的 resolveRelativeImages 被跳过。
|
||||||
|
RealIO = window.IntersectionObserver;
|
||||||
|
window.IntersectionObserver = vi.fn(function () {
|
||||||
|
return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() };
|
||||||
|
});
|
||||||
|
viewer = new MarkdownViewer({
|
||||||
|
bodyElement: body,
|
||||||
|
emptyElement: empty,
|
||||||
|
statusPath: document.createElement('div'),
|
||||||
|
statusMeta: document.createElement('div'),
|
||||||
|
});
|
||||||
|
// resolveRelativeImages 需要 currentFile.path;无 getDataDir → 跳过越界检查
|
||||||
|
viewer.currentFile = { path: '/notes/a.md', name: 'a.md' };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (viewer) viewer.dispose();
|
||||||
|
if (savedApi === undefined) delete window.api;
|
||||||
|
else window.api = savedApi;
|
||||||
|
window.IntersectionObserver = RealIO;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changed 语义:内容变了 → true;同内容 → false 且 _resolveGen 仍 +1', () => {
|
||||||
|
const gen0 = viewer._resolveGen;
|
||||||
|
expect(viewer.loadContentLive('<p>x</p>')).toEqual({ changed: true });
|
||||||
|
const gen1 = viewer._resolveGen;
|
||||||
|
// 第一次调用 bump 了 generation(loadContentLive 自身 +1;
|
||||||
|
// 收尾的 resolveRelativeImages 入口也会 +1 —— 不锁精确值,只锁「有 bump」)
|
||||||
|
expect(gen1).toBeGreaterThan(gen0);
|
||||||
|
expect(viewer.loadContentLive('<p>x</p>')).toEqual({ changed: false });
|
||||||
|
// M-series Phase M-H3:同内容早返回也恰好 bump 一次(让 in-flight 的
|
||||||
|
// resolveRelativeImages 失配放弃),这里锁精确 +1
|
||||||
|
expect(viewer._resolveGen).toBe(gen1 + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('小文档(≤ 200 children)同步落地:返回后 DOM 立即可用', () => {
|
||||||
|
const r = viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||||
|
expect(r).toEqual({ changed: true });
|
||||||
|
// fast-path:不经过 rAF,同步完成 —— 与原同步 innerHTML 行为等价
|
||||||
|
expect(body.querySelectorAll('p').length).toBe(3);
|
||||||
|
expect(body.textContent).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('大文档(> 200 children)分批:同步返回时 body 已清空,await 后完整', async () => {
|
||||||
|
const html = makeParas(500);
|
||||||
|
const r = viewer.loadContentLive(html);
|
||||||
|
expect(r).toEqual({ changed: true });
|
||||||
|
// 分批路径:首帧只做「清空 + 解析」,append 在后续 rAF 帧里
|
||||||
|
expect(body.querySelectorAll('p').length).toBe(0);
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const ps = body.querySelectorAll('p');
|
||||||
|
expect(ps.length).toBe(500);
|
||||||
|
// 顺序保持(chunk 从 fragment 头部依序取)
|
||||||
|
expect(ps[0].textContent).toBe('p-0');
|
||||||
|
expect(ps[499].textContent).toBe('p-499');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('单飞行:连续两次 loadContentLive,第一次的剩余 chunk 不会混进第二次', async () => {
|
||||||
|
// 第一次:500 段(走分批,同步返回时什么都还没 append)
|
||||||
|
viewer.loadContentLive(makeParas(500, 'old'));
|
||||||
|
// 不 await,立刻第二次:3 段小文档
|
||||||
|
viewer.loadContentLive('<p>B1</p><p>B2</p><p>B3</p>');
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const ps = body.querySelectorAll('p');
|
||||||
|
// 关键断言:最终只有第二次的 3 段;第一次的 500 段一段都不残留
|
||||||
|
expect(ps.length).toBe(3);
|
||||||
|
expect(ps[0].textContent).toBe('B1');
|
||||||
|
expect(ps[2].textContent).toBe('B3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heading observer 与 resolveRelativeImages 推迟到全部 chunk 落地后', async () => {
|
||||||
|
const rebuildSpy = vi.spyOn(viewer, '_rebuildHeadingObserver');
|
||||||
|
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||||
|
|
||||||
|
// 500 段 + 末尾一个带 id 的 h1(childNodes = 501 > 200 → 分批)
|
||||||
|
viewer.loadContentLive(makeParas(500) + '<h1 id="end-h">E</h1>');
|
||||||
|
// 同步返回时:observer / resolve 都不该被调(DOM 还没拼完)
|
||||||
|
expect(rebuildSpy).not.toHaveBeenCalled();
|
||||||
|
expect(resolveSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
expect(rebuildSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// heading 快查表已含新 h1(observer 是在完整 DOM 上重建的)
|
||||||
|
expect(viewer._headingMap.get('end-h')).toMatchObject({ level: 1, text: 'E' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose 取消 in-flight insert:不再 append,也不跑收尾回调', async () => {
|
||||||
|
const rebuildSpy = vi.spyOn(viewer, '_rebuildHeadingObserver');
|
||||||
|
viewer.loadContentLive(makeParas(500));
|
||||||
|
viewer.dispose();
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
// 首帧清空后从未 append(dispose 在第一个 rAF 帧之前 abort)
|
||||||
|
expect(body.querySelectorAll('p').length).toBe(0);
|
||||||
|
expect(rebuildSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadContent 取消 in-flight live insert:旧 chunk 不污染新文件 DOM', async () => {
|
||||||
|
viewer.loadContentLive(makeParas(500, 'stale'));
|
||||||
|
// 立刻切文件(loadContent 是冷启动路径,保持同步 innerHTML)
|
||||||
|
await viewer.loadContent({ path: '/notes/b.md', name: 'b.md', size: 3 }, '# B');
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
// 新文件内容(mock render 是 pass-through → '# B' 文本节点)
|
||||||
|
expect(body.textContent).toBe('# B');
|
||||||
|
// 旧的 500 段一个都不剩
|
||||||
|
expect(body.querySelectorAll('p').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear 取消 in-flight live insert', async () => {
|
||||||
|
viewer.loadContentLive(makeParas(500));
|
||||||
|
viewer.clear();
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
expect(body.querySelectorAll('p').length).toBe(0);
|
||||||
|
expect(body.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('性能:5MB html 连续 50 次 loadContentLive,DOM 提交推迟到 rAF(同步段不阻塞)', async () => {
|
||||||
|
// audit P1-16 验收标准:5MB fixture + 50 次 load,调用方视角的同步段
|
||||||
|
// 必须不阻塞主线程 —— 真正的 parse + append 全部推迟到 rAF 帧。
|
||||||
|
// (jsdom 的 parse5 与 Chromium 原生 parser 性能不可比,真机 5MB 实测
|
||||||
|
// 在 Electron 里手动验证;此处锁定的是「不再同步提交 DOM」的语义。)
|
||||||
|
const para = '<p>lorem ipsum dolor sit amet, consectetur adipiscing elit</p>';
|
||||||
|
const bigHtml = para.repeat(Math.ceil((5 * 1024 * 1024) / para.length));
|
||||||
|
expect(bigHtml.length).toBeGreaterThan(5 * 1024 * 1024);
|
||||||
|
|
||||||
|
// 同步段:50 次调用都走「大文档推迟」路径(> 256KB → 首帧先 await rAF
|
||||||
|
// 才碰 body)。loadContentLive 同步返回时 body 不应被大子树同步污染 ——
|
||||||
|
// 这是 P1-16 锁死的核心不变量,且不受机器 / V8 版本性能差异影响。
|
||||||
|
const t0 = performance.now();
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
// 每次内容都略不同(绕开「同内容早返回」分支),但差异极小不影响测量。
|
||||||
|
viewer.loadContentLive(bigHtml + `<!-- ${i} -->`);
|
||||||
|
}
|
||||||
|
const wall = performance.now() - t0;
|
||||||
|
|
||||||
|
// 关键断言(精确、机器无关):同步返回后 body 还没有被大 html 同步插入。
|
||||||
|
expect(body.querySelectorAll('p').length).toBe(0);
|
||||||
|
|
||||||
|
// 绝对 wall 在不同机器 / V8 版本上差异较大(循环里 50 次 × 5MB 字符串分配
|
||||||
|
// 主导,与真实 DOM 提交无关),仅作回归粗筛:不应出现秒级卡顿。
|
||||||
|
expect(wall).toBeLessThan(2000);
|
||||||
|
|
||||||
|
// 收尾:abort 掉最后一次还没开始解析的 insert(5MB 的 parse5 解析在
|
||||||
|
// jsdom 里要秒级,aborted 时首帧直接 return,不会真跑)。
|
||||||
|
viewer.clear();
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// P2-17(audit R8):_applyHtmlLive 两端 diff —— 未变更顶级块保留 DOM 节点
|
||||||
|
// 引用,只删/插真正变化的中段;属性顺序差异走 isEqualNode 等价 → 零变更。
|
||||||
|
// P2-14:_finishLiveInsert 把变更块作为 roots 传给 resolveRelativeImages,
|
||||||
|
// 图片解析只扫新插入的块;loadContent 等非 diff 路径不传 roots(全量扫描)。
|
||||||
|
describe('MarkdownViewer live diff 节点保留 + resolve scope (audit R8 P2-17 / P2-14)', () => {
|
||||||
|
let body;
|
||||||
|
let empty;
|
||||||
|
let viewer;
|
||||||
|
let savedApi;
|
||||||
|
let RealIO;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
|
||||||
|
body = document.querySelector('.body');
|
||||||
|
empty = document.querySelector('.empty');
|
||||||
|
savedApi = window.api;
|
||||||
|
window.api = { renderMarkdown: (md) => md };
|
||||||
|
// 与 P1-16 块相同:jsdom 无 IntersectionObserver,不 mock 会让
|
||||||
|
// _finishLiveInsert 里的 _rebuildHeadingObserver 抛 ReferenceError。
|
||||||
|
RealIO = window.IntersectionObserver;
|
||||||
|
window.IntersectionObserver = vi.fn(function () {
|
||||||
|
return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() };
|
||||||
|
});
|
||||||
|
viewer = new MarkdownViewer({
|
||||||
|
bodyElement: body,
|
||||||
|
emptyElement: empty,
|
||||||
|
statusPath: document.createElement('div'),
|
||||||
|
statusMeta: document.createElement('div'),
|
||||||
|
});
|
||||||
|
viewer.currentFile = { path: '/notes/a.md', name: 'a.md' };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (viewer) viewer.dispose();
|
||||||
|
if (savedApi === undefined) delete window.api;
|
||||||
|
else window.api = savedApi;
|
||||||
|
window.IntersectionObserver = RealIO;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 尾部追加:前缀节点引用保留,只插新块', async () => {
|
||||||
|
viewer.loadContentLive('<p>a</p><p>b</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const a = body.childNodes[0];
|
||||||
|
const b = body.childNodes[1];
|
||||||
|
|
||||||
|
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
expect(body.childNodes.length).toBe(3);
|
||||||
|
expect(body.childNodes[0]).toBe(a);
|
||||||
|
expect(body.childNodes[1]).toBe(b);
|
||||||
|
expect(body.childNodes[2].textContent).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 中间编辑:只替换变化的块,前后节点引用不变', async () => {
|
||||||
|
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const a = body.childNodes[0];
|
||||||
|
const mid = body.childNodes[1];
|
||||||
|
const c = body.childNodes[2];
|
||||||
|
|
||||||
|
viewer.loadContentLive('<p>a</p><p>B</p><p>c</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
expect(body.childNodes.length).toBe(3);
|
||||||
|
expect(body.childNodes[0]).toBe(a);
|
||||||
|
expect(body.childNodes[2]).toBe(c);
|
||||||
|
expect(body.childNodes[1]).not.toBe(mid);
|
||||||
|
expect(body.childNodes[1].textContent).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 中间插入与删除:前后缀节点引用保留', async () => {
|
||||||
|
viewer.loadContentLive('<p>a</p><p>c</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const a = body.childNodes[0];
|
||||||
|
const c = body.childNodes[1];
|
||||||
|
|
||||||
|
// 中间插入一个块
|
||||||
|
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
expect(body.childNodes.length).toBe(3);
|
||||||
|
expect(body.childNodes[0]).toBe(a);
|
||||||
|
expect(body.childNodes[2]).toBe(c);
|
||||||
|
expect(body.childNodes[1].textContent).toBe('b');
|
||||||
|
|
||||||
|
// 再删掉它(回到原状)—— a/c 仍然是同一批节点
|
||||||
|
viewer.loadContentLive('<p>a</p><p>c</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
expect(body.childNodes.length).toBe(2);
|
||||||
|
expect(body.childNodes[0]).toBe(a);
|
||||||
|
expect(body.childNodes[1]).toBe(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 属性顺序差异 → isEqualNode 等价:零 DOM 变更,resolve 收到空 roots (P2-14)', async () => {
|
||||||
|
viewer.loadContentLive('<p id="a" class="x">t</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const p = body.childNodes[0];
|
||||||
|
|
||||||
|
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||||
|
// 字符串不同(绕过 content 早返回),但 DOM 语义完全等价
|
||||||
|
viewer.loadContentLive('<p class="x" id="a">t</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
expect(body.childNodes.length).toBe(1);
|
||||||
|
expect(body.childNodes[0]).toBe(p); // 节点没有被替换
|
||||||
|
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// P2-14:零变更路径传 roots=[] → resolve 内部一张 img 都不扫
|
||||||
|
expect(resolveSpy.mock.calls[0][1]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 保留前后缀 + 大中段(500 块):仍走分批插入,前后缀节点不动', async () => {
|
||||||
|
viewer.loadContentLive('<p>keep-0</p><p>keep-1</p><p>keep-2</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const k0 = body.childNodes[0];
|
||||||
|
const k1 = body.childNodes[1];
|
||||||
|
const k2 = body.childNodes[2];
|
||||||
|
|
||||||
|
const mid = [];
|
||||||
|
for (let i = 0; i < 500; i++) mid.push(`<p>mid-${i}</p>`);
|
||||||
|
viewer.loadContentLive('<p>keep-0</p>' + mid.join('') + '<p>keep-1</p><p>keep-2</p>');
|
||||||
|
// 中段 500 > 200 → 分批:同步返回时中段还没开始插(body 仍是 3 个 keep)
|
||||||
|
expect(body.childNodes.length).toBe(3);
|
||||||
|
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
expect(body.childNodes.length).toBe(503);
|
||||||
|
expect(body.childNodes[0]).toBe(k0);
|
||||||
|
expect(body.childNodes[501]).toBe(k1);
|
||||||
|
expect(body.childNodes[502]).toBe(k2);
|
||||||
|
expect(body.childNodes[1].textContent).toBe('mid-0');
|
||||||
|
expect(body.childNodes[500].textContent).toBe('mid-499');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 abort 自愈:分批插入中途被接管,最终 DOM 收敛到新内容', async () => {
|
||||||
|
const paras = [];
|
||||||
|
for (let i = 0; i < 500; i++) paras.push(`<p>A-${i}</p>`);
|
||||||
|
viewer.loadContentLive(paras.join(''));
|
||||||
|
// 等一帧(rAF 按请求顺序执行:apply 的回调先跑完第一批插入,我们的才
|
||||||
|
// resolve)—— 此时 A 只落地了第一批 200 段,body 处于「半截 A」态。
|
||||||
|
await viewer._nextFrame();
|
||||||
|
const partial = body.querySelectorAll('p').length;
|
||||||
|
expect(partial).toBeGreaterThan(0);
|
||||||
|
expect(partial).toBeLessThan(500);
|
||||||
|
|
||||||
|
// 新内容接管:abort 旧的,diff 的旧侧读当前真实 DOM(半截 A),
|
||||||
|
// 不做任何回滚也能收敛到 B —— 这是「abort 自愈」语义的核心断言。
|
||||||
|
viewer.loadContentLive('<p>B1</p><p>B2</p><p>B3</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
const ps = body.querySelectorAll('p');
|
||||||
|
expect(ps.length).toBe(3);
|
||||||
|
expect(ps[0].textContent).toBe('B1');
|
||||||
|
expect(ps[2].textContent).toBe('B3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-14 live diff 后 resolve 只扫变更块:roots 恰好是新插入的那个块', async () => {
|
||||||
|
viewer.loadContentLive('<p>one</p><p>two</p><p>three</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||||
|
viewer.loadContentLive('<p>one</p><p>TWO</p><p>three</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const roots = resolveSpy.mock.calls[0][1];
|
||||||
|
expect(roots.length).toBe(1);
|
||||||
|
expect(roots[0].textContent).toBe('TWO');
|
||||||
|
// roots 里的就是当前 body 里那个新块;保留的前后缀不在其中
|
||||||
|
expect(roots[0]).toBe(body.childNodes[1]);
|
||||||
|
expect(roots).not.toContain(body.childNodes[0]);
|
||||||
|
expect(roots).not.toContain(body.childNodes[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-14 loadContent 路径不传 roots → 维持全 body 扫描原语义', async () => {
|
||||||
|
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||||
|
await viewer.loadContent({ path: '/notes/b.md', name: 'b.md' }, 'plain text');
|
||||||
|
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resolveSpy.mock.calls[0][1]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-14 变更块里的图片在 roots scope 下照常解析成 file://', async () => {
|
||||||
|
viewer.getDataDir = () => posixPath('/notes');
|
||||||
|
viewer.loadContentLive('<p>txt</p><p><img src="pic.png"></p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
// 同步 getDataDir → resolve 在 _finishLiveInsert 里同步跑完,
|
||||||
|
// _test_liveInsertDone 返回时图片已解析
|
||||||
|
const img = body.querySelector('img');
|
||||||
|
expect(img.getAttribute('src')).toMatch(/^file:/);
|
||||||
|
expect(img.getAttribute('src')).toMatch(/pic\.png$/);
|
||||||
|
expect(img.getAttribute('loading')).toBe('lazy');
|
||||||
|
expect(img.getAttribute('decoding')).toBe('async');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2-17 已知行为:含已解析 file:// 图片的块下轮 diff 失配 → 替换 + scoped 重解析', async () => {
|
||||||
|
// resolveRelativeImages 会把 img src 改写成 file:// 并加 loading/decoding,
|
||||||
|
// 而下一轮渲染产物里仍是原始相对 src → isEqualNode 为 false,含图块
|
||||||
|
// 无法享受节点保留(退化为替换 + scoped 重解析)。这与 P1-16 前的全量
|
||||||
|
// 替换基线行为一致(图片块本来就每轮重解析),不是回归 —— 锁语义防误改。
|
||||||
|
viewer.getDataDir = () => posixPath('/notes');
|
||||||
|
viewer.loadContentLive('<p><img src="pic.png"></p><p>one</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
const imgBlock = body.childNodes[0];
|
||||||
|
const textBlock = body.childNodes[1];
|
||||||
|
expect(imgBlock.querySelector('img').getAttribute('src')).toMatch(/^file:/);
|
||||||
|
|
||||||
|
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||||
|
viewer.loadContentLive('<p><img src="pic.png"></p><p>two</p>');
|
||||||
|
await viewer._test_liveInsertDone();
|
||||||
|
|
||||||
|
// 文本块变了 + 含图块因 src 改写失配 → 两个块都被替换,都进 roots
|
||||||
|
expect(body.childNodes[0]).not.toBe(imgBlock);
|
||||||
|
expect(body.childNodes[1]).not.toBe(textBlock);
|
||||||
|
expect(resolveSpy.mock.calls[0][1].length).toBe(2);
|
||||||
|
// 重解析后新块的 img 仍是合法 file://(scoped resolve 覆盖到位)
|
||||||
|
const img = body.querySelector('img');
|
||||||
|
expect(img.getAttribute('src')).toMatch(/^file:/);
|
||||||
|
expect(img.getAttribute('src')).toMatch(/pic\.png$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 状态栏 aria-label 与 textContent 必须同源 —— 防止「SR 听到短名,视觉看到全路径」
|
||||||
|
// 的可访问性漂移。同时占位 fallback 也走同一函数,不再硬编码 '—' 字面量。
|
||||||
|
describe('MarkdownViewer 状态栏 aria-label (audit R7a a11y)', () => {
|
||||||
|
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(opts = {}) {
|
||||||
|
return new MarkdownViewer({
|
||||||
|
bodyElement: body,
|
||||||
|
emptyElement: empty,
|
||||||
|
statusPath,
|
||||||
|
statusMeta: document.createElement('div'),
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('clear() 在无 getCurrentDir 时:textContent + aria-label 都走占位分支', () => {
|
||||||
|
const v = makeViewer();
|
||||||
|
v.clear();
|
||||||
|
expect(statusPath.textContent).toBe('—');
|
||||||
|
// 占位分支的 aria-label 是固定的描述性短句,描述按钮行为而不是"显示 —"
|
||||||
|
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示当前文件夹');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear() 有 currentDir 时:aria-label 把目录路径带上', () => {
|
||||||
|
const v = makeViewer({ getCurrentDir: () => '/data/notes/sub' });
|
||||||
|
v.clear();
|
||||||
|
expect(statusPath.textContent).toBe('/data/notes/sub');
|
||||||
|
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /data/notes/sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshCurrentDir 跟 clear 同源:用 STATUS_PATH_PLACEHOLDER 判占位', () => {
|
||||||
|
// 拿不到 dir → 占位路径 + 占位 aria-label
|
||||||
|
let cur = '';
|
||||||
|
const v = makeViewer({ getCurrentDir: () => cur });
|
||||||
|
v.clear();
|
||||||
|
cur = '/data/notes';
|
||||||
|
v.refreshCurrentDir();
|
||||||
|
expect(statusPath.textContent).toBe('/data/notes');
|
||||||
|
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /data/notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatusBar:aria-label 与 textContent 完全一致(不是只听文件名)', () => {
|
||||||
|
// 原 bug:file.name=foo.md,file.path=/a/b/foo.md → 视觉看到全路径,
|
||||||
|
// SR 只听到"在文件管理器中显示 foo.md"。现在改成用 path 统一。
|
||||||
|
const v = makeViewer();
|
||||||
|
v.updateStatusBar({ name: 'foo.md', path: '/a/b/foo.md', size: 100 });
|
||||||
|
expect(statusPath.textContent).toBe('/a/b/foo.md');
|
||||||
|
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /a/b/foo.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatusBar:缺 name 但有 path → aria-label 仍带 path', () => {
|
||||||
|
const v = makeViewer();
|
||||||
|
v.updateStatusBar({ path: '/only/path.md' });
|
||||||
|
expect(statusPath.textContent).toBe('/only/path.md');
|
||||||
|
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /only/path.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatusBar:file={} → 占位 aria-label(不暴「显示 —」)', () => {
|
||||||
|
const v = makeViewer();
|
||||||
|
v.updateStatusBar({});
|
||||||
|
expect(statusPath.textContent).toBe('—');
|
||||||
|
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示当前文件夹');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 空状态 CTA 委托:app.js init 把 click listener 挂在 emptyElement 上而不是按钮本身上。
|
||||||
|
// 因为 markdown-viewer.js 的 showUnsupportedFile / _resetEmptyState 会 innerHTML 重写
|
||||||
|
// emptyElement —— 监听挂在被 detached 的 button 上没意义;委托给父容器才稳。
|
||||||
|
// 这个 describe 模拟 app.js 的注册逻辑,确保 innerHTML 重写后按钮仍被响应。
|
||||||
|
describe('空状态 CTA 委托模式 (R7a a11y/correctness)', () => {
|
||||||
|
it('innerHTML 重写后,委托到父容器的 listener 仍能命中新按钮', () => {
|
||||||
|
document.body.innerHTML = '<div id="empty-state"><button id="empty-state-new-btn">新建笔记</button></div>';
|
||||||
|
const empty = document.getElementById('empty-state');
|
||||||
|
const initialBtn = document.getElementById('empty-state-new-btn');
|
||||||
|
|
||||||
|
// 模拟 app.js 的真实注册:挂在 emptyElement 上,closest 取按钮
|
||||||
|
const handler = vi.fn();
|
||||||
|
empty.addEventListener('click', (ev) => {
|
||||||
|
if (ev.target.closest('#empty-state-new-btn')) handler();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 第一次点击:原始按钮,被 handler 响应
|
||||||
|
initialBtn.click();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 模拟 markdown-viewer.js 改写 innerHTML(showUnsupportedFile 会做)
|
||||||
|
empty.innerHTML = '<p>该文件不支持预览</p>';
|
||||||
|
// 再 _resetEmptyState 恢复默认空态 —— button 是新建的节点
|
||||||
|
empty.innerHTML = '<button id="empty-state-new-btn">新建笔记</button>';
|
||||||
|
const newBtn = document.getElementById('empty-state-new-btn');
|
||||||
|
expect(newBtn).not.toBe(initialBtn); // 确认是新节点
|
||||||
|
|
||||||
|
// 第二次点击新按钮 —— 委托仍在 empty 上,必须仍能命中
|
||||||
|
newBtn.click();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -86,10 +86,20 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
|
|||||||
['.\\foo.png', true],
|
['.\\foo.png', true],
|
||||||
['..\\up.md', true],
|
['..\\up.md', true],
|
||||||
['\\abs\\x.png', true],
|
['\\abs\\x.png', true],
|
||||||
|
// Mac/Linux 文件名 / 路径段内允许 `:` —— 之前 `[^/\\:]*` 把整个 Mac 截图命名
|
||||||
|
// 习惯(`image:001.jpg` 等)拒掉,正则分支 A 用 `(?![/\\])` 守住
|
||||||
|
// `data:text/html` / `javascript:foo/bar` 同形态,外层 `(?!)` 显式拒已
|
||||||
|
// 知危险 / 未授权 scheme,IMG 分支再 defense-in-depth。
|
||||||
|
['image:001.jpg', true],
|
||||||
|
['screenshot:1.png', true],
|
||||||
|
['dir/sub:dir/file.png', true],
|
||||||
|
// 即使危险 scheme 后只接字母(无 `/`),正则仍拒绝:顶层 `(?!)` 拦掉。
|
||||||
|
['javascript:foo', false],
|
||||||
|
['vbscript:foo', false],
|
||||||
|
['about:blank', false],
|
||||||
['javascript:alert(1)', false],
|
['javascript:alert(1)', false],
|
||||||
['vbscript:msgbox(1)', false],
|
['vbscript:msgbox(1)', false],
|
||||||
['data:text/html,xxx', false],
|
['data:text/html,xxx', false],
|
||||||
['about:blank', false],
|
|
||||||
['chrome-extension://x/y', false],
|
['chrome-extension://x/y', false],
|
||||||
// 协议相对 URL:// 或 \\ 开头会被浏览器解析为「继承当前 scheme」,
|
// 协议相对 URL:// 或 \\ 开头会被浏览器解析为「继承当前 scheme」,
|
||||||
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
|
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
|
||||||
@@ -191,6 +201,26 @@ describe('<img src> 保留策略', () => {
|
|||||||
expect(html).not.toMatch(/src="data:text\/html/i);
|
expect(html).not.toMatch(/src="data:text\/html/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mac/Linux 文件名 / 路径段内允许 `:`(修复前会被剥)。分支 A `(?![/\\])`
|
||||||
|
// 让 `image:001.jpg` 通过、`data:text/html` 拒掉;外层 `(?!)` 与 IMG 兜底把
|
||||||
|
// 形如 `javascript:foo` / `vbscript:foo` / `about:blank` 拦死。
|
||||||
|
it.each([
|
||||||
|
['image:001.jpg', 'image:001.jpg'],
|
||||||
|
['screenshot:1.png', 'screenshot:1.png'],
|
||||||
|
['dir/sub:dir/file.png', 'dir/sub:dir/file.png'],
|
||||||
|
])('Mac/Linux 文件名带 : 保留 src(%s)', (_label, src) => {
|
||||||
|
const html = render(``);
|
||||||
|
expect(html).toContain(`src="${src}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('javascript:foo / vbscript:foo / about:blank(无括号变体)在 <img src> 被剥', () => {
|
||||||
|
// 这些形态与新增的 Mac 文件名分支 A 同结构 —— 分支 A `(?![/\\])` 不会拒它们
|
||||||
|
// (没有 `/` 紧跟 `:`),靠外层 `(?!)` 兜底。测试保证这条防御路径覆盖到。
|
||||||
|
expect(render('')).not.toMatch(/src="javascript:/);
|
||||||
|
expect(render('')).not.toMatch(/src="vbscript:/i);
|
||||||
|
expect(render('')).not.toMatch(/src="about:/i);
|
||||||
|
});
|
||||||
|
|
||||||
// fix(audit 2026-08):SVG 数据 URL 在 <a href> 上点击会导航到 top-level SVG
|
// fix(audit 2026-08):SVG 数据 URL 在 <a href> 上点击会导航到 top-level SVG
|
||||||
// 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。
|
// 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。
|
||||||
// ALLOWED_URI_REGEXP 限定 data:image 为栅格格式(png/jpeg/gif/webp/...),
|
// ALLOWED_URI_REGEXP 限定 data:image 为栅格格式(png/jpeg/gif/webp/...),
|
||||||
|
|||||||
54
tests/unit/save-settings-validate.test.js
Normal file
54
tests/unit/save-settings-validate.test.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// main.js app:save-settings —— invalid partial payload 拒绝(audit P1-1)
|
||||||
|
//
|
||||||
|
// 背景:原 handler `if (!partial || typeof partial !== 'object') return
|
||||||
|
// { ok: true, settings: configStore.getConfig() };` 把 null / undefined /
|
||||||
|
// string / array 都当成「无更新」静默放过,renderer 缓存与磁盘脱钩
|
||||||
|
// (settingsStore.update({...}) 拿不到 error 也不会重试,直到下次合法保存)。
|
||||||
|
//
|
||||||
|
// 修复:抽出 isValidPartialSettings(partial) 纯函数;handler 顶部用。
|
||||||
|
// 拒收集合:null / undefined / 任何 primitive / Array / Function。
|
||||||
|
// 接收:plain object(typeof === 'object' && !Array.isArray)。
|
||||||
|
//
|
||||||
|
// 测试策略:直接 import 纯函数,无 Electron / fs / IPC mock 依赖。
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isValidPartialSettings } from '../../main/save-settings-validate.js';
|
||||||
|
|
||||||
|
describe('app:save-settings partial payload 校验(audit P1-1)', () => {
|
||||||
|
it('null → false', () => {
|
||||||
|
expect(isValidPartialSettings(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undefined → false', () => {
|
||||||
|
expect(isValidPartialSettings(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字符串 primitive → false', () => {
|
||||||
|
expect(isValidPartialSettings('hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('数字 primitive → false', () => {
|
||||||
|
expect(isValidPartialSettings(42)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boolean primitive → false', () => {
|
||||||
|
expect(isValidPartialSettings(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Array → false(即使非空)', () => {
|
||||||
|
expect(isValidPartialSettings([])).toBe(false);
|
||||||
|
expect(isValidPartialSettings(['a', 'b'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Function → false', () => {
|
||||||
|
expect(isValidPartialSettings(() => {})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain object(空)→ true', () => {
|
||||||
|
expect(isValidPartialSettings({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain object(带字段)→ true', () => {
|
||||||
|
expect(isValidPartialSettings({ alwaysOnTop: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
260
tests/unit/secret-store.test.js
Normal file
260
tests/unit/secret-store.test.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// main/secret-store.js —— aiApiKey safeStorage 加密层单测(audit R3 P0-1)
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// - encrypt / decrypt 的对称与幂等
|
||||||
|
// - safeStorage 不可用(典型 Linux 无 keyring)降级 + warn-once
|
||||||
|
// - decrypt 遇损坏包络 / keyring 不可用 → 返回 ''(不抛)
|
||||||
|
// - 旧版明文 passthrough(懒迁移关键:旧 config.json 仍能解密出 Key)
|
||||||
|
// - isAvailable 处理 safeStorage 自身抛错 / 未注册的情况
|
||||||
|
//
|
||||||
|
// 注入模式:模块自暴露的 _setSafeStorage(mock)。原因同 main/config-store.js
|
||||||
|
// —— vi.mock('electron') 拦不住 CJS require。mock 用闭包记录 isEncryptionAvailable
|
||||||
|
// 状态和调用计数,单测间通过 _reset() 重置。
|
||||||
|
|
||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// 强制每次 beforeEach 拿一份新模块实例(_reset 也会清 _warnedUnavailable 标志)
|
||||||
|
const loadModule = async () => {
|
||||||
|
// 走 ESM dynamic import;secret-store.js 是 CJS,但 Node 处理 CJS↔ESM 互操作
|
||||||
|
// 给一个 .default 包含 module.exports 的默认导出(如果用 require() 则没有)
|
||||||
|
// —— secret-store.js 用 module.exports = {...},ESM 默认 import 拿到的就是
|
||||||
|
// module.exports 本身,不需要 .default 访问。
|
||||||
|
const mod = await import('../../main/secret-store.js');
|
||||||
|
return mod;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('secret-store:encrypt / decrypt 对称', () => {
|
||||||
|
let mod;
|
||||||
|
let encryptCalls;
|
||||||
|
let decryptCalls;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mod = await loadModule();
|
||||||
|
encryptCalls = [];
|
||||||
|
decryptCalls = [];
|
||||||
|
// mock safeStorage:明文 ↔ base64('ENC:<plaintext>') 做对称回路
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (plain) => {
|
||||||
|
encryptCalls.push(plain);
|
||||||
|
return Buffer.from(`ENC:${plain}`, 'utf-8');
|
||||||
|
},
|
||||||
|
decryptString: (buf) => {
|
||||||
|
const s = buf.toString('utf-8');
|
||||||
|
decryptCalls.push(s);
|
||||||
|
if (!s.startsWith('ENC:')) throw new Error('mock: bad envelope');
|
||||||
|
return s.slice('ENC:'.length);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mod._reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypt(\'\') → \'\'', () => {
|
||||||
|
expect(mod.encrypt('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypt(null) → \'\'', () => {
|
||||||
|
expect(mod.encrypt(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypt(undefined) → \'\'', () => {
|
||||||
|
expect(mod.encrypt(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypt(\'sk-test\') → 包络(enc:v1: 前缀 + base64)', () => {
|
||||||
|
const out = mod.encrypt('sk-test');
|
||||||
|
expect(out.startsWith(mod.ENVELOPE_PREFIX)).toBe(true);
|
||||||
|
// base64 decode 应该回到我们 mock 用的 `ENC:sk-test`
|
||||||
|
const b64 = out.slice(mod.ENVELOPE_PREFIX.length);
|
||||||
|
const decoded = Buffer.from(b64, 'base64').toString('utf-8');
|
||||||
|
expect(decoded).toBe('ENC:sk-test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypt → decrypt 往返:明文完整恢复', () => {
|
||||||
|
const original = 'sk-中文-emoji-🔑-whitespace \t';
|
||||||
|
const enc = mod.encrypt(original);
|
||||||
|
expect(enc.startsWith(mod.ENVELOPE_PREFIX)).toBe(true);
|
||||||
|
expect(mod.decrypt(enc)).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypt 已经是包络的字符串 → 原样返回(幂等,防止套娃)', () => {
|
||||||
|
const once = mod.encrypt('sk-x');
|
||||||
|
const twice = mod.encrypt(once);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
// 验证 mock encryptString 只被调一次
|
||||||
|
expect(encryptCalls).toEqual(['sk-x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt(\'\') → \'\'', () => {
|
||||||
|
expect(mod.decrypt('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt(null) / decrypt(undefined) → \'\'', () => {
|
||||||
|
expect(mod.decrypt(null)).toBe('');
|
||||||
|
expect(mod.decrypt(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt 旧版明文(无前缀)→ 原样返回(懒迁移关键)', () => {
|
||||||
|
// 模拟升级前 config.json 里的 plaintext key
|
||||||
|
expect(mod.decrypt('sk-old-plaintext')).toBe('sk-old-plaintext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt 任意不含前缀的字符串 → 原样返回(防御性)', () => {
|
||||||
|
expect(mod.decrypt('xxx')).toBe('xxx');
|
||||||
|
expect(mod.decrypt('not-an-envelope-but-still-string')).toBe('not-an-envelope-but-still-string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secret-store:safeStorage 不可用降级', () => {
|
||||||
|
let warnSpy;
|
||||||
|
let mod;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mod = await loadModule();
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mod._reset();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEncryptionAvailable() === false → encrypt 返回明文 + warn 一次', () => {
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => false,
|
||||||
|
encryptString: () => Buffer.from('SHOULD-NOT-BE-USED'),
|
||||||
|
decryptString: () => 'SHOULD-NOT-BE-USED',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mod.encrypt('sk-fallback')).toBe('sk-fallback');
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warnSpy.mock.calls[0][0]).toMatch(/safeStorage 不可用/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多次 encrypt 不可用 → 只 warn 一次(不重复打扰用户)', () => {
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => false,
|
||||||
|
encryptString: () => Buffer.from('x'),
|
||||||
|
decryptString: () => 'x',
|
||||||
|
});
|
||||||
|
|
||||||
|
mod.encrypt('a');
|
||||||
|
mod.encrypt('b');
|
||||||
|
mod.encrypt('c');
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt 遇到包络但 keyring 不可用 → 返回 \'\'(不抛)', () => {
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => false,
|
||||||
|
encryptString: () => Buffer.from('x'),
|
||||||
|
decryptString: () => 'x',
|
||||||
|
});
|
||||||
|
// 模拟磁盘上有包络(base64 随便编)
|
||||||
|
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('whatever', 'utf-8').toString('base64');
|
||||||
|
expect(mod.decrypt(fakeEnvelope)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt 遇到损坏包络(decryptString 抛错)→ 返回 \'\'(不抛)', () => {
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (s) => Buffer.from(`ENC:${s}`),
|
||||||
|
decryptString: () => {
|
||||||
|
throw new Error('bad ciphertext');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('corrupt', 'utf-8').toString('base64');
|
||||||
|
expect(() => mod.decrypt(fakeEnvelope)).not.toThrow();
|
||||||
|
expect(mod.decrypt(fakeEnvelope)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEncryptionAvailable 自身抛错 → isAvailable() 返回 false', () => {
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => {
|
||||||
|
throw new Error('keyring daemon crashed');
|
||||||
|
},
|
||||||
|
encryptString: () => Buffer.from('x'),
|
||||||
|
decryptString: () => 'x',
|
||||||
|
});
|
||||||
|
expect(mod.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secret-store:safeStorage 完全缺失', () => {
|
||||||
|
let warnSpy;
|
||||||
|
let mod;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mod = await loadModule();
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mod._reset();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注入 null safeStorage(模拟 require(\'electron\') 无 safeStorage)→ 降级明文', () => {
|
||||||
|
mod._setSafeStorage(null);
|
||||||
|
// encrypt 走降级路径
|
||||||
|
expect(mod.encrypt('sk-x')).toBe('sk-x');
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注入 null safeStorage + decrypt 包络 → 返回 \'\'', () => {
|
||||||
|
mod._setSafeStorage(null);
|
||||||
|
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('x', 'utf-8').toString('base64');
|
||||||
|
expect(mod.decrypt(fakeEnvelope)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isAvailable 在无 safeStorage 时 → false', () => {
|
||||||
|
mod._setSafeStorage(null);
|
||||||
|
expect(mod.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secret-store:_reset 状态隔离', () => {
|
||||||
|
let mod;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mod = await loadModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_reset 后 _setSafeStorage 重新注入 + warn 标志清零', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => false,
|
||||||
|
encryptString: () => Buffer.from('x'),
|
||||||
|
decryptString: () => 'x',
|
||||||
|
});
|
||||||
|
mod.encrypt('a');
|
||||||
|
mod.encrypt('b');
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mod._reset();
|
||||||
|
// 重新注入可用 safeStorage
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (s) => Buffer.from(`E:${s}`),
|
||||||
|
decryptString: (b) => b.toString('utf-8').slice(2),
|
||||||
|
});
|
||||||
|
// warn 标志清零,再次不可用时重新 warn
|
||||||
|
mod._setSafeStorage({
|
||||||
|
isEncryptionAvailable: () => false,
|
||||||
|
encryptString: () => Buffer.from('x'),
|
||||||
|
decryptString: () => 'x',
|
||||||
|
});
|
||||||
|
mod.encrypt('c');
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
mod._reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -490,4 +490,48 @@ describe('SETTINGS_UI_OPTIONS.aiProvider[*].defaults (audit T-4)', () => {
|
|||||||
expect(() => { p.defaults.baseUrl = 'http://evil'; }).toThrow();
|
expect(() => { p.defaults.baseUrl = 'http://evil'; }).toThrow();
|
||||||
expect(p.defaults.baseUrl).toBe('https://api.openai.com/v1');
|
expect(p.defaults.baseUrl).toBe('https://api.openai.com/v1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2026-10 加:fileListSort 排序下拉的 schema 契约 —— enum 与 UI 选项表必须同源,
|
||||||
|
// renderer 从两端派生(FILE_LIST_SORT_VALUES / FILE_LIST_SORT_OPTIONS)。
|
||||||
|
// 任一端漏更新 → 排序下拉菜单少一项或多出非法值。
|
||||||
|
describe('fileListSort 排序下拉契约(2026-10 dropdown 改造)', () => {
|
||||||
|
const schema = require('../../shared/settings-schema.js');
|
||||||
|
const { SETTINGS_UI_OPTIONS, SETTINGS_SCHEMA } = schema;
|
||||||
|
const sortSpec = SETTINGS_SCHEMA.fileListSort;
|
||||||
|
const uiOptions = SETTINGS_UI_OPTIONS.fileListSort;
|
||||||
|
|
||||||
|
it('SETTINGS_SCHEMA.fileListSort.enum 与 SETTINGS_UI_OPTIONS.fileListSort[*].value 完全一致', () => {
|
||||||
|
const enumValues = sortSpec.enum;
|
||||||
|
const uiValues = uiOptions.map((o) => o.value);
|
||||||
|
// 用 toEqual 而非 toBe —— 顺序也得一致(dropdown 顺序由 enum 决定)
|
||||||
|
expect(uiValues).toEqual(enumValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('每项 UI 选项都含 value + label + hint 三个字段', () => {
|
||||||
|
uiOptions.forEach((opt) => {
|
||||||
|
expect(typeof opt.value).toBe('string');
|
||||||
|
expect(typeof opt.label).toBe('string');
|
||||||
|
expect(typeof opt.hint).toBe('string');
|
||||||
|
expect(opt.label.length).toBeGreaterThan(0);
|
||||||
|
expect(opt.hint.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enum 至少 2 项 —— dropdown 设计要求比并排按钮更多选项', () => {
|
||||||
|
// 设计动机:原并排 2 按钮在 220px 侧栏下「修改时间」会被截断;改下拉后
|
||||||
|
// 至少要有「按名称 A→Z」「按修改时间 新→旧」两项作为基础集合。
|
||||||
|
expect(sortSpec.enum.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT_SETTINGS.fileListSort === schema.enum[0] === UI options[0].value(保证 fallback / 旧值不会静默被 coerce)', () => {
|
||||||
|
// 三处必须相等:
|
||||||
|
// 1) DEFAULT_SETTINGS.fileListSort —— 新装用户的初始值
|
||||||
|
// 2) schema.enum[0] —— validateAndSanitize 接受的默认值会回到这个
|
||||||
|
// 3) SETTINGS_UI_OPTIONS.fileListSort[0].value —— 下拉默认选中项
|
||||||
|
// 谁漏改谁静默失效:例如把 DEFAULT 改成 'mtime-desc' 但 enum/uiOptions 不动,
|
||||||
|
// validateAndSanitize 会把它强制降回 'name',用户看 UI 永远停在 name。
|
||||||
|
expect(DEFAULT_SETTINGS.fileListSort).toBe(sortSpec.enum[0]);
|
||||||
|
expect(DEFAULT_SETTINGS.fileListSort).toBe(SETTINGS_UI_OPTIONS.fileListSort[0].value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
80
tests/unit/tmp-perms.test.js
Normal file
80
tests/unit/tmp-perms.test.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// main/file-ops.js + main/config-store.js tmp 文件权限(audit R5 P1-13)
|
||||||
|
//
|
||||||
|
// 背景:tmp 文件用 fs.open(path, 'w') 创建时,Node 默认 mode = 0o666 受
|
||||||
|
// umask 影响(典型 0o022 → 实际 0o644),多用户 Linux/macOS 上其他用户能
|
||||||
|
// 读父目录(典型 ~/Library/Application Support/Notes)就能读 tmp,里面
|
||||||
|
// 可能含 AI Key 包络 / 整份配置 / 用户笔记内容。
|
||||||
|
//
|
||||||
|
// 修复:fs.open(tmpPath, 'w', 0o600) 强制 rw owner only。
|
||||||
|
//
|
||||||
|
// 测试策略:直接调 fs.promises.open 验证 0o600 mode 生效(file-ops 内部用
|
||||||
|
// 的就是这条路径)。atomicWriteFile 端到端测试在 file-ops.test.js 已经覆盖
|
||||||
|
// 写盘正确性,本文件专注「mode 真的生效」这条安全属性。
|
||||||
|
|
||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
|
||||||
|
let workDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), 'notes-tmp-perms-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tmp 文件权限 0o600(audit R5 P1-13)', () => {
|
||||||
|
it('fs.open(path, "w", 0o600) → mode & 0o077 === 0', async () => {
|
||||||
|
// Windows NTFS 不强制 POSIX 权限位(用 ACL),statSync.mode 读出来仍是
|
||||||
|
// 默认 ACL 简化位。修复在 Windows 上是 no-op(但语义一致,无害)。
|
||||||
|
// 这条断言只验证 Linux/macOS —— 真正受多用户威胁面影响的平台。
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
const tmpPath = join(workDir, 'with-mode.tmp');
|
||||||
|
const fh = await open(tmpPath, 'w', 0o600);
|
||||||
|
await fh.writeFile('test', 'utf-8');
|
||||||
|
await fh.close();
|
||||||
|
const st = statSync(tmpPath);
|
||||||
|
// 0o600 = rw------- :owner r/w,其他全无
|
||||||
|
expect(st.mode & 0o077).toBe(0);
|
||||||
|
expect(st.mode & 0o700).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fs.open 缺 mode(fallback to 0o666 + umask)可能非 0o600 —— 回归保护', async () => {
|
||||||
|
// Windows 上 NTFS 不强制 POSIX 权限位(用 ACL),跳过
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
const tmpPath = join(workDir, 'no-mode.tmp');
|
||||||
|
const fh = await open(tmpPath, 'w');
|
||||||
|
await fh.writeFile('test', 'utf-8');
|
||||||
|
await fh.close();
|
||||||
|
const st = statSync(tmpPath);
|
||||||
|
// 不传 mode → 默认 0o666 受 umask 影响。
|
||||||
|
// umask 0o022 → 实际 0o644 (& 0o077 = 0o044)
|
||||||
|
// umask 0o077 → 实际 0o610 (& 0o077 = 0)
|
||||||
|
// 不强断言非 0,只把 mode 读出来供 review(不传 mode 是漏洞)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[tmp-perms] no-mode default mode = 0o${(st.mode & 0o777).toString(8)} (umask-dependent)`);
|
||||||
|
expect(st).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-ops atomicWriteFile 写盘成功 + 目标文件最终权限 0o600(rename 不改 mode)', async () => {
|
||||||
|
const fileOps = await import('../../main/file-ops.js');
|
||||||
|
const target = join(workDir, 'note.md');
|
||||||
|
await fileOps.atomicWriteFile(target, '# hello\n', 'utf-8');
|
||||||
|
// rename 在 Linux 上保留 source inode 的 mode,所以 final 文件的 mode
|
||||||
|
// 也应该是 0o600(除非 umask 让 0o666 & 0o600 之后又变 —— 不会,
|
||||||
|
// 因为 0o600 显式设的)。Windows 上跳过 mode 断言。
|
||||||
|
const st = statSync(target);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
expect(st.mode & 0o077).toBe(0);
|
||||||
|
}
|
||||||
|
// 写盘内容正确(与权限无关,但顺手验一下不破坏)
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
expect(readFileSync(target, 'utf-8')).toBe('# hello\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user