This commit is contained in:
2026-09-21 02:30:48 +08:00
parent a1089efd18
commit 7a3769670b
50 changed files with 3375 additions and 783 deletions

35
.gitignore vendored
View File

@@ -1,2 +1,37 @@
# 依赖
node_modules/ node_modules/
# 打包产物
dist/ dist/
# 调试 / 工具产物
_dbg-*.png
.playwright-mcp/
*.log
tmp-*.mjs
_test-*.mjs
scripts/launch-debug.js
# 会话临时截图(手工布局调试用,看一眼即丢;不进版本库)
# 命名约定:check-*.png / overview*.png / opt-*.png / test-*.png
check-*.png
overview*.png
opt-*.png
test-*.png
# 一次性手工 debug 页(CodeMirror 行为 / 视觉孤立测试)
# 之前 test-cursor.html 被误提交,又被误删;模式化命中避免复发。
test-*.html
# 截图 / 视觉回归
screenshots/
screenshot-*.png
# 手工视觉验证的临时产物(布局调试截图 + 独立复现页)
# 这些是「看一眼就丢」的调试产物,不是回归基线,不进版本库。
tests/manual/
# 一次性 repro / debug 脚本存档(与 tmp-*.mjs 同性质)。
# 历史一次性脚本,未被 package.json / lint / check 引用 —— 不进版本库
# 即可,但留在磁盘方便日后查同一类问题的修复路径。
scripts/_archive/

197
README.md
View File

@@ -1,149 +1,160 @@
# Notes # Notes
桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。 本地优先的 Markdown 阅读与编辑工具。界面看到的和磁盘上的 `.md` 文件一模一样 —— 复制走就是备份。
![tests](https://img.shields.io/badge/tests-642%2F28%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) ![tests](https://img.shields.io/badge/tests-947%2F34%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue)
## 简介 ## 它是什么
- **文件即真相**:界面显示什么,磁盘上就是什么。 Notes 把一个文件夹变成笔记本。`.md` 文件就是笔记,**文件即数据**,没有数据库也没有专有格式。内置 CodeMirror 6 编辑器,`Ctrl + S` 落盘,也可开启自动保存。
- **保存由你决定**:默认 `Ctrl + S` 手动保存;工具栏可一键开启自动保存。
- **完全本地**:所有文件就在你电脑上的指定文件夹里,复制走就是备份。
- **AI 可选**:内置修改助手,支持 OpenAI / Anthropic 兼容协议,不启用则永不联网。
## 快速开始 8 套配色(default / ocean / forest / sunset / rose / amber / slate / mono),暗 / 亮各一版,字号行距可调。不联网也能用 —— AI 是可选插件,**不配置就永不联网**。
---
## 5 分钟上手
需要 [Node.js 20.18 或更高](https://nodejs.org/)。
```bash ```bash
git clone <仓库地址> notes
cd notes
npm install npm install
npm start npm start
``` ```
要求 Node ≥ 20.18(见 `.nvmrc`)。首次启动会在用户主目录创建 `Notes` 文件夹,把 `.md` 文件放进去即可在左侧栏看到。 首次启动会在用户主目录创建 `Notes` 文件夹(Windows 是 `C:\Users\<你>\Notes`,macOS / Linux 是 `~/Notes`),并把欢迎页等 4 篇引导文档复制进去。把任意 `.md` 文件拖进那个文件夹,左侧栏立刻显示。
> **Windows + 中文路径**:Electron 在含中文路径下启动会报错。两种解决: 按 `Ctrl + ,` 打开设置。主题 / 置顶 / 视图模式 / 自动保存在工具栏有独立按钮,**设置里不会出现重复入口**。
> 1. 把项目移到纯 ASCII 路径,如 `D:\Projects\notes`
> 2. 用管理员 cmd 建符号链接:`mklink /D C:\dev\notes "C:\path\to\中文路径\Notes"`,在 `C:\dev\notes` 跑 `npm install && npm start`
## 命令 ---
```bash ## 日常使用
npm start # 启动(UTF-8 包装,推荐;中文 Windows 别用 start:raw)
npm test # 全部单测(642 用例 / 28 文件)
npm run test:watch # watch 模式
npm run check # 语法 + IPC 通道配对
npm run lint # ESLint
npm run build:win # Windows → dist/(NSIS 安装包)
npm run build:mac # macOS → dist/
npm run build:linux # Linux → dist/(AppImage)
```
## 功能
### 阅读与编辑 ### 阅读与编辑
- 侧栏文件列表,按文件名实时过滤(`Ctrl + F`) - `Ctrl + E` 在「预览 → 编辑 → 双栏」之间循环
- 三种视图:预览 / 编辑 / 双栏(`Ctrl + E` 循环切换) - `Ctrl + F` 侧栏搜索框按文件名实时过滤
- 暗 / 亮主题 × 5 套配色(默认 / 海洋 / 森林 / 薰衣草 / 夕阳),正交可选 - `Ctrl + S` 保存,`Ctrl + N` 新建笔记,`Ctrl + W` 关闭当前文件
- 工具栏一键窗口置顶 - `Ctrl + Shift + F` 聚焦模式,隐藏工具栏 / 侧栏 / 状态栏,纯净阅读
- 链接智能处理:外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转 - 状态栏实时显示当前阅读到的章节标题
- 链接分流:外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转
- 相对路径图片(`![](img/a.png)`)直接渲染 - 相对路径图片(`![](img/a.png)`)直接渲染
- 状态栏实时高亮当前阅读章节
- 聚焦模式隐藏工具栏 / 侧栏 / 状态栏(`Ctrl + Shift + F`)
- 系统托盘:关闭窗口保留托盘图标,单击恢复
- 右键菜单:阅读视图复制 / 全选;编辑器复制 / 剪切 / 粘贴 / 全选
### 数据安全
- 外部修改自动检测:其他编辑器改了文件会提示重新加载(IME 合成期间不打断)
- 退出保护:有未保存改动时关闭窗口 / 退出托盘都会先问「保存 / 丢弃 / 取消」
- 原子写盘(`tmp + rename`),断电不留半截文件
- 数据目录自动回退:当前目录不可用(U 盘拔了 / 网盘断连)时自动切回默认目录
- 监听对云同步盘(OneDrive / 坚果云等)也可靠
### AI 修改 ### AI 修改
- 工具栏唤起底部对话面板 `Ctrl + Shift + A` 打开底部 AI 面板。描述你想怎么改(润色 / 翻译 / 总结 / 续写),AI 给出行级 + 词级 diff,可逐段应用或一键应用全部,不满意随时取消。
- 双协议:OpenAI 兼容(`/chat/completions`)与 Anthropic 兼容(`/v1/messages`)
- 行级 + 词级 diff 高亮,可逐 region 应用或一键应用全部
- 提交后可随时取消,状态干净复位
## 设置 协议选择:
`Ctrl + ,` 打开设置对话框。 | 选项 | 适用服务 | Base URL 末尾 |
|---|---|---|
| OpenAI 兼容 | OpenAI / DeepSeek / Moonshot / Azure / 自部署 | 必须含 `/v1` |
| Anthropic 兼容 | Claude 系列 / 第三方中转 | 不含 `/v1` |
**数据** — 数据文件夹:留空用默认 `~/Notes`;切换时旧目录失效会自动回退 API Key 默认脱敏显示,点眼睛图标露出明文。**不配也不影响其它功能**。
**外观** — 配色 5 套 × 2 主题;阅读字号 14/15/17/19/22;阅读行距 1.5/1.7/1.85/2.0 ---
**行为** — 文件列表排序(名称 / 修改时间);自动保存(编辑停下 500 ms 后落盘) ## 数据安全
**AI** — 服务提供方 / Base URL / API Key(明文存本机 `settings.json`,UI 默认脱敏)/ 模型 / 系统提示(留空用内置中文 Markdown 助手 prompt) - **外部修改检测** —— 其他编辑器改了文件会提示重新加载(正在打字时不打断)
- **退出保护** —— 有未保存改动就关窗 / 退托盘,会先问「保存 / 丢弃 / 取消」
- **原子写盘** —— 断电也不会留下半截文件
- **数据目录自动回退** —— U 盘拔了 / 网盘断连时自动切回默认目录
- **云同步盘友好** —— OneDrive / 坚果云 / iCloud 都做过专门适配
- **关窗 ≠ 退出** —— 应用缩到系统托盘,单击恢复,右键看菜单
主题 / 置顶 / 视图模式在工具栏有独立按钮,**同一功能只保留一个入口**。 ---
## 快捷键 ## 数据文件夹
| 系统 | 默认路径 |
|---|---|
| Windows | `C:\Users\<你>\Notes` |
| macOS | `~/Notes` |
| Linux | `$HOME/Notes` |
可在设置里改成任意目录。状态栏左段(紧贴当前路径)有三个 icon-only 按钮:打开、切换、回到默认。
---
## 快捷键速查
| 快捷键 | 作用 | | 快捷键 | 作用 |
|---|---| |---|---|
| `Ctrl/Cmd + F` | 聚焦文件搜索 |
| `Ctrl/Cmd + N` | 新建笔记 | | `Ctrl/Cmd + N` | 新建笔记 |
| `Ctrl/Cmd + F` | 聚焦文件搜索(编辑器内改为 CM6 查找) | | `Ctrl/Cmd + S` | 保存(IME 合成中不触发写盘) |
| `Ctrl/Cmd + S` | 保存(IME 合成中拒绝物理写盘) | | `Ctrl/Cmd + W` | 关闭当前文件 |
| `Ctrl/Cmd + E` | 切换视图模式(预览 → 编辑 → 双栏) | | `Ctrl/Cmd + E` | 切换视图模式 |
| `Ctrl/Cmd + ,` | 打开设置 | | `Ctrl/Cmd + ,` | 打开设置 |
| `Ctrl/Cmd + R` | 重新加载界面(脏状态会先确认) | | `Ctrl/Cmd + R` | 重新加载界面 |
| `Ctrl/Cmd + Shift + T` | 切换主题 | | `Ctrl/Cmd + Shift + T` | 切换主题 |
| `Ctrl/Cmd + Shift + F` | 切换聚焦模式 | | `Ctrl/Cmd + Shift + F` | 切换聚焦模式 |
| `Ctrl/Cmd + Shift + A` | 切换 AI 面板 | | `Ctrl/Cmd + Shift + A` | 切换 AI 面板 |
| `Esc`(搜索框内) | 清空搜索 | | `Esc`(搜索框内) | 清空搜索 |
| `F12` | 打开 DevTools | | `F12` | 打开 DevTools |
窗口为无边框(自绘标题栏),Windows / Linux 上看不到菜单栏,但上表快捷键全部直接可用。 窗口是无边框自绘标题栏,**Windows / Linux 上看不到菜单栏**,但上表所有快捷键全部生效。
## 数据文件夹 ---
| 平台 | 默认路径 | ## 常见问题
|---|---|
| Windows | `C:\Users\<你>\Notes` |
| macOS | `~/Notes` |
| Linux | `$HOME/Notes` |
可在设置里改成任意目录。**所有文件就在这里** —— 复制走就是备份。 **搜索文件没反应?**
检查设置里的「数据文件夹」是不是对的。某些云盘的实时监听不可靠,按 `Ctrl + R` 手动刷新。
## 项目结构 **AI 提示「无法连接到服务」?**
核对 Base URL:OpenAI 兼容需含 `/v1`,Anthropic 不含。点眼睛图标露出 API Key 明文,确认没多余空格。按 `F12` 看完整错误码。
**中文 Windows 启动报错?**
Electron 在中文路径下启动会报错。两种办法:
1. 把项目挪到纯英文路径(推荐),如 `D:\Projects\notes`
2. 用管理员 cmd 建符号链接:
```cmd
mklink /D C:\dev\notes "C:\当前\中文路径\Notes"
```
然后在 `C:\dev\notes` 跑 `npm install && npm start`
**控制台中文乱码?**
用 `npm start`,**不要用 `npm run start:raw`**。前者会自动把控制台切到 UTF-8。
**笔记文件会被改动吗?**
不会。读什么就显示什么,保存时原样写回 —— **不会偷偷重排 Markdown 格式**。
---
## 给开发者
```bash
npm start # 启动(推荐,自动处理中文 Windows 编码)
npm test # 全部单测(947 用例 / 34 文件)
npm run test:watch # watch 模式
npm run check # 语法 + IPC 通道配对
npm run lint # ESLint
npm run build:win # 打包 Windows 安装包 → dist/
npm run build:mac # 打包 macOS → dist/
npm run build:linux # 打包 Linux AppImage → dist/
```
``` ```
Notes/ Notes/
├── main.js # Electron 主进程 ├── main.js # Electron 主进程入口
├── main/ # 主进程模块(ai、file-ops、fs-watcher、config-store) ├── preload.js # IPC 桥(contextBridge)
├── preload.js # IPC bridge
├── index.html # UI 框架 + CSP + importmap ├── index.html # UI 框架 + CSP + importmap
├── src/ # 渲染端(ai/ 面板、styles/ CSS 分片) ├── main/ # 主进程模块:ai / config-store / file-ops / fs-watcher
├── shared/ # 主进程 / preload / 渲染端共享 ├── src/ # 渲染端:UI 组件 + CSS 分片 + AI 面板
├── data/welcome.md # 首次启动种子文件 ├── shared/ # 主 / preload / 渲染端共享:schema / diff / 错误码
├── scripts/ # 启动包装(launch.js)+ 静态检查(check-syntax / check-ipc) ├── data/ # 首次启动种子化的引导文档(4 篇)
├── scripts/ # 启动包装 + 静态检查
└── tests/ └── tests/
├── unit/ # 28 个测试文件,642 用例(vitest) ├── unit/ # vitest 单测(34 个文件,947 用例)
└── manual/ # 手工视觉验证产物(不进版本库) └── manual/ # 手工视觉验证产物(不进版本库)
``` ```
## 故障排除 ---
**文件列表不更新**
- 检查设置里的数据目录
- 某些云同步盘的 `fs.watch` 不可靠,已加轮询 + watchdog 自愈
- `Ctrl + R` 手动重新加载(脏状态会先确认)
**控制台中文乱码** — 用 `npm start`,别用 `start:raw`
**AI 提示"无法连接到服务"**
- 检查 Base URL:OpenAI 兼容需含 `/v1`,Anthropic 不含
- 核对 API Key(点"显示"露出明文)
- `F12` → console 看完整错误码(参考 `shared/ai-errors.js`)
**启动报错**
- 路径含中文(仅 Windows)→ 见上文"快速开始"
- Node 版本低于 20.18:`nvm use`(仓库根目录有 `.nvmrc`)
- `F12` → console 看渲染进程堆栈
## License ## License

18
data/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Notes
一个简洁的 **Markdown 阅读器 / 编辑器**。
把 `.md` 文件放进数据文件夹,左侧栏就会自动出现 —— 文件怎么写,界面就怎么显示。
## 常用快捷键
| 快捷键 | 功能 |
|--------|------|
| `Ctrl + F` | 聚焦文件搜索 |
| `Ctrl + N` | 新建笔记 |
| `Ctrl + S` | 保存当前编辑 |
| `Ctrl + E` | 切换视图模式(预览 / 编辑 / 双栏)|
| `Ctrl + ,` | 打开设置 |
| `Ctrl + Shift + T` | 切换主题 |
开始写笔记吧。

View File

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

View File

@@ -9,17 +9,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<!-- 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:
- default-src 'self' 只允许加载同源资源
- style-src 'self' 'unsafe-inline' 允许 inline style(marked 渲染的代码块可能会用到)
- script-src 'self' 'unsafe-inline' 允许同源 + 内联脚本(用于 importmap)
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64)
关于 'unsafe-inline':本应用唯一的内联脚本是 importmap(用于映射 CodeMirror 包)。
渲染端的实际代码全部走 .js 文件加载。Markdown 内容经 DOMPurify 清洗,<script> 会被剥离。
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
-->
<!-- <!--
Content Security Policy (加固版): Content Security Policy (加固版):
- default-src 'self' 只允许加载同源资源 - default-src 'self' 只允许加载同源资源
@@ -35,13 +24,29 @@
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。 在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
未来若改用 nonce 或 hash,可去除 'unsafe-inline'。 未来若改用 nonce 或 hash,可去除 'unsafe-inline'。
--> -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: file:; object-src 'none'; base-uri 'none'; form-action 'none';"> <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';">
<title>Notes</title> <title>Notes</title>
<!-- <!--
Import map: 把 CodeMirror 6 及其传递依赖映射到本地 node_modules。 Import map: 把 CodeMirror 6 及其传递依赖映射到本地 node_modules。
浏览器原生 ESM 不解析 package.json 的 exports 字段,所以必须写完整相对路径。 浏览器原生 ESM 不解析 package.json 的 exports 字段,所以必须写完整相对路径。
style-mod 的 ESM 在 src/ 而不是 dist/(dist/ 只有 CJS)—— 这是常见坑。 style-mod 的 ESM 在 src/ 而不是 dist/(dist/ 只有 CJS)—— 这是常见坑。
保留的条目是 src/ 直接 import + lang-markdown 间接需要的传递依赖 + basicSetup
顶层 import 拉进来的 search/autocomplete/lint:
- 直接 import:view / state / theme-one-dark / lang-markdown
- lang-markdown 间接(用于 markdown 内嵌 HTML/CSS/JS 代码块高亮):
lang-html / lang-css / lang-javascript / lezer/{html,css,javascript}
- basicSetup 顶层 import(codemirror/dist/index.js:1-8 用到):
search / autocomplete / lint
- 其他:commands / language / lezer/{common,highlight,markdown,lr}
/ marijn/find-cluster-break / style-mod / w3c-keyname / crelt
审计 T-7 曾把 search/autocomplete/lint + lang-{html,css,javascript} +
lezer-{html,css,javascript} 都当作"无引用"删掉 —— 但这些全是 side-effect
链:basicSetup 顶层 import 拉 search/autocomplete/lint;lang-markdown
顶层 import 拉 lang-html → 继续拉 lang-css / lang-javascript → 继续拉
lezer-{html,css,javascript}。markdown-editor.js 用了 basicSetup + lang-markdown,
这 9 条 importmap 条目都不能少,缺一个 renderer 立即
"Failed to resolve module specifier '@...'"。
--> -->
<script type="importmap"> <script type="importmap">
{ {
@@ -52,8 +57,8 @@
"@codemirror/commands": "./node_modules/@codemirror/commands/dist/index.js", "@codemirror/commands": "./node_modules/@codemirror/commands/dist/index.js",
"@codemirror/language": "./node_modules/@codemirror/language/dist/index.js", "@codemirror/language": "./node_modules/@codemirror/language/dist/index.js",
"@codemirror/lang-markdown": "./node_modules/@codemirror/lang-markdown/dist/index.js", "@codemirror/lang-markdown": "./node_modules/@codemirror/lang-markdown/dist/index.js",
"@codemirror/lang-css": "./node_modules/@codemirror/lang-css/dist/index.js",
"@codemirror/lang-html": "./node_modules/@codemirror/lang-html/dist/index.js", "@codemirror/lang-html": "./node_modules/@codemirror/lang-html/dist/index.js",
"@codemirror/lang-css": "./node_modules/@codemirror/lang-css/dist/index.js",
"@codemirror/lang-javascript": "./node_modules/@codemirror/lang-javascript/dist/index.js", "@codemirror/lang-javascript": "./node_modules/@codemirror/lang-javascript/dist/index.js",
"@codemirror/search": "./node_modules/@codemirror/search/dist/index.js", "@codemirror/search": "./node_modules/@codemirror/search/dist/index.js",
"@codemirror/autocomplete": "./node_modules/@codemirror/autocomplete/dist/index.js", "@codemirror/autocomplete": "./node_modules/@codemirror/autocomplete/dist/index.js",
@@ -62,8 +67,8 @@
"@lezer/common": "./node_modules/@lezer/common/dist/index.js", "@lezer/common": "./node_modules/@lezer/common/dist/index.js",
"@lezer/highlight": "./node_modules/@lezer/highlight/dist/index.js", "@lezer/highlight": "./node_modules/@lezer/highlight/dist/index.js",
"@lezer/markdown": "./node_modules/@lezer/markdown/dist/index.js", "@lezer/markdown": "./node_modules/@lezer/markdown/dist/index.js",
"@lezer/css": "./node_modules/@lezer/css/dist/index.js",
"@lezer/html": "./node_modules/@lezer/html/dist/index.js", "@lezer/html": "./node_modules/@lezer/html/dist/index.js",
"@lezer/css": "./node_modules/@lezer/css/dist/index.js",
"@lezer/javascript": "./node_modules/@lezer/javascript/dist/index.js", "@lezer/javascript": "./node_modules/@lezer/javascript/dist/index.js",
"@lezer/lr": "./node_modules/@lezer/lr/dist/index.js", "@lezer/lr": "./node_modules/@lezer/lr/dist/index.js",
"@marijn/find-cluster-break": "./node_modules/@marijn/find-cluster-break/src/index.js", "@marijn/find-cluster-break": "./node_modules/@marijn/find-cluster-break/src/index.js",
@@ -116,6 +121,28 @@
<span id="app-title" class="app-title"><span id="app-title-text" class="app-title-text">Notes</span></span> <span id="app-title" class="app-title"><span id="app-title-text" class="app-title-text">Notes</span></span>
</div> </div>
<div class="toolbar-right no-drag"> <div class="toolbar-right no-drag">
<!--
搜索文件入口(设计优化 2026-09):
- 从侧栏上移到底部顶栏首位,腾出侧栏垂直空间给文件列表
- 紧贴侧栏上方,视觉上明确"作用于侧栏"
- flex:0 1 240px + min-width:160px:窗口过窄时让搜索框先收缩,
不挤掉右侧窗口控制按钮
-->
<div class="toolbar-search">
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 应用组:主题 + 置顶 --> <!-- 应用组:主题 + 置顶 -->
<div class="toolbar-group"> <div class="toolbar-group">
<div class="btn-group" role="group" aria-label="外观"> <div class="btn-group" role="group" aria-label="外观">
@@ -179,8 +206,8 @@
<div class="toolbar-group"> <div class="toolbar-group">
<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">
<path d="M3 12a9 9 0 1 0 3-6.7"></path> <rect x="3" y="3" width="18" height="18" rx="2"></rect>
<polyline points="3 4 3 9 8 9"></polyline> <line x1="12" y1="3" x2="12" y2="21"></line>
</svg> </svg>
</button> </button>
</div> </div>
@@ -212,7 +239,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">
<!-- <!--
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态(warning 色 + 脉动)。 保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态(warning 色 + 脉动)。
@@ -225,6 +252,21 @@
<polyline points="7 3 7 8 15 8"></polyline> <polyline points="7 3 7 8 15 8"></polyline>
</svg> </svg>
</button> </button>
<!--
关闭当前文件:仅清空视图,不删磁盘文件。disabled 态走 .btn-icon 现有规则
(fg-tertiary + opacity 0.4 + cursor not-allowed);无文件时由 syncCloseFileButton
切到 disabled,HTML 默认 disabled 保证首帧 FOUC 也对。
与右上角窗口 ×(id=btn-close)区分:那个关的是 BrowserWindow,这个关的是「当前打开的文件」。
-->
<button class="btn-icon" id="btn-close-file" type="button" title="关闭当前文件(无打开文件)" aria-label="关闭当前文件(无打开文件)" aria-disabled="true" disabled>
<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">
<!-- Lucide file-x 风格:文档 + 右上角 × -->
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="9.5" y1="13" x2="14.5" y2="18"></line>
<line x1="14.5" y1="13" x2="9.5" y2="18"></line>
</svg>
</button>
<button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置"> <button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <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>
@@ -263,33 +305,12 @@
--> -->
<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="文件与目录">
<div class="sidebar-header"> <!-- 搜索框已上移到工具栏(见 .toolbar-search),侧栏不再保留 header。
<div class="search-wrap"> 排序工具条 + 新建笔记合并行作为首屏唯一入口,垂直空间专注于文件列表 -->
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <!-- 排序工具条 + 新建笔记:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]])。
<circle cx="11" cy="11" r="7"></circle> 新建笔记从独立行合并到排序条右端:节省垂直空间,让侧栏首屏只剩「工具条」一行,
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> 主操作 + 列表工具就近成组,视觉密度更紧凑 -->
</svg> <div class="sidebar-sort" role="group" aria-label="文件列表排序与新建">
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
>
</div>
</div>
<!-- 新建笔记:放在搜索框下方的独立主操作行,不再和搜索框挤在同一排抢首屏注意力(见 [[feedback-prefer-near-affected-area]]) -->
<div class="sidebar-new-row">
<button id="btn-new-file" class="sidebar-new-btn" type="button" title="新建笔记 (Ctrl+N)" aria-label="新建笔记">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>新建笔记</span>
</button>
</div>
<!-- 排序工具条:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]]) -->
<div class="sidebar-sort" role="group" aria-label="文件列表排序">
<button id="btn-sort-name" type="button" class="sidebar-sort-btn is-active" aria-pressed="true" title="按名称 A → Z 排序"> <button id="btn-sort-name" type="button" class="sidebar-sort-btn is-active" aria-pressed="true" title="按名称 A → Z 排序">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h13M3 12h9M3 18h5"></path> <path d="M3 6h13M3 12h9M3 18h5"></path>
@@ -304,6 +325,13 @@
</svg> </svg>
<span>修改时间</span> <span>修改时间</span>
</button> </button>
<button id="btn-new-file" class="sidebar-new-btn sidebar-new-btn--inline" type="button" title="新建笔记 (Ctrl+N)" aria-label="新建笔记">
<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">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>新建笔记</span>
</button>
</div> </div>
<!-- Folder Browser(Stage 8):面包屑 + 「回到根」按钮,贴近它作用的文件列表。 <!-- Folder Browser(Stage 8):面包屑 + 「回到根」按钮,贴近它作用的文件列表。
@@ -324,40 +352,10 @@
<!-- 文件项由 file-list.js 注入 --> <!-- 文件项由 file-list.js 注入 -->
</ul> </ul>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button type="button" id="link-open-folder" class="link-subtle footer-action" title="在系统文件管理器中打开数据文件夹"> <!-- AI 入口:侧栏唯一保留的"次级主操作"按钮。
<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"> 文件夹管理(打开/切换/回到默认)已搬到状态栏左段(见 [[feedback-folder-actions-to-statusbar]]),
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path> 与 status-path 组成「当前位置 + 操作」语义组;状态栏只有 38px 高,
</svg> 文字版挤在侧栏底部会浪费列表空间,现在侧栏更"纯净"。 -->
<span>打开数据文件夹</span>
</button>
<button type="button" id="btn-switch-folder" class="link-subtle footer-action" title="切换到其他数据文件夹">
<!-- folder + 双向 swap 箭头 (Lucide folder-sync 风格) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1-2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<polyline points="16 3 19 3 19 6"></polyline>
<polyline points="8 21 5 21 5 18"></polyline>
<line x1="19" y1="3" x2="13" y2="9"></line>
<line x1="5" y1="21" x2="11" y2="15"></line>
</svg>
<span>切换数据文件夹</span>
</button>
<!-- auto-fallback 2026-08:「回到默认」按钮 —— 清空持久化的 dataDir 并切回 ~/Notes。
始终显示:用户切到非默认目录后想回到默认不需要先「切换」再选;救命的入口
不能藏在条件分支里。当已经在默认文件夹时按钮可点(toast 提示「已在默认」),
复用现有 .link-subtle .footer-action 样式,不新增 CSS。 -->
<button type="button" id="btn-reset-folder" class="link-subtle footer-action" title="回到默认数据文件夹(~/Notes)">
<!-- home + 屋顶(表达「回到原始默认」) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12l9-9 9 9"></path>
<path d="M5 10v10h14V10"></path>
</svg>
<span>回到默认</span>
</button>
<!-- 视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开 -->
<div class="sidebar-footer-sep" aria-hidden="true"></div>
<!-- AI 入口:放在侧栏最底,与顶部"主操作(新建笔记)→ 列表 → 排序 → 二级链接"形成层次。
默认沿用 .sidebar-new-btn 的描边主操作样式;展开后切到实心强调态(见 src/styles/11-ai.css 的 [aria-pressed="true"])。
AI 是「点开就弹,关闭就退」的命令式入口,与 settingsStore 无交集。 -->
<button id="btn-ai" class="sidebar-new-btn" type="button" title="AI 修改 (Ctrl+Shift+A)" aria-label="AI 修改" aria-pressed="false"> <button id="btn-ai" class="sidebar-new-btn" type="button" title="AI 修改 (Ctrl+Shift+A)" aria-label="AI 修改" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2a4 4 0 0 0-4 4v1H6a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3h-2V6a4 4 0 0 0-4-4z"></path> <path d="M12 2a4 4 0 0 0-4 4v1H6a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3h-2V6a4 4 0 0 0-4-4z"></path>
@@ -443,7 +441,10 @@
把 <code>.md</code> 文件放入数据文件夹后,<br> 把 <code>.md</code> 文件放入数据文件夹后,<br>
会自动出现在左侧列表。 会自动出现在左侧列表。
</p> </p>
<p class="hint hint-secondary">左下角可切换数据文件夹,或在系统文件管理器中打开。</p> <p class="hint hint-secondary">
<kbd>Ctrl</kbd>+<kbd>N</kbd> 新建笔记 · <kbd>Ctrl</kbd>+<kbd>F</kbd> 搜索<br>
底部状态栏可切换数据文件夹
</p>
</div> </div>
</main> </main>
</div> </div>
@@ -475,11 +476,40 @@
</div> </div>
<!-- 状态栏:3 段式(路径弱化 / 字数核心 / 模式 + 进度 + 文件数) <!-- 状态栏:3 段式(路径弱化 / 字数核心 / 模式 + 进度 + 文件数)
segment 之间用 .status-spacer 推开,让眼睛先扫到核心信息 --> segment 之间用 .status-spacer 推开,让眼睛先扫到核心信息。
状态栏左段同时承担「数据文件夹管理」入口(打开/切换/回到默认),
见 [[feedback-folder-actions-to-statusbar]] —— icon-only 紧凑按钮,
紧贴 status-path,与路径组成「当前位置 + 操作」语义组。 -->
<footer class="statusbar"> <footer class="statusbar">
<div class="status-segment status-segment-left"> <div class="status-segment status-segment-left">
<button id="status-path" class="status-path status-path-clickable" type="button" title="在文件管理器中显示" aria-label="在文件管理器中显示当前文件">—</button> <button id="status-path" class="status-path status-path-clickable" type="button" title="在文件管理器中显示" aria-label="在文件管理器中显示当前文件">—</button>
<span class="status-divider"></span> <span class="status-divider"></span>
<button type="button" id="link-open-folder" class="status-folder-btn" title="在系统文件管理器中打开数据文件夹" aria-label="在系统文件管理器中打开数据文件夹">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
</button>
<button type="button" id="btn-switch-folder" class="status-folder-btn" title="切换到其他数据文件夹" aria-label="切换到其他数据文件夹">
<!-- folder + 双向 swap 箭头 (Lucide folder-sync 风格) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1-2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<polyline points="16 3 19 3 19 6"></polyline>
<polyline points="8 21 5 21 5 18"></polyline>
<line x1="19" y1="3" x2="13" y2="9"></line>
<line x1="5" y1="21" x2="11" y2="15"></line>
</svg>
</button>
<!-- auto-fallback 2026-08:「回到默认」按钮 —— 清空持久化的 dataDir 并切回 ~/Notes。
始终显示:用户切到非默认目录后想回到默认不需要先「切换」再选;救命的入口
不能藏在条件分支里。当已经在默认文件夹时按钮可点(toast 提示「已在默认」)。 -->
<button type="button" id="btn-reset-folder" class="status-folder-btn" title="回到默认数据文件夹(~/Notes)" aria-label="回到默认数据文件夹(~/Notes)">
<!-- home + 屋顶(表达「回到原始默认」) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12l9-9 9 9"></path>
<path d="M5 10v10h14V10"></path>
</svg>
</button>
<span class="status-divider"></span>
<span id="status-meta" class="status-meta"></span> <span id="status-meta" class="status-meta"></span>
</div> </div>
<div class="status-spacer"></div> <div class="status-spacer"></div>

332
main.js
View File

@@ -343,6 +343,58 @@ let rendererDirty = false; // 渲染端是否有未保存改动
// quitConfirmInFlight 模式。 // quitConfirmInFlight 模式。
let closeConfirmInFlight = false; let closeConfirmInFlight = false;
// audit fix (Round 17 H2):fs 操作 30s 超时包裹。SMB / 坚果云 / OneDrive 在
// 网络抖动 / 杀毒持锁 / 用户挂起电脑后重新唤醒时,fs.read / fs.write / fs.stat
// / fs.rename 可能挂死几分钟甚至永久。renderer 这边 `await window.api.xxx()`
// 永远 resolve 不出来,UI 无 loading、无错误提示,用户只能强杀进程。
//
// 设计要点:
// - 用 Promise.race 而非 AbortController —— Node fs.* 没有内置 abort 语义,
// 注入 abort signal 在不同版本兼容差;race 超时后底层 promise 仍会跑完,
// 但其结果已被丢弃,不会影响调用方。
// - 超时错误带 code='FS_TIMEOUT',handler catch 块透传到 renderer;
// renderer 看到 FS_TIMEOUT 走「磁盘暂时不可用,请稍后重试」友好文案。
// - 30s 是经验值:原子写 + fsync + rename 在 SMB 上实测 1-3s,30s 给 10x
// 余量;超过这个值基本就是磁盘真的没了,再等也没意义。
// - timeout Timer 必须 .unref(),否则 fs 操作 hang 时主进程不会自然退出。
const FS_OP_TIMEOUT_MS = 30000;
function withTimeout(promise, ms = FS_OP_TIMEOUT_MS, label = 'fs') {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
const err = new Error(`${label} 操作超过 ${ms}ms 未完成`);
err.code = 'FS_TIMEOUT';
reject(err);
}, ms);
if (typeof timer.unref === 'function') timer.unref();
});
return Promise.race([promise, timeout])
.finally(() => { clearTimeout(timer); });
}
/**
* 安全向渲染端发送 IPC。
*
* mainWindow 已被销毁时(isDestroyed() true)会直接返回;
* 但还有一种时序:BrowserWindow 对象还活着、webContents 已被拆毁
* (例如关窗时 did-finish-load / resize 事件晚到一步),
* 此时 webContents.send 会同步抛 throw,没有调用方能接住 → unhandledException。
* main\fs-watcher.js:145 safeSend 用了同款防御;这里共用一份语义。
*
* 参数名故意避开 channel:check-ipc.js 的静态扫描按变量名匹配 webContents.send
* 与 const 字面量,channel 已被 promptRendererSave 的局部 const 占走同名,全局共用
* 会被错配。改用 eventName 避开冲突。
*/
function safeSendToRenderer(eventName, payload) {
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
mainWindow.webContents.send(eventName, payload);
} catch (e) {
console.warn(`[main] send ${eventName} 失败:`, e && e.message);
}
}
// 渲染进程崩溃计数器(防止 reload 死循环) // 渲染进程崩溃计数器(防止 reload 死循环)
let renderGoneCount = 0; let renderGoneCount = 0;
const MAX_RENDER_GONE_RELOADS = 3; const MAX_RENDER_GONE_RELOADS = 3;
@@ -716,7 +768,10 @@ function buildTrayMenu() {
configStore.saveConfig({ alwaysOnTop: item.checked }).catch((e) => { configStore.saveConfig({ alwaysOnTop: item.checked }).catch((e) => {
console.error('[main] tray alwaysOnTop 保存失败:', e?.error || e?.message || e); console.error('[main] tray alwaysOnTop 保存失败:', e?.error || e?.message || e);
}); });
mainWindow?.webContents.send('always-on-top:changed', item.checked); // audit fix (H3):走 safeSendToRenderer —— mainWindow 已被 destroyed
// 但引用还在的极小窗口里 .send() 同步抛 TypeError,托盘 click 处理器
// 没 try/catch → unhandledException 主进程侧崩溃。
safeSendToRenderer('always-on-top:changed', item.checked);
} }
}, },
{ type: 'separator' }, { type: 'separator' },
@@ -781,7 +836,7 @@ app.whenReady().then(async () => {
configStore.init(); configStore.init();
// auto-fallback 2026-08 + auto-create 默认目录:currentDataRoot() 内部走 // auto-fallback 2026-08 + auto-create 默认目录:currentDataRoot() 内部走
// resolveDataDirOrFallback,customDir 失效时回退到默认;返回 DEFAULT_DATA_DIR // resolveDataDirOrFallback,customDir 失效时回退到默认;返回 DEFAULT_DATA_DIR
// 时还会 sync mkdir(确保 fsWatcher 启动时不撞 ENOENT)+ 异步种子 welcome.md // 时还会 sync mkdir(确保 fsWatcher 启动时不撞 ENOENT)+ 异步种子 README.md
// (fire-and-forget,下次 fsWatcher 第一次扫描大概率就绪)。 // (fire-and-forget,下次 fsWatcher 第一次扫描大概率就绪)。
// 之前用 resolveDataDir() 不做 stat、不 mkdir —— 用户首次启动默认目录还没建、 // 之前用 resolveDataDir() 不做 stat、不 mkdir —— 用户首次启动默认目录还没建、
// 或 U 盘掉线 fallback 路径下默认目录也从未被用过,都会让 fsWatcher.startWatchingDir // 或 U 盘掉线 fallback 路径下默认目录也从未被用过,都会让 fsWatcher.startWatchingDir
@@ -792,18 +847,26 @@ app.whenReady().then(async () => {
console.log('[main] 已加载配置'); console.log('[main] 已加载配置');
console.log('[main] 数据目录:', currentDataDir); console.log('[main] 数据目录:', currentDataDir);
// 等欢迎文档就绪再启动 fsWatcher —— 默认目录刚创建的情况下,先把 welcome.md // 等欢迎文档就绪再启动 fsWatcher —— 默认目录刚创建的情况下,先把 README.md
// 写好再让 fsWatcher 开始监听,避免「fsWatcher 第一次扫描拿到空目录、几秒 // 写好再让 fsWatcher 开始监听,避免「fsWatcher 第一次扫描拿到空目录、几秒
// 后 welcome.md 突然冒出侧栏」的 UX 撕裂。resolveDataDirOrFallback 内部已 // 后 README.md 突然冒出侧栏」的 UX 撕裂。resolveDataDirOrFallback 内部已
// fire-and-forget 异步种子一次,这里 await 的是同一份 in-flight promise。 // fire-and-forget 异步种子一次,这里 await 的是同一份 in-flight promise。
await configStore.scheduleEnsureDefaultDataDir(); await configStore.scheduleEnsureDefaultDataDir();
// audit fix (Round 16 R2):清理数据目录里残留的 `*.tmp.<pid>.<ts>.<rand>`
// 孤儿文件。atomicWriteFile 失败路径会 unlink,但「进程被 kill -9 / 断电」
// 不会走那条路。configStore 自己的 `config.json.tmp.*` 在 configStore.init()
// 内部已经处理;这里只覆盖 fileOps 这一组。fire-and-forget,不阻塞启动。
fileOps.cleanupStaleFileTmpFiles(currentDataDir).catch((e) => {
console.warn('[main] 清理数据目录残留 tmp 失败:', e && e.message);
});
console.log('[main] app ready'); console.log('[main] app ready');
buildMenu(); buildMenu();
createTray(); createTray();
createWindow(); createWindow();
// 启动目录监听器(默认目录已 sync mkdir + welcome.md 已 async 种子完毕) // 启动目录监听器(默认目录已 sync mkdir + README.md 已 async 种子完毕)
fsWatcher.startWatchingDir(currentDataDir); fsWatcher.startWatchingDir(currentDataDir);
app.on('activate', () => { app.on('activate', () => {
@@ -877,7 +940,22 @@ app.on('window-all-closed', () => {
// IPC: 渲染端脏状态同步 + 触发保存 // IPC: 渲染端脏状态同步 + 触发保存
// ============================================ // ============================================
ipcMain.handle('renderer:set-dirty', (_event, isDirty) => { ipcMain.handle('renderer:set-dirty', (event, isDirty) => {
// audit fix (L5):sender 校验。rendererDirty 是退出路径上 confirmDiscardIfDirty
// 决定「是否弹未保存确认框」的唯一开关。若 XSS 拿到 window.api.setDirty
// (preload.js 暴露的 contextBridge API,渲染端任意 JS 都能调)就能把
// rendererDirty 强写为 false → 用户关窗时直接跳过保存确认 → 未保存内容静默
// 丢失。校验 event.sender 是 mainWindow.webContents、senderFrame 是顶级
// frame —— 拒掉所有来自子 frame / devtools / 其它 BrowserWindow 的调用。
if (!event?.sender || !mainWindow || event.sender !== mainWindow.webContents) {
console.warn('[main] renderer:set-dirty 拒绝:sender 不是主窗口 webContents');
return false;
}
const senderFrame = event.senderFrame;
if (senderFrame && senderFrame.parent) {
console.warn('[main] renderer:set-dirty 拒绝:来自子 frame');
return false;
}
// 仅同步标志位,供 confirmDiscardIfDirty 在退出路径上判断「是否弹未保存确认框」。 // 仅同步标志位,供 confirmDiscardIfDirty 在退出路径上判断「是否弹未保存确认框」。
// 不在此处刷新托盘菜单 —— dirty 状态与托盘菜单项(始终置顶、显示主窗口)无关。 // 不在此处刷新托盘菜单 —— dirty 状态与托盘菜单项(始终置顶、显示主窗口)无关。
rendererDirty = !!isDirty; rendererDirty = !!isDirty;
@@ -909,7 +987,13 @@ function promptRendererSave() {
const settle = (value) => { const settle = (value) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(hardTimer); // audit fix (M2):hardTimer 已被删(见下方),这里不再 clearTimeout(hardTimer)。
// 旧 hardTimer(10s) 的 callback 是空,只给 settle 一个 handle 来 clear —— 但
// settle 在 0-10s 之间必然先 fire(renderer 几乎总是 1-3s 内回结果),到 10s 时
// hardTimer 已 clearTimeout 掉,never fires;10s 后仍未 settle 的极小窗口里,
// hardTimer fires 是个 no-op(listener 自身由 graceTimer 的 settle(false) 移除,
// 或被任意 settle(ok) 移除)。删 hardTimer 后语义不变,注释中「进入宽限窗口」
// 的解释改为「10s 这一刻只是渲染承诺过期,监听器照常保留到 graceTimer」。
clearTimeout(graceTimer); clearTimeout(graceTimer);
try { ipcMain.removeAllListeners(channel); } catch { /* ignore */ } try { ipcMain.removeAllListeners(channel); } catch { /* ignore */ }
// Phase N M1 fix:早退路径(IPC 收到结果 / 窗口 destroyed / send 失败)清掉 // Phase N M1 fix:早退路径(IPC 收到结果 / 窗口 destroyed / send 失败)清掉
@@ -933,7 +1017,12 @@ function promptRendererSave() {
// 保存的诡异状态。 // 保存的诡异状态。
const HARD_DEADLINE_MS = 10000; const HARD_DEADLINE_MS = 10000;
const GRACE_PERIOD_MS = 15000; const GRACE_PERIOD_MS = 15000;
let hardTimer = setTimeout(() => { /* 进入宽限窗口:监听器保留 */ }, HARD_DEADLINE_MS); // audit fix (M2):硬期限改成「软」语义 —— 没有任何 settle,10s 后 renderer 没
// 回结果只代表渲染侧可能慢(大文件 / 慢盘),监听器不摘、graceTimer 继续守。
// 旧版 hardTimer 是空 callback + 只为让 settle 能 clearTimeout 的 dead timer;
// 删掉后行为完全一致:0-15s 之间晚到的 renderer 结果照样被 settle(ok) 接受,
// 15s graceTimer 仍未回 → settle(false)。每次 quit attempt 少一个 timer。
void HARD_DEADLINE_MS; // 仅作注释常量保留(其它模块若想对齐可参考)
let graceTimer = setTimeout(() => settle(false), GRACE_PERIOD_MS); let graceTimer = setTimeout(() => settle(false), GRACE_PERIOD_MS);
ipcMain.once(channel, (_event, result) => { ipcMain.once(channel, (_event, result) => {
@@ -985,7 +1074,14 @@ async function _confirmDiscardIfDirtyBounded(depth) {
async function confirmDiscardIfDirty(depth = MAX_CONFIRM_RECURSION) { async function confirmDiscardIfDirty(depth = MAX_CONFIRM_RECURSION) {
if (!rendererDirty) return true; if (!rendererDirty) return true;
if (!mainWindow || mainWindow.isDestroyed()) return true; // 旧实现:mainWindow 缺失/被销毁时直接 return true("允许丢弃")。
// 这会让 rendererDirty=true 但窗口在弹框前就被拆掉的极端时序下
// 默默把未保存改动丢进黑洞 —— close/quit 路径在 call site `if (!proceed) return;`
// 直接吞掉,等价于「不确认就退出」。
// 修复:拿不到窗口就拒绝(return false),call site 收到 false 后中止
// close/quit;用户重启 / 重新打开窗口时仍能拿到 promptRendererSave 流程
// 继续问「保存 / 丢弃 / 取消」。
if (!mainWindow || mainWindow.isDestroyed()) return false;
// 窗口可能已经隐藏到托盘了,先显示出来,否则原生框会挂在看不见的窗口上 // 窗口可能已经隐藏到托盘了,先显示出来,否则原生框会挂在看不见的窗口上
if (!mainWindow.isVisible() || mainWindow.isMinimized()) { if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
@@ -1078,7 +1174,7 @@ function currentDataRoot() {
function broadcastDataDirResolved() { function broadcastDataDirResolved() {
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
const info = configStore.resolveDataDirOrFallback(); const info = configStore.resolveDataDirOrFallback();
mainWindow.webContents.send('data-dir:resolved', { safeSendToRenderer('data-dir:resolved', {
dir: info.dir, dir: info.dir,
defaultDir: configStore.getDefaultDataDir(), defaultDir: configStore.getDefaultDataDir(),
fellBack: info.fellBack, fellBack: info.fellBack,
@@ -1209,7 +1305,7 @@ ipcMain.handle('file:watch-dir', async (_event, relDir) => {
} }
}); });
ipcMain.handle('file:read', async (event, filePath) => { ipcMain.handle('file:read', async (_event, filePath) => {
if (typeof filePath !== 'string' || !filePath) { if (typeof filePath !== 'string' || !filePath) {
// audit fix (Round 7 IPC-1):补齐 message 字段。renderer 走 // audit fix (Round 7 IPC-1):补齐 message 字段。renderer 走
// `result.message || result.error || '未知错误'` 兜底,没 message 会退到 // `result.message || result.error || '未知错误'` 兜底,没 message 会退到
@@ -1244,7 +1340,10 @@ ipcMain.handle('file:read', async (event, filePath) => {
return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message };
} }
try { try {
const st = await fs.stat(filePath); // audit fix (Round 17 H2):stat / readFile 走 withTimeout —— SMB / OneDrive
// 挂死时 renderer 不会无 loading 一直等;超时错误带 FS_TIMEOUT 透传到
// renderer 走「磁盘暂时不可用」文案。
const st = await withTimeout(fs.stat(filePath), undefined, 'file:read.stat');
if (!st.isFile()) { if (!st.isFile()) {
return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '不是普通文件' }; return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '不是普通文件' };
} }
@@ -1256,7 +1355,7 @@ ipcMain.handle('file:read', async (event, filePath) => {
message: `文件过大(${(st.size / 1024 / 1024).toFixed(1)} MB),已超过 ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)} MB 上限` message: `文件过大(${(st.size / 1024 / 1024).toFixed(1)} MB),已超过 ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)} MB 上限`
}; };
} }
const content = await fs.readFile(filePath, 'utf-8'); const content = await withTimeout(fs.readFile(filePath, 'utf-8'), undefined, 'file:read.read');
// audit fix (Phase L3-FS 2A):去掉 UTF-8 BOM(Notepad 等 Windows 工具会带 // audit fix (Phase L3-FS 2A):去掉 UTF-8 BOM(Notepad 等 Windows 工具会带
// 字节序标记存 UTF-8)。BOM 在第一行 heading 之前渲染成零宽字符,会让 // 字节序标记存 UTF-8)。BOM 在第一行 heading 之前渲染成零宽字符,会让
// 文内锚点的 slug 偏移 / 复制粘贴时污染 / DOMPurify 把它当成不可见字符 // 文内锚点的 slug 偏移 / 复制粘贴时污染 / DOMPurify 把它当成不可见字符
@@ -1312,7 +1411,7 @@ ipcMain.handle('file:read', async (event, filePath) => {
* *
* 不传 expectedMtimeMs(老调用 / 强写场景)则跳过校验。 * 不传 expectedMtimeMs(老调用 / 强写场景)则跳过校验。
*/ */
ipcMain.handle('file:write', async (event, filePath, content, expectedMtimeMs = null) => { ipcMain.handle('file:write', async (_event, filePath, content, expectedMtimeMs = null) => {
if (typeof filePath !== 'string' || !filePath) { if (typeof filePath !== 'string' || !filePath) {
// audit fix (Round 7 IPC-3):补齐 code + message,对齐其它 file:* handler 的 // audit fix (Round 7 IPC-3):补齐 code + message,对齐其它 file:* handler 的
// 字段集。renderer 一律 `result.code / result.message` 取值,没这两个字段 // 字段集。renderer 一律 `result.code / result.message` 取值,没这两个字段
@@ -1374,7 +1473,8 @@ ipcMain.handle('file:write', async (event, filePath, content, expectedMtimeMs =
try { try {
// mtime 校验(audit #2) // mtime 校验(audit #2)
if (Number.isFinite(expectedMtimeMs)) { if (Number.isFinite(expectedMtimeMs)) {
const stNow = await fs.stat(filePath); // audit fix (Round 17 H2):stat 走 withTimeout —— 挂死磁盘不挡 IPC 响应。
const stNow = await withTimeout(fs.stat(filePath), undefined, 'file:write.mtimeCheck');
// Math.floor 兼容 sub-ms 浮点误差 // Math.floor 兼容 sub-ms 浮点误差
if (Math.floor(stNow.mtimeMs) !== Math.floor(expectedMtimeMs)) { if (Math.floor(stNow.mtimeMs) !== Math.floor(expectedMtimeMs)) {
return { return {
@@ -1389,8 +1489,24 @@ ipcMain.handle('file:write', async (event, filePath, content, expectedMtimeMs =
} }
// audit fix (C1 file-IO):原子写(tmp + fsync + rename),防止进程在 // audit fix (C1 file-IO):原子写(tmp + fsync + rename),防止进程在
// writeFile 中途崩溃导致文件内容半截(用户丢失全部编辑)。 // writeFile 中途崩溃导致文件内容半截(用户丢失全部编辑)。
await fileOps.atomicWriteFile(filePath, content); // audit fix (Round 17 H2):原子写整体包裹 —— renameWithRetry 的总耗时
const st = await fs.stat(filePath); // (fs.open + writeFile + sync + rename + 父目录 sync)在 SMB 上可能
// 超 30s 时基本就是磁盘掉了。
await withTimeout(
fileOps.atomicWriteFile(filePath, content),
undefined,
'file:write.atomic',
);
const st = await withTimeout(fs.stat(filePath), undefined, 'file:write.stat');
// audit fix (Round 16 M1):self-write filter —— 慢盘(SMB / OneDrive)下
// atomicWriteFile 触发 fs.watch 事件的时刻可能早于本 IPC 响应回到 renderer,
// renderer 没拿到 mtimeMs 就收 files:changed → 误判外部修改 → 弹 reload
// 对话框。告诉 fsWatcher 「这个文件接下来 500ms 内的变更是我自己」,让
// dirWatcher 跳过对应 emit。
if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') {
try { fsWatcher.markExpectedWrite(filePath, st.mtimeMs); }
catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); }
}
return { ok: true, mtimeMs: st.mtimeMs, size: st.size }; return { ok: true, mtimeMs: st.mtimeMs, size: st.size };
} catch (e) { } catch (e) {
if (e.code === 'ENOENT') { if (e.code === 'ENOENT') {
@@ -1483,43 +1599,62 @@ ipcMain.handle('file:create', async (_event, rawName, opts) => {
}; };
} }
const r = await resolveFileName(rawName, targetDir); // audit fix (2026-09-21):标记是否新建了子目录,用于失败路径下清理空目录。
// audit fix (Round 7 IPC-2):补齐 code + message 字段,与其它 file:* handler // 之前 mkdir 成功后任何下游失败(resolveFileName / isWithinDataDir / symlink
// 对齐。resolveFileName 返回的 r.error 已经是中文明文消息("文件名不能为空" // 防护 / atomicWriteFile)都会留下 targetDir 这个空子目录 —— 数据目录里
// / "文件名包含 Windows 保留字符" 等),把它同时塞 message 让 renderer 走 // 出现不可见的垃圾文件夹,下次扫描会列入文件列表。targetDir === dataRoot
// friendlyFsError(code, message) 时不再兜底到 result.error 字符串。 // 时不需要清理(root 早就在)。
if (!r.ok) { const didMkdirSubdir = targetDir !== dataRoot;
return { // best-effort 清理函数:仅当目录为空时移除(已含文件的不动,避免误删)。
ok: false, async function cleanupEmptySubdir() {
error: 'INVALID_NAME', if (!didMkdirSubdir) return;
code: 'INVALID_NAME', try {
message: r.error, const entries = await fs.readdir(targetDir);
}; if (entries.length === 0) {
} await fs.rmdir(targetDir);
}
// audit fix (1.1):与其他 file:* handler 对称地做 isWithinDataDir 防御 + symlink 防护。 } catch (cleanupErr) {
// 理论上是新文件不会有 symlink,但 resolveFileName 已过滤路径分隔符 / '..', // 清理本身失败不抛 —— 让原始错误的响应路径主导。
// 这层只为了与 read/write/rename/delete 保持对称,且挡住未来可能的边界输入。 console.warn('[main] file:create 清理空子目录失败:', _redactPath(targetDir), cleanupErr.code || cleanupErr.message);
if (!isWithinDataDir(r.path, dataRoot)) { }
return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' };
}
const linkCheck = await assertNotSymlink(r.path);
if (!linkCheck.ok) {
return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message };
}
// audit fix:与 file:read/write/rename/delete 对称做 assertNoSymlinkAncestor。
// file:create 的 targetDir 上面只 lstat 了自己(如果非根),但 r.path 落在某个
// 中间是 symlink 的目录下时(如 dataRoot/links/sub/foo.md,其中 links 是
// 指向外部的 symlink),atomicWriteFile 会跟随 symlink 把文件写到外部,
// 绕过 isWithinDataDir 的字符串前缀边界。assertNotSymlink 只判 r.path 本身
// (不存在 → 放过),挡不住中间路径是 symlink 的场景。
const ancestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot);
if (!ancestorCheck.ok) {
return { ok: false, error: ancestorCheck.error, message: ancestorCheck.message };
} }
// 把 mkdir 之后的所有失败路径统一收到一个 catch 里,方便清理逻辑只写一份。
// 早期错误(resolveFileName / isWithinDataDir / symlink 防护)抛带 _early
// 标记的结构化对象;atomicWriteFile 抛原始 Error。catch 里按标记分流。
let resolvedName = null;
try { try {
const r = await resolveFileName(rawName, targetDir);
// audit fix (Round 7 IPC-2):补齐 code + message 字段,与其它 file:* handler
// 对齐。resolveFileName 返回的 r.error 已经是中文明文消息("文件名不能为空"
// / "文件名包含 Windows 保留字符" 等),把它同时塞 message 让 renderer 走
// friendlyFsError(code, message) 时不再兜底到 result.error 字符串。
if (!r.ok) {
throw Object.assign(new Error(r.error), { _early: { error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error } });
}
// audit fix (1.1):与其他 file:* handler 对称地做 isWithinDataDir 防御 + symlink 防护。
// 理论上是新文件不会有 symlink,但 resolveFileName 已过滤路径分隔符 / '..',
// 这层只为了与 read/write/rename/delete 保持对称,且挡住未来可能的边界输入。
if (!isWithinDataDir(r.path, dataRoot)) {
throw Object.assign(new Error('路径不在数据目录内'), { _early: { error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' } });
}
const linkCheck = await assertNotSymlink(r.path);
if (!linkCheck.ok) {
throw Object.assign(new Error(linkCheck.message || 'symlink 防护拒绝'), { _early: linkCheck });
}
// audit fix:与 file:read/write/rename/delete 对称做 assertNoSymlinkAncestor。
// file:create 的 targetDir 上面只 lstat 了自己(如果非根),但 r.path 落在某个
// 中间是 symlink 的目录下时(如 dataRoot/links/sub/foo.md,其中 links 是
// 指向外部的 symlink),atomicWriteFile 会跟随 symlink 把文件写到外部,
// 绕过 isWithinDataDir 的字符串前缀边界。assertNotSymlink 只判 r.path 本身
// (不存在 → 放过),挡不住中间路径是 symlink 的场景。
const ancestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot);
if (!ancestorCheck.ok) {
throw Object.assign(new Error(ancestorCheck.message || '祖先链 symlink 防护拒绝'), { _early: ancestorCheck });
}
// 初始内容默认为 `# 标题\n\n` —— 哪怕空字符串也保证文件可读 // 初始内容默认为 `# 标题\n\n` —— 哪怕空字符串也保证文件可读
// 2026-08-28 反馈:resolveFileName 不再强制补 .md,所以这里按最后一个 . // 2026-08-28 反馈:resolveFileName 不再强制补 .md,所以这里按最后一个 .
// 剥除任意扩展名(foo.md → foo;foo.txt → foo;无扩展名 → 原样)。 // 剥除任意扩展名(foo.md → foo;foo.txt → foo;无扩展名 → 原样)。
@@ -1529,20 +1664,36 @@ ipcMain.handle('file:create', async (_event, rawName, opts) => {
: `# ${baseForTitle}\n\n`; : `# ${baseForTitle}\n\n`;
// audit fix (C2 file-IO):原子写,与 file:write 对齐。 // audit fix (C2 file-IO):原子写,与 file:write 对齐。
// 即便 create 失败也不该留半截文件——磁盘要么没这个文件,要么是完整的。 // 即便 create 失败也不该留半截文件——磁盘要么没这个文件,要么是完整的。
await fileOps.atomicWriteFile(r.path, content); // audit fix (Round 17 H2):包裹 withTimeout —— 与 file:write 同源。
const st = await fs.stat(r.path); await withTimeout(
fileOps.atomicWriteFile(r.path, content),
undefined,
'file:create.atomic',
);
resolvedName = r.name;
const st = await withTimeout(fs.stat(r.path), undefined, 'file:create.stat');
// audit fix (Round 16 M1):self-write filter —— 与 file:write 同源(见
// 上方 file:write 的注释)。新建文件的 fs.watch 事件同样会在 IPC 响应
// 之前到达 renderer,导致侧栏触发不必要的重渲染 / 「文件已存在」误判。
if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') {
try { fsWatcher.markExpectedWrite(r.path, st.mtimeMs); }
catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); }
}
return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size };
} catch (e) { } catch (e) {
// audit fix (1.1):补齐 code / message 字段,与 file:read/write 对齐, // 失败路径:先清理可能新建的空子目录,再按错误类型返回响应。
// renderer 的 friendlyWriteError 才能按 errno 给中文提示。 await cleanupEmptySubdir();
// audit fix (B-2):这里之前直接返回 e.message(英文 + 含完整路径),与 file:write if (e && e._early) {
// 不一致。按 errno 翻译成中文提示,路径走 _redactPath 防泄露到 tmp 日志。 // 早期错误(resolveFileName / isWithinDataDir / symlink 防护):直接透传结构化字段。
console.error('[main] file:create 失败:', _redactPath(r.path), e.code || e.message); return { ok: false, error: e._early.error, code: e._early.code, message: e._early.message };
}
// atomicWriteFile 错误:按 errno 翻译成中文提示,路径走 _redactPath 防泄露到 tmp 日志。
console.error('[main] file:create 失败:', _redactPath(targetDir + '/' + rawName), e.code || e.message);
return { return {
ok: false, ok: false,
error: e.code || 'CREATE_FAILED', error: e.code || 'CREATE_FAILED',
code: e.code, code: e.code,
message: _friendlyCreateError(e, r.name), message: _friendlyCreateError(e, resolvedName || rawName),
}; };
} }
}); });
@@ -1653,9 +1804,18 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => {
}; };
} }
try { try {
await fs.rename(oldPath, r.path); // audit fix (Round 17 H2):rename 走 withTimeout —— SMB / OneDrive 持锁时
// rename 可能挂死数分钟;超时透传 FS_TIMEOUT 让 renderer 弹友好提示。
await withTimeout(fs.rename(oldPath, r.path), undefined, 'file:rename.rename');
// 重新 stat 拿到新 mtime/size(audit #4:之前漏掉,renderer 状态栏会短暂无日期) // 重新 stat 拿到新 mtime/size(audit #4:之前漏掉,renderer 状态栏会短暂无日期)
const st = await fs.stat(r.path); const st = await withTimeout(fs.stat(r.path), undefined, 'file:rename.stat');
// audit fix (Round 16 M1):self-write filter —— rename 触发 fs.watch 两个
// 事件(oldPath 消失 + r.path 出现)。只要新路径上 500ms 内是 self-write 就
// 拦住后续 emit,避免 renderer 把「自己刚重命名」当成外部修改触发侧栏闪烁。
if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') {
try { fsWatcher.markExpectedWrite(r.path, st.mtimeMs); }
catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); }
}
return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size };
} catch (e) { } catch (e) {
// audit fix (K1-H1):console 走 _redactPath(之前 e.message 直接打到日志, // audit fix (K1-H1):console 走 _redactPath(之前 e.message 直接打到日志,
@@ -1739,7 +1899,10 @@ ipcMain.handle('file:delete', async (_event, filePath) => {
// - 文件在外部存储 / 网络盘等 trash 不可达的位置 // - 文件在外部存储 / 网络盘等 trash 不可达的位置
// 现在:trashItem 失败时直接报错,让 UI 提示用户(而不是悄悄 unlink 把文件抹掉)。 // 现在:trashItem 失败时直接报错,让 UI 提示用户(而不是悄悄 unlink 把文件抹掉)。
try { try {
await shell.trashItem(filePath); // audit fix (Round 17 H2):trashItem 也走 withTimeout —— 网络盘 trash 不可达时
// Electron 内部可能挂死(直到 trash 处理超时才会 reject)。30s 强制让
// renderer 拿到结果。
await withTimeout(shell.trashItem(filePath), undefined, 'file:delete.trash');
return { ok: true, path: filePath, recycled: true }; return { ok: true, path: filePath, recycled: true };
} catch (e) { } catch (e) {
// audit fix (K1-L2):之前 message 拼 e.message(英文 errno + 路径)回到 renderer // audit fix (K1-L2):之前 message 拼 e.message(英文 errno + 路径)回到 renderer
@@ -1854,7 +2017,10 @@ ipcMain.handle('app:open-path', async (_event, target) => {
}); });
ipcMain.handle('app:choose-data-dir', async () => { ipcMain.handle('app:choose-data-dir', async () => {
if (!mainWindow) return null; // audit fix (H5):补 isDestroyed() —— !mainWindow 只能挡 null/undefined,
// 异步窗口销毁的窗口期(mainWindow 引用还在但 isDestroyed===true)会进
// showOpenDialog 抛错。X 紧接菜单点「选择数据目录」的场景能复现。
if (!mainWindow || mainWindow.isDestroyed()) return null;
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
title: '选择数据文件夹', title: '选择数据文件夹',
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
@@ -1886,12 +2052,12 @@ ipcMain.handle('app:reset-data-dir', async () => {
return { ok: false, error: saveResult.error || '配置保存失败' }; return { ok: false, error: saveResult.error || '配置保存失败' };
} }
// 走 currentDataRoot 而不是 resolveDataDir —— 内部会 sync mkdir(默认目录可能 // 走 currentDataRoot 而不是 resolveDataDir —— 内部会 sync mkdir(默认目录可能
// 从未被用过)+ 异步种子 welcome.md,让 fsWatcher 启动时不撞 ENOENT、侧栏里 // 从未被用过)+ 异步种子 README.md,让 fsWatcher 启动时不撞 ENOENT、侧栏里
// 立刻有欢迎文档可见。 // 立刻有欢迎文档可见。
const newDir = currentDataRoot(); const newDir = currentDataRoot();
currentDataDir = newDir; currentDataDir = newDir;
// 等欢迎文档就绪再重启 fsWatcher,避免「fsWatcher 启动时拿到空目录、 // 等欢迎文档就绪再重启 fsWatcher,避免「fsWatcher 启动时拿到空目录、
// 几秒后 welcome.md 突然冒出侧栏」的 UX 撕裂。 // 几秒后 README.md 突然冒出侧栏」的 UX 撕裂。
await configStore.scheduleEnsureDefaultDataDir(); await configStore.scheduleEnsureDefaultDataDir();
// 重启目录监听:旧监听在缺失的 custom 路径上,新监听切到默认 // 重启目录监听:旧监听在缺失的 custom 路径上,新监听切到默认
try { try {
@@ -2070,7 +2236,10 @@ ipcMain.handle('shell:open-dir', async (_event, dirPath) => {
} }
const resolved = path.resolve(dirPath); const resolved = path.resolve(dirPath);
if (!isWithinDataDir(resolved, currentDataRoot())) { if (!isWithinDataDir(resolved, currentDataRoot())) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; // audit fix (Round 7 IPC-4 风格补齐):与 file:read/write 等对齐 error + code + message。
// renderer 走 friendlyFsError(code, message) 没 code 字段会落到 error 字符串,
// 文案与其它通道不一致。
return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' };
} }
try { try {
// audit fix:用 lstat 拒绝 symlink 目录(与 file:scan-dir 对齐)。 // audit fix:用 lstat 拒绝 symlink 目录(与 file:scan-dir 对齐)。
@@ -2165,8 +2334,7 @@ ipcMain.handle('window:close', () => {
}); });
function broadcastMaximizeState() { function broadcastMaximizeState() {
if (!mainWindow || mainWindow.isDestroyed()) return; safeSendToRenderer('window:maximize-state', mainWindow?.isMaximized?.() ?? false);
mainWindow.webContents.send('window:maximize-state', mainWindow.isMaximized());
} }
// ============================================ // ============================================
@@ -2337,10 +2505,16 @@ ipcMain.handle('app:open-config-dir', async () => {
ipcMain.handle('app:save-settings', async (event, partial) => { ipcMain.handle('app:save-settings', async (event, partial) => {
if (!partial || typeof partial !== 'object') return { ok: true, settings: configStore.getConfig() }; if (!partial || typeof partial !== 'object') return { ok: true, settings: configStore.getConfig() };
// 目录存在性校验(schema 里用 opts.resolveDir 注入,因为要异步 fs.stat + access) // 目录存在性校验(schema 里用 opts.resolveDir 注入,因为要异步 fs.lstat + access)
// audit fix (Security-L4):旧版用 fs.stat 会跟随 symlink —— 用户 / 同步盘 /
// 配置注入可以塞一个 symlink 指到任意目录,本会话内该 symlink 目标就成了 active
// dataDir,违反「dataDir 不得是 symlink」的不变量(resolveDataDirOrFallback 是
// 用 lstat 拒的;本入口是同一不变量,必须对齐)。改用 fs.lstat 后 isSymbolicLink
// 即拒。
const resolveDir = async (p) => { const resolveDir = async (p) => {
try { try {
const st = await fs.stat(p); const st = await fs.lstat(p);
if (st.isSymbolicLink()) return { ok: false, error: '不允许将符号链接作为数据目录' };
if (!st.isDirectory()) return { ok: false, error: '路径不是一个目录' }; if (!st.isDirectory()) return { ok: false, error: '路径不是一个目录' };
await fs.access(p, fsSync.constants.R_OK); await fs.access(p, fsSync.constants.R_OK);
return { ok: true }; return { ok: true };
@@ -2392,13 +2566,21 @@ ipcMain.handle('app:save-settings', async (event, partial) => {
// audit fix:直接比较字符串会因为尾斜杠 / 大小写 / `..` 残留而误判。 // audit fix:直接比较字符串会因为尾斜杠 / 大小写 / `..` 残留而误判。
// 用 path.resolve 归一化后再比;Windows 上大小写不敏感。 // 用 path.resolve 归一化后再比;Windows 上大小写不敏感。
const newResolved = path.resolve(newDirRaw); const newResolved = path.resolve(newDirRaw);
const curResolved = path.resolve(currentDataDir); // audit fix (M3):currentDataDir === null 兜底 —— 当前总是非 null(whenReady
// 已先 set),但 app:save-settings 的早期触发 / future splash 路径可能命中。
// TypeError: path.resolve(null) 会把整个 settings 保存流程打挂。
const curResolved = path.resolve(currentDataDir || newDirRaw);
const same = process.platform === 'win32' const same = process.platform === 'win32'
? newResolved.toLowerCase() === curResolved.toLowerCase() ? newResolved.toLowerCase() === curResolved.toLowerCase()
: newResolved === curResolved; : newResolved === curResolved;
if (!same) { if (!same) {
currentDataDir = newDirRaw; currentDataDir = newDirRaw;
fsWatcher.startWatchingDir(newDirRaw); // audit fix (H4):与 app:reset-data-dir 同款 try/catch —— startWatchingDir
// 同步路径可能 ENOENT / EACCES(旧目录已被删 / 权限收紧),失败也不应该
// 让 IPC 抛回 renderer(磁盘已 saveConfig 成功)。失败只丢 watcher,配置
// 与数据目录写入照常生效;下次启动会自然拉起。
try { fsWatcher.startWatchingDir(newDirRaw); }
catch (e) { console.warn('[main] restart fsWatcher 失败:', e?.message || e); }
if (tray) { if (tray) {
tray.setContextMenu(buildTrayMenu()); tray.setContextMenu(buildTrayMenu());
} }
@@ -2438,13 +2620,15 @@ function buildMenu() {
{ {
label: '切换主题', label: '切换主题',
accelerator: 'CmdOrCtrl+Shift+T', accelerator: 'CmdOrCtrl+Shift+T',
click: () => mainWindow?.webContents.send('menu:toggle-theme') // audit fix (H3):应用菜单 click 走 safeSendToRenderer —— 关闭中窗口
// 的 webContents.send 同款 TypeError 风险。
click: () => safeSendToRenderer('menu:toggle-theme')
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: '设置...', label: '设置...',
accelerator: 'CmdOrCtrl+,', accelerator: 'CmdOrCtrl+,',
click: () => mainWindow?.webContents.send('menu:settings') click: () => safeSendToRenderer('menu:settings')
}, },
{ type: 'separator' }, { type: 'separator' },
{ {

View File

@@ -40,6 +40,9 @@ const DEFAULT_TIMEOUT_MS = 300_000;
// 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。 // 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。
// 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。 // 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。
const MAX_CONTENT_BYTES = 1_500_000; const MAX_CONTENT_BYTES = 1_500_000;
// 提示词硬上限:64 KB ≈ 1.6 万中文字。系统提示词另算(settings 上限 200 KB,
// 拼接后的总输入也由各 API 服务端二次校验);这里只挡用户 prompt 单字段。
const MAX_PROMPT_BYTES = 64_000;
// 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。 // 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。
// 5 MB 对正常 AI 回复(几十 KB 到 1 MB)足够宽松。 // 5 MB 对正常 AI 回复(几十 KB 到 1 MB)足够宽松。
const MAX_RESP_BYTES = 5 * 1024 * 1024; const MAX_RESP_BYTES = 5 * 1024 * 1024;
@@ -56,6 +59,7 @@ const ANTHROPIC_API_VERSION = '2023-06-01';
// 把同一份 AI_ERROR 暴露到 window.api.aiErrors,main / renderer 永远引用同一对象, // 把同一份 AI_ERROR 暴露到 window.api.aiErrors,main / renderer 永远引用同一对象,
// 不再靠注释提醒同步。 // 不再靠注释提醒同步。
const { AI_ERROR } = require('../shared/ai-errors.js'); const { AI_ERROR } = require('../shared/ai-errors.js');
const nodeCrypto = require('node:crypto');
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;
@@ -205,13 +209,32 @@ function joinUrl(base, path) {
// M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo(`https://user:pass@host`)。 // M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo(`https://user:pass@host`)。
// fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务, // fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务,
// 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。 // 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。
//
// M2 follow-up:旧 regex `^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@` 只识别字面 `@`,
// 对 percent-encoded `user%40evil.com:pass@host` 会放过(`%40` 走 URL 解析后
// 才被还原成 `@`,fetch 仍按 Basic Auth 发送)。统一走 new URL 解析:URL parser
// 主动还原 percent-encoding 并暴露 username / password,靠这两个字段判定 /
// 剥除更稳。query / fragment 仍由外层 split 拿回。
const s = String(base || ''); const s = String(base || '');
const m = s.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); const m = s.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
if (!m) return s; if (!m) return s;
let b = m[1].replace(/\/+$/, ''); let originAndPath = m[1].replace(/\/+$/, '');
b = b.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1'); try {
const parsed = new URL(originAndPath);
// 任何形式的 userinfo(字面 / percent-encoded / IDN punycode 包装)都会
// 被 URL parser 还原到 username / password 字段。剥干净再拼回。
// 注意:直接把 username/password 置空 + toString() 仍会保留空 userinfo 的
// `https://@host` 形式,所以这里走 host + pathname 重组最稳。
if (parsed.username || parsed.password) {
const port = parsed.port ? `:${parsed.port}` : '';
originAndPath = `${parsed.protocol}//${parsed.hostname}${port}${parsed.pathname}`.replace(/\/+$/, '');
}
} catch {
// URL 解析失败:保留原字符串的 regex 兜底(防御旧路径畸形输入)
originAndPath = originAndPath.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1');
}
const p = String(path || '').replace(/^\/+/, ''); const p = String(path || '').replace(/^\/+/, '');
return `${b}/${p}${m[2] || ''}${m[3] || ''}`; return `${originAndPath}/${p}${m[2] || ''}${m[3] || ''}`;
} }
/** /**
@@ -256,10 +279,20 @@ function sanitizeDetail(detail) {
* @returns {string} * @returns {string}
*/ */
function sanitizeUrl(raw) { function sanitizeUrl(raw) {
return String(raw || '') let s = String(raw || '');
// M2 fix (audit):先剥 userinfo(https://user:pass@host),否则 fetch Basic Auth // M2 follow-up:与 joinUrl 同款 percent-encoded userinfo 防御。
// 凭据会被完整写到错误日志(即使 query/fragment 已剥,userinfo 仍在 host 前) // 先用 new URL 解析(字面 / 编码 / punycode 都被还原到 username/password),
.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1') // 命中则走 host + pathname 重组。否则 fallback 旧 regex 兜底畸形输入。
try {
const parsed = new URL(s);
if (parsed.username || parsed.password) {
const port = parsed.port ? `:${parsed.port}` : '';
s = `${parsed.protocol}//${parsed.hostname}${port}${parsed.pathname}`;
}
} catch {
s = s.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1');
}
return s
.replace(/[?#].*$/, '') // 去 query / fragment .replace(/[?#].*$/, '') // 去 query / fragment
.replace(/\/(sk-[A-Za-z0-9_-]{8,})/g, '/[REDACTED]') // path 段里的 OpenAI key .replace(/\/(sk-[A-Za-z0-9_-]{8,})/g, '/[REDACTED]') // path 段里的 OpenAI key
.replace(/\/(AIzaSy[A-Za-z0-9_-]{20,})/g, '/[REDACTED]'); // path 段里的 Google key .replace(/\/(AIzaSy[A-Za-z0-9_-]{20,})/g, '/[REDACTED]'); // path 段里的 Google key
@@ -288,8 +321,16 @@ async function readBodyWithLimit(res) {
/** @type {Buffer[]} */ /** @type {Buffer[]} */
const chunks = []; const chunks = [];
let total = 0; let total = 0;
let readErr = null;
while (true) { while (true) {
const { value, done } = await reader.read(); let value;
let done;
try {
({ value, done } = await reader.read());
} catch (e) {
readErr = e;
break;
}
if (done) break; if (done) break;
total += value.byteLength; total += value.byteLength;
if (total > MAX_RESP_BYTES) { if (total > MAX_RESP_BYTES) {
@@ -298,6 +339,13 @@ async function readBodyWithLimit(res) {
} }
chunks.push(Buffer.from(value)); chunks.push(Buffer.from(value));
} }
if (readErr) {
// Round 13 fix (M9):reader.read() 抛错时不 cancel — 流连接资源会被 GC
// 最终释放,但 fetch 在 abort 时仍可能持有未读数据;显式 cancel 让对端
// 立即知道我们不再消费,提早关连接。
try { reader.cancel(); } catch { /* ignore */ }
return { ok: false, message: '读取 AI 响应失败' };
}
return { ok: true, text: Buffer.concat(chunks).toString('utf8') }; return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
} }
@@ -343,6 +391,14 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
// C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转 // C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转
for (const timer of timers.values()) clearTimeout(timer); for (const timer of timers.values()) clearTimeout(timer);
timers.clear(); timers.clear();
// 第二阶段代码审查 (Phase 2 review #3):同步清理 inFlightRequestIds。
// 之前只清 pending / timers —— runEdit 的 finally 也会 clear 自己加进 Set
// 的 requestId,所以正常路径下 Set 会自然排空。但 cancelAll 在
// app.on('will-quit') 调用(main.js:889),与 runEdit finally 之间存在
// 竞态窗口:requestId 仍残留在 Set 里时若有新 AI 请求复用同一 requestId
// (test harness / HMR / 未来 re-init),cancel(requestId) 会取消错对象。
// 显式 clear 让 cancelAll 真正幂等且不留残根。
inFlightRequestIds.clear();
} }
/** /**
@@ -377,6 +433,32 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
return { ok: true, value: s }; return { ok: true, value: s };
} }
/**
* Round 13 fix (M6):抽「明文 HTTP + 非 loopback + 已配 Key → 拒绝」共用检查。
* OpenAI / Anthropic 两条分支各复制 30 行同款防御,现在统一走这里。
* 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 / ::1
* 不需要 Key(apiKey 留空即放过),配置 Key 时必须 HTTPS。
* @param {string} baseUrl - 已经 validateBaseUrl 通过的 URL
* @param {string} apiKey - 当前配置的 API Key(空串 = 未配)
* @returns {null | {ok:false, error:string, message:string}} 通过时返回 null
*/
function rejectInsecureKeyTransport(baseUrl, apiKey) {
if (!/^http:\/\//i.test(baseUrl) || !apiKey) return null;
try {
const parsed = new URL(baseUrl);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (isLoopback) return null;
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(baseUrl));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。',
};
} catch { /* validateBaseUrl 已校验过 URL,这里兜底 */ }
return null;
}
/** /**
* 按模型名路由 token 上限 + token 字段。 * 按模型名路由 token 上限 + token 字段。
* H1 (audit):65536 远超多数模型上限(gpt-3.5=4096, gpt-4=8192, gpt-4o=16384), * H1 (audit):65536 远超多数模型上限(gpt-3.5=4096, gpt-4=8192, gpt-4o=16384),
@@ -434,12 +516,18 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
*/ */
function pickAnthropicTokenConfig(model) { function pickAnthropicTokenConfig(model) {
const m = String(model || '').toLowerCase(); const m = String(model || '').toLowerCase();
// 5 系(opus-5 / sonnet-5 / fable-5 / mythos-5)与 4.6+ → 官方 128k 上限 // 5 系(opus-5 / sonnet-5 / fable-5 / mythos-5 / haiku-5)+ 4.6+ → 官方 128k 上限
if (/claude-(opus|sonnet|fable|mythos)-5/.test(m)) return 32_000; // Round 13 fix:旧正则漏了 `claude-haiku-5-*`,会落到下一档的 16k;
// 同样保留 haiku-5 与其他 5 系在同一档。
if (/claude-(opus|sonnet|fable|mythos|haiku)-5/.test(m)) return 32_000;
if (/claude-(opus|sonnet)-4-(6|7|8)/.test(m)) return 32_000; if (/claude-(opus|sonnet)-4-(6|7|8)/.test(m)) return 32_000;
// 其余 4 系(sonnet-4-5 / opus-4-5 / haiku-4-5 / claude-4-*)+ 3-5 / 3-7 // 其余 4 系(sonnet-4-5 / opus-4-5 / haiku-4-5 / claude-4-*)+ 3-5 / 3-7
if (/claude-(3-5|3\.5|3-7|sonnet-4|opus-4|haiku-4|4)/.test(m)) return 16_384; if (/claude-(3-5|3\.5|3-7|sonnet-4|opus-4|haiku-4|4)/.test(m)) return 16_384;
if (/claude-3/.test(m)) return 8_192; // Round 13 fix (H2):claude-3 老家族官方 max output = 4096。
// 旧值 8192 在 opus-3 / sonnet-3 / haiku-3 上会被服务端 400 拒绝,
// runEdit 走 TRUNCATED_MESSAGE 分支报「AI 修改结果不完整」(实际是
// 请求参数越界,不是输出被截断)。claude-3-5 / claude-3-7 由上一档命中。
if (/claude-3/.test(m)) return 4_096;
return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff
} }
@@ -676,26 +764,13 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
if (!urlCheck.ok) { if (!urlCheck.ok) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason }; return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
} }
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 // audit fix (Round 8 A-2) + Round 13 抽 helper:明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 / ::1 // 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 / ::1
// 且不需要 Key —— apiKey 留空就过;这里有 apiKey 意味着第三方 API,必须 HTTPS。 // 且不需要 Key —— apiKey 留空就过;这里有 apiKey 意味着第三方 API,必须 HTTPS。
// 静默放行会让 Key 在网线上裸奔到攻击者嗅探点,错误必须显式。 // 静默放行会让 Key 在网线上裸奔到攻击者嗅探点,错误必须显式。
// localhost/127.0.0.1/::1 仍然放过:本地抓包门槛远高于公网,本地代理是合法场景。 // localhost/127.0.0.1/::1 仍然放过:本地抓包门槛远高于公网,本地代理是合法场景。
if (/^http:\/\//i.test(urlCheck.value) && apiKey) { const insecureReject = rejectInsecureKeyTransport(urlCheck.value, apiKey);
try { if (insecureReject) return insecureReject;
const parsed = new URL(urlCheck.value);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (!isLoopback) {
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(urlCheck.value));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。',
};
}
} catch { /* validateBaseUrl 已校验过 URL,这里兜底 */ }
}
const url = joinUrl(baseURL, 'chat/completions'); const url = joinUrl(baseURL, 'chat/completions');
// H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒; // H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒;
@@ -767,23 +842,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
if (!urlCheck.ok) { if (!urlCheck.ok) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason }; return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
} }
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 // audit fix (Round 8 A-2) + Round 13 抽 helper:与 OpenAI 分支共用同样防御,
// 与 OpenAI 分支共用同样防御,本地代理不需要 Key,留空即可。 // 本地代理不需要 Key,留空即可。
if (/^http:\/\//i.test(urlCheck.value) && apiKey) { const insecureReject = rejectInsecureKeyTransport(urlCheck.value, apiKey);
try { if (insecureReject) return insecureReject;
const parsed = new URL(urlCheck.value);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (!isLoopback) {
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(urlCheck.value));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。',
};
}
} catch { /* validateBaseUrl 已校验过 URL */ }
}
const url = joinUrl(baseURL, 'v1/messages'); const url = joinUrl(baseURL, 'v1/messages');
// H1 (audit):Anthropic 各模型 max_tokens 上限不同,超额会 400 // H1 (audit):Anthropic 各模型 max_tokens 上限不同,超额会 400
@@ -822,6 +884,11 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
let hasNonTextBlock = false; let hasNonTextBlock = false;
for (const block of json.content) { for (const block of json.content) {
if (block && block.type === 'text' && typeof block.text === 'string') { if (block && block.type === 'text' && typeof block.text === 'string') {
// Round 13 fix (H7):Anthropic 不保证 text 块之间有分隔符。
// [{text:'A.'}, {text:'B begin.'}] 直接拼接得 'A.B begin.',
// Markdown 渲染会把 `A.B` 误识为 list / 句点边界。块与块之间补
// 双换行(Markdown 段落分隔),让多块拼接后渲染保持原意。
if (message && !message.endsWith('\n')) message += '\n\n';
message += block.text; message += block.text;
} else if (block && block.type !== 'text') { } else if (block && block.type !== 'text') {
// audit fix (shared-M11):tool_use / tool_result / image 等非文本块 // audit fix (shared-M11):tool_use / tool_result / image 等非文本块
@@ -850,6 +917,14 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat }; return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat };
} }
// Round 13 fix (H3):从共享 AI_ERROR 引用错误码字面值。原先 4 处裸字面量
// ('INVALID_PROMPT' / 'INVALID_CONTENT' / 'CONTENT_TOO_LARGE' / 'PROMPT_TOO_LARGE')
// 绕开 AI_ERROR,renderer 端 ai-controller 走通用 toast 而不是本地化提示。
const ERR_INVALID_PROMPT = AI_ERROR.INVALID_PROMPT;
const ERR_INVALID_CONTENT = AI_ERROR.INVALID_CONTENT;
const ERR_CONTENT_TOO_LARGE = AI_ERROR.CONTENT_TOO_LARGE;
const ERR_PROMPT_TOO_LARGE = AI_ERROR.PROMPT_TOO_LARGE;
/** /**
* 执行一次 AI 修改请求。 * 执行一次 AI 修改请求。
* @param {{ prompt:string, content:string, filename:string, requestId:string }} input * @param {{ prompt:string, content:string, filename:string, requestId:string }} input
@@ -857,31 +932,33 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
*/ */
async function runEdit({ prompt, content, filename, requestId }) { async function runEdit({ prompt, content, filename, requestId }) {
if (typeof prompt !== 'string' || !prompt.trim()) { if (typeof prompt !== 'string' || !prompt.trim()) {
return { ok: false, error: 'INVALID_PROMPT', message: '修改要求不能为空' }; return { ok: false, error: ERR_INVALID_PROMPT, message: '修改要求不能为空' };
} }
if (typeof content !== 'string') { if (typeof content !== 'string') {
return { ok: false, error: 'INVALID_CONTENT', message: '当前文件内容无效' }; return { ok: false, error: ERR_INVALID_CONTENT, message: '当前文件内容无效' };
} }
// 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估) // 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估)
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) { if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
return { return {
ok: false, ok: false,
error: 'CONTENT_TOO_LARGE', error: ERR_CONTENT_TOO_LARGE,
message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB),请拆分后再让 AI 修改`, message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB),请拆分后再让 AI 修改`,
}; };
} }
if (Buffer.byteLength(prompt, 'utf8') > 64_000) { if (Buffer.byteLength(prompt, 'utf8') > MAX_PROMPT_BYTES) {
return { return {
ok: false, ok: false,
error: 'PROMPT_TOO_LARGE', error: ERR_PROMPT_TOO_LARGE,
message: '提示词过长(超过 64 KB),请简化要求', message: `提示词过长(超过 ${MAX_PROMPT_BYTES / 1024} KB),请简化要求`,
}; };
} }
if (typeof filename !== 'string') { if (typeof filename !== 'string') {
filename = ''; filename = '';
} }
if (typeof requestId !== 'string' || !requestId) { if (typeof requestId !== 'string' || !requestId) {
requestId = `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`; // Round 13 fix (M8):用 crypto.randomUUID 代替 Math.random;后者在
// 高并发(用户连点)下理论碰撞,会让 cancel 误杀新请求。
requestId = `ai-${Date.now()}-${nodeCrypto.randomUUID()}`;
} }
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};

View File

@@ -3,7 +3,7 @@
// 职责: // 职责:
// - 加载 / 保存用户配置(userData/config.json) // - 加载 / 保存用户配置(userData/config.json)
// - 解析「实际生效」的数据目录(用户自定义 vs 默认 ~/Notes) // - 解析「实际生效」的数据目录(用户自定义 vs 默认 ~/Notes)
// - 首次启动把 data/welcome.md 种子化到默认数据目录 // - 首次启动把 data/README.md 种子化到默认数据目录
// //
// 设计: // 设计:
// - 本模块自给自足:不读 mainWindow / tray / IPC 等任何 main.js 状态。 // - 本模块自给自足:不读 mainWindow / tray / IPC 等任何 main.js 状态。
@@ -102,7 +102,15 @@ function loadConfig() {
/** /**
* 删除数据目录里残留的 config.json.tmp.* 文件。 * 删除数据目录里残留的 config.json.tmp.* 文件。
* 只删自己 pid 的(其它进程残留不碰,避免多开实例互相影响)。 * 两层阈值:
* - 自己 pid + 60s 前 → 必删(防止本进程崩溃留下永久孤儿)
* - 非自己 pid + 24h 前 → 必删(清理上一次 Notes 实例崩溃的孤儿,
* 24h 长到不会误删正在运行的其它 Notes 实例)
*
* audit fix (Round 16 R2):旧版只覆盖「自己 pid + 60s」一层。Notes
* 进程被 kill / 断电时,tmp 文件用的是当时进程的 pid;下次启动时本进程
* pid 不同,60s 阈值永远不命中,孤儿永久残留。`scripts/_archive/` 下
* 9 个一次性 repro 脚本的历史足以说明这种崩溃/中断场景不是理论风险。
*/ */
function cleanupStaleTmpFiles() { function cleanupStaleTmpFiles() {
try { try {
@@ -110,16 +118,19 @@ function cleanupStaleTmpFiles() {
const files = fsSync.readdirSync(dir); const files = fsSync.readdirSync(dir);
const myPid = process.pid; const myPid = process.pid;
const now = Date.now(); const now = Date.now();
const STALE_THRESHOLD_MS = 60_000; // 60s 之前留下的才视为残留(避免误删正在写的) const STALE_THRESHOLD_MS = 60_000; // 自己 pid:60s
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 跨 pid:24h
for (const f of files) { for (const f of files) {
if (!/^config\.json\.tmp\./.test(f)) continue; if (!/^config\.json\.tmp\./.test(f)) continue;
const full = path.join(dir, f); const full = path.join(dir, f);
try { try {
const st = fsSync.statSync(full); const st = fsSync.statSync(full);
// 自己的 pid + 超过 60s 前 → 视为残留;跨 pid 不动(不归本进程管)
const isMine = f.includes(`.${myPid}.`); const isMine = f.includes(`.${myPid}.`);
const ageMs = now - st.mtimeMs; const ageMs = now - st.mtimeMs;
if (isMine && ageMs > STALE_THRESHOLD_MS) { const shouldRemove = isMine
? ageMs > STALE_THRESHOLD_MS
: ageMs > CROSS_PID_STALE_THRESHOLD_MS;
if (shouldRemove) {
fsSync.unlinkSync(full); fsSync.unlinkSync(full);
} }
} catch { /* 单个文件 stat/unlink 失败不影响整体 */ } } catch { /* 单个文件 stat/unlink 失败不影响整体 */ }
@@ -178,7 +189,12 @@ function saveConfig(next) {
_invalidateResolveCache(); _invalidateResolveCache();
} }
const cfgPath = getConfigPath(); const cfgPath = getConfigPath();
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}`; // audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证
// 跨进程 / 跨毫秒唯一,同进程同毫秒并发(典型场景:saveQueue 中 p1 与后续 task
// 在 await 后进入 fs.open)会算出相同 tmpPath,后到的 rename 因源文件已被消费
// 而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 亿组合,
// 同毫秒冲突概率可忽略。main/file-ops.js#atomicWriteFile 也做了对称修复。
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
let fh = null; let fh = null;
try { try {
await fs.mkdir(path.dirname(cfgPath), { recursive: true }); await fs.mkdir(path.dirname(cfgPath), { recursive: true });
@@ -337,12 +353,19 @@ function resolveDataDirOrFallback() {
// 重新 stat // 重新 stat
let exists = true; let exists = true;
try { try {
const st = fsSync.statSync(custom); // audit fix (H1 / symlink elevation):用 lstat 而非 stat —— stat 会 follow
// statSync 在普通文件 / 符号链接上不会抛 ENOTDIR —— ENOTDIR 只在「当目录用」时 // 符号链接,让一个被外部指向敏感目录的 symlink 当作「合法数据目录」通过
// 才会报。这里额外检查「不是目录」:dataDir 字段意外指向了一个文件路径 // 校验,随后 file:read/write/fsWatcher 都会跑在外部目录上,用户完全无感。
// (用户在设置对话框里手填、或 config.json 被改坏),仍应 fallback,否则 // 这里先 lstat,再显式拒绝 symlink;普通目录 / 文件路径走原逻辑。
// 上层 scanDir / watchDir 立刻 ENOTDIR 报上来,体验割裂。 const st = fsSync.lstatSync(custom);
if (!st.isDirectory()) exists = false; if (st.isSymbolicLink()) {
exists = false;
} else if (!st.isDirectory()) {
// dataDir 字段意外指向了一个文件路径(用户在设置对话框里手填、或
// config.json 被改坏),仍应 fallback,否则上层 scanDir / watchDir
// 立刻 ENOTDIR 报上来,体验割裂。
exists = false;
}
} catch (e) { } catch (e) {
if (e.code === 'ENOENT' || e.code === 'ENOTDIR') { if (e.code === 'ENOENT' || e.code === 'ENOTDIR') {
exists = false; exists = false;
@@ -381,25 +404,34 @@ function getConfig() {
} }
/** /**
* 首次启动把 data/welcome.md 种子化到默认数据目录。 * 首次启动把 data/README.md 种子化到默认数据目录。
* *
* 触发条件(任一为真则跳过): * 触发条件(任一为真则跳过):
* - 默认目录里已有 welcome.md(已种子过)→ 直接 return * - 默认目录里已有 README.md(已种子过)→ 直接 return
* - 默认目录里有其它 .md 文件(用户笔记)→ 不覆盖 * - 默认目录里有其它 .md 文件(用户笔记)→ 不覆盖
* *
* 用 welcome.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件 * 用 README.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件
* —— 用户数据目录应该只放用户的内容。 * —— 用户数据目录应该只放用户的内容。
* *
* 不再守卫「用户没设 dataDir」:auto-fallback 路径下用户设了 dataDir 但路径失效, * 不再守卫「用户没设 dataDir」:auto-fallback 路径下用户设了 dataDir 但路径失效,
* 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 welcome.md * 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 README
* 让用户立刻看到内容(而不是打开一个空侧栏)。 * 让用户立刻看到内容(而不是打开一个空侧栏)。
* *
* 失败仅 console.warn,不弹窗;用户用「打开数据文件夹」按钮可以自己补救。 * 失败仅 console.warn,不弹窗;用户用「打开数据文件夹」按钮可以自己补救。
*/ */
/**
* 种子文件清单 —— 顺序就是侧栏显示顺序(README 是首页)。
* 新增 onboarding 文档时只在这里加一行 + 在 data/ 下放对应源文件,无需改 IPC / 主流程。
*/
const ONBOARDING_SEED_FILES = [
'README.md',
];
async function ensureDefaultDataDir() { async function ensureDefaultDataDir() {
if (!DEFAULT_DATA_DIR) return; if (!DEFAULT_DATA_DIR) return;
const targetDir = DEFAULT_DATA_DIR; const targetDir = DEFAULT_DATA_DIR;
const welcomeDst = path.join(targetDir, 'welcome.md'); const sentinelDst = path.join(targetDir, 'README.md');
try { try {
// 先确保目录存在(mkdir recursive 不存在就建、存在 no-op)。 // 先确保目录存在(mkdir recursive 不存在就建、存在 no-op)。
@@ -409,15 +441,15 @@ async function ensureDefaultDataDir() {
// 或之后(启动 bootstrap),所以这里再保险一次。 // 或之后(启动 bootstrap),所以这里再保险一次。
await fs.mkdir(targetDir, { recursive: true }); await fs.mkdir(targetDir, { recursive: true });
// welcome.md 已存在 → 已经种子过(或用户改过),跳过 // README.md 已存在 → 已经种子过(或用户改过),跳过整个 onboarding 集合
try { try {
await fs.stat(welcomeDst); await fs.stat(sentinelDst);
return; return;
} catch (e) { } catch (e) {
if (e.code !== 'ENOENT') throw e; if (e.code !== 'ENOENT') throw e;
} }
// 已有其它 .md → 用户笔记,不覆盖 welcome 也不打扰用户 // 已有其它 .md → 用户笔记,不种子化也不打扰用户
const dirEntries = await fs.readdir(targetDir).catch((err) => { const dirEntries = await fs.readdir(targetDir).catch((err) => {
if (err.code === 'ENOENT') return []; if (err.code === 'ENOENT') return [];
throw err; throw err;
@@ -426,12 +458,25 @@ async function ensureDefaultDataDir() {
return; return;
} }
// 种子化 welcome.md // 种子化整套 onboarding 文件。每个文件独立 copyFile —— 中途某一份失败
const src = path.join(__dirname, '..', 'data', 'welcome.md'); // 不影响其他(部分种子优于全失败,符合"让用户立刻看到内容"的目标)。
await fs.copyFile(src, welcomeDst); const seeded = [];
console.log('[config-store] 已种子 welcome.md 到默认数据目录:', welcomeDst); for (const fileName of ONBOARDING_SEED_FILES) {
try {
const src = path.join(__dirname, '..', 'data', fileName);
const dst = path.join(targetDir, fileName);
await fs.copyFile(src, dst);
seeded.push(fileName);
} catch (fileErr) {
// 单文件失败(最常见:data/ 里漏放新文件源)仅 warn,不中断剩余文件
console.warn(`[config-store] 种子 ${fileName} 失败:`, fileErr.message);
}
}
if (seeded.length > 0) {
console.log(`[config-store] 已种子 onboarding 到默认数据目录(${seeded.length} 个):`, targetDir);
}
} catch (e) { } catch (e) {
console.warn('[config-store] welcome 种子失败:', e.message); console.warn('[config-store] onboarding 种子失败:', e.message);
} }
} }
@@ -445,7 +490,7 @@ async function seedDefaultDataDir() {
/** /**
* fire-and-forget 异步种子 —— resolveDataDirOrFallback 内每次返回 DEFAULT_DATA_DIR * fire-and-forget 异步种子 —— resolveDataDirOrFallback 内每次返回 DEFAULT_DATA_DIR
* 都会调它,确保在 IPC handler 同步返回之后异步把 welcome.md 种子进去。 * 都会调它,确保在 IPC handler 同步返回之后异步把 README.md 种子进去。
* *
* 用 _ensureInFlight Promise 去重:同一时间多次触发只跑一次(fsWatcher 启动后 * 用 _ensureInFlight Promise 去重:同一时间多次触发只跑一次(fsWatcher 启动后
* 第一次 IPC scanDir 触发 fallback + renderer 启动后的 resetDataDir + 启动自身 * 第一次 IPC scanDir 触发 fallback + renderer 启动后的 resetDataDir + 启动自身

View File

@@ -29,6 +29,21 @@ const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-f
/** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */ /** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */
const MAX_FILE_SIZE = 5 * 1024 * 1024; const MAX_FILE_SIZE = 5 * 1024 * 1024;
/**
* atomicWriteFile 产生的 tmp 文件名匹配规则:`<name>.<editableExt>.tmp.<pid>.<ts>.<rand>`。
* cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。
* 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 6 字符小写 base36。
*
* 旧版 `\.tmp\.\d+\.\d+\.[a-z0-9]+$` 太宽 —— 把 `report.tmp.2024.1234567.abcdef` 这种
* 用户合法文件也匹配上,24h 跨进程阈值后会被静默 unlink。要求 .tmp. 前一段是
* EDITABLE_EXTS 之一(白名单扩展名)把误伤清零。
*/
const ATOMIC_TMP_RE = new RegExp(
'\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') +
')\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]{1,8}$',
'i',
);
/** /**
* 检查 fullPath 的占用情况 —— 返回三种状态: * 检查 fullPath 的占用情况 —— 返回三种状态:
* - 'free' 路径不存在,可用 * - 'free' 路径不存在,可用
@@ -381,6 +396,11 @@ async function scanFiles(dir) {
} }
const mdFiles = entries.filter((e) => { const mdFiles = entries.filter((e) => {
if (!e.isFile()) return false; if (!e.isFile()) return false;
// Round 13 fix (H6):与 scanDir 对称过滤符号链接。OneDrive / 网盘
// 用户在数据目录里放指向外部 secret 文件的 symlink 时,旧版会跟随
// 读 size / mtime 但 file:read 拒开,UX 不一致且把外部 stat 信息暴露
// 到 renderer 内存。
if (e.isSymbolicLink()) return false;
const lower = e.name.toLowerCase(); const lower = e.name.toLowerCase();
return lower.endsWith('.md') || lower.endsWith('.markdown'); return lower.endsWith('.md') || lower.endsWith('.markdown');
}); });
@@ -585,7 +605,12 @@ function toRelativeDir(absDir, dataRoot) {
* @param {string} content UTF-8 文本 * @param {string} content UTF-8 文本
*/ */
async function atomicWriteFile(dst, content) { async function atomicWriteFile(dst, content) {
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}`; // audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证
// 跨进程 / 跨毫秒唯一,同进程同毫秒并发(如自动保存触发的 saveConfig 与
// 用户手动 Ctrl+S 撞在同一毫秒)会算出相同 tmpPath,后到的 rename 因源文件
// 已被消费而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21
// 亿组合,同毫秒冲突概率可忽略。main/config-store.js#saveConfig 也做了对称修复。
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
let fh; let fh;
try { try {
fh = await fs.open(tmpPath, 'w'); fh = await fs.open(tmpPath, 'w');
@@ -611,12 +636,14 @@ async function atomicWriteFile(dst, content) {
// 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。 // 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。
// 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。 // 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。
if (process.platform !== 'win32') { 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 { try {
const dirFh = await fs.open(path.dirname(dst), 'r');
await dirFh.sync(); await dirFh.sync();
await dirFh.close(); } finally {
} catch (dirFsyncErr) { try { await dirFh.close(); } catch { /* close 失败也无能为力 */ }
console.warn('[file-ops] 父目录 fsync 失败(不影响本次保存内容,但跨崩溃可能丢目录项):', dirFsyncErr.message);
} }
} }
} catch (e) { } catch (e) {
@@ -649,6 +676,57 @@ async function renameWithRetry(src, dst) {
} }
} }
/**
* 删除数据目录里残留的 `*.tmp.<pid>.<ts>.<rand>` 文件。
* 由 main.js 在启动时 / 切换数据目录时调用一次。
*
* 两层阈值(与 main/config-store.js#cleanupStaleTmpFiles 对齐):
* - 自己 pid + 60s 前 → 必删(防止本进程崩溃留下永久孤儿)
* - 非自己 pid + 24h 前 → 必删(清理上一次 Notes 实例崩溃的孤儿)
*
* audit fix (Round 16 R2):atomicWriteFile 自身的 try/catch 已经在失败时
* unlink tmp,但「本进程崩溃 / 被 kill -9 / 断电」三种场景不会进入清理分支。
* `scripts/_archive/` 9 个一次性 repro 脚本的历史足以说明崩溃/中断场景不是
* 理论风险。启动时扫一次数据目录,避免孤儿永久累积。
*
* @param {string} dataDir 数据目录绝对路径
* @returns {Promise<void>}
*/
async function cleanupStaleFileTmpFiles(dataDir) {
if (!dataDir) return;
try {
const files = await fs.readdir(dataDir);
const myPid = process.pid;
const now = Date.now();
const STALE_THRESHOLD_MS = 60_000;
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
for (const f of files) {
// 只匹配 atomicWriteFile 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`。
// audit fix (Tmp-L2):旧版 `/\.tmp\.\d+\.\d+\.[a-z0-9]+$/i` 太宽,
// 会把用户自己命名 / 同步盘生成 / 备份工具遗留的 `report.tmp.2024.1234567.abcdef`
// 这种正常文件当成孤儿静默 unlink。必须要求 .tmp. 前面是 EDITABLE_EXTS
// 之一(白名单扩展名),且 tmp 段是 pid.ts.rand 严格三段数字 + 小写 base36。
// config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向
// shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。
if (!ATOMIC_TMP_RE.test(f)) continue;
const full = path.join(dataDir, f);
try {
const st = await fs.stat(full);
const isMine = f.includes(`.${myPid}.`);
const ageMs = now - st.mtimeMs;
const shouldRemove = isMine
? ageMs > STALE_THRESHOLD_MS
: ageMs > CROSS_PID_STALE_THRESHOLD_MS;
if (shouldRemove) {
await fs.unlink(full);
}
} catch { /* 单个文件 stat/unlink 失败不影响整体 */ }
}
} catch {
// readdir 失败(目录不存在等)静默 —— 后续 IPC handler 会处理
}
}
/** /**
* audit fix (Round 4 收尾):file:write 的 errno 翻译 —— 与 main.js 各 IPC handler * audit fix (Round 4 收尾):file:write 的 errno 翻译 —— 与 main.js 各 IPC handler
* (_friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError) * (_friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError)
@@ -678,6 +756,7 @@ function friendlyWriteError(e) {
module.exports = { module.exports = {
MAX_FILE_SIZE, MAX_FILE_SIZE,
EDITABLE_EXTS, EDITABLE_EXTS,
ATOMIC_TMP_RE,
assertNotSymlink, assertNotSymlink,
assertNoSymlinkAncestor, assertNoSymlinkAncestor,
classifyEntry, classifyEntry,
@@ -690,5 +769,6 @@ module.exports = {
toRelativeDir, toRelativeDir,
atomicWriteFile, atomicWriteFile,
renameWithRetry, renameWithRetry,
cleanupStaleFileTmpFiles,
friendlyWriteError, friendlyWriteError,
}; };

View File

@@ -17,6 +17,9 @@
// - 不重复实现 scanDir —— 由 deps.scanDir() 注入(同一份扫描逻辑) // - 不重复实现 scanDir —— 由 deps.scanDir() 注入(同一份扫描逻辑)
const fsSync = require('fs'); const fsSync = require('fs');
// audit fix (Round 16 M1):self-write filter 需要 path.resolve 标准化路径,
// 把登记 key 和 dirWatcher 上报的 absPath 都归一化到同一形态。
const path = require('path');
const DIR_POLL_INTERVAL_MS = 2000; const DIR_POLL_INTERVAL_MS = 2000;
const EMIT_DEBOUNCE_MS = 300; const EMIT_DEBOUNCE_MS = 300;
@@ -31,6 +34,13 @@ const REATTACH_MAX_MS = 60_000;
// 屏幕常驻 5 条警告。30s 重发间隔让用户有充裕时间响应,又不至于漏掉 // 屏幕常驻 5 条警告。30s 重发间隔让用户有充裕时间响应,又不至于漏掉
// 真实新错误(同 error 不同 dir 也算新错误,立即重发)。 // 真实新错误(同 error 不同 dir 也算新错误,立即重发)。
const ERROR_REPEAT_THROTTLE_MS = 30_000; const ERROR_REPEAT_THROTTLE_MS = 30_000;
// audit fix (Round 16 M1):self-write filter TTL。file:write / file:create 成功后
// 会在 expectedWrites 里登记这个文件未来 ~500ms 内的变更属于自己;dirWatcher
// callback 看到这个 key 就跳过 emit。500ms 比 SMB / OneDrive rename+fsync
// 最坏值(实测 100~400ms)再大一档,留余量。500ms 之后外部真改的概率已经
// 上来了,不再抑制 —— 不会漏真信号,只会避免「renderer 把自己的写盘当
// 外部修改」的幽灵 reload 对话框。
const SELF_WRITE_TTL_MS = 500;
/** /**
* @typedef {Object} FsWatcherDeps * @typedef {Object} FsWatcherDeps
@@ -84,12 +94,91 @@ function createFsWatcher(deps) {
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。 // lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
// pollInFlight 守卫:上一轮未返回时直接跳过本次。 // pollInFlight 守卫:上一轮未返回时直接跳过本次。
let pollInFlight = false; let pollInFlight = false;
// audit fix (Round 16 M1):self-write filter。file:write / file:create 成功后
// 通过 markExpectedWrite(absPath, mtimeMs) 登记;dirWatcher callback 看到这个
// key 命中 + 未过期 → 跳过 scheduleEmit,避免「renderer 把自己的写盘当外部
// 修改」(慢盘上 atomicWriteFile 的 fs.watch 事件晚于 IPC 响应 → race)。
// 结构:{ mtimeMs, expiresAt };命中检查只看路径不看 mtime —— 同一文件 500ms
// 内自己再写一次也会命中(更新条目即可);漏报概率 1/1.6M(同毫秒 + 同路径
// + 非自己写)基本为零。
const expectedWrites = new Map();
function isWindowVisible() { function isWindowVisible() {
const win = getMainWindow(); const win = getMainWindow();
return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized(); return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
} }
/**
* audit fix (Round 16 M1):登记一次「自己刚写」的路径,500ms 内 dirWatcher
* 看到这个 key 就跳过 scheduleEmit。file:write / file:create 成功后调用。
*
* 路径大小写处理:Windows fs.watch 上报的文件名大小写与写入路径可能不同
* (取决于底层 ReadDirectoryChangesW 是否 normalize)。统一走 path.resolve
* 把 caller 路径标准化,dirWatcher 解析后的 absPath 也走同一份 resolve +
* 同平台大小写策略比对,避免「登记 C:\...\Foo.md、fs.watch 上报 c:\...\foo.md」
* 不命中。
*
* @param {string} absPath - 文件绝对路径
* @param {number} mtimeMs - 写入后磁盘 mtime(ms),仅用于日志 / 调试
*/
function markExpectedWrite(absPath, mtimeMs) {
if (typeof absPath !== 'string' || !absPath) return;
// audit fix (Case-M1):用 pathKey(case-fold) 当 map key,跨 case 命中。
expectedWrites.set(pathKey(absPath), {
mtimeMs: Number.isFinite(mtimeMs) ? mtimeMs : 0,
expiresAt: Date.now() + SELF_WRITE_TTL_MS,
});
}
/**
* 判断一个 absPath 是否在「self-write」窗口内。dirWatcher callback 用。
* 命中时调用方应跳过 scheduleEmit(避免 renderer 误把自写当成外部修改)。
* 顺带做一次惰性 GC —— 路过时把已过期的条目清掉,保持 Map 大小有界。
*
* @param {string} absPath
* @returns {boolean}
*/
function isSelfWrite(absPath) {
if (!absPath || expectedWrites.size === 0) return false;
let key;
try { key = pathKey(absPath); }
catch { return false; }
const entry = expectedWrites.get(key);
if (!entry) return false;
const now = Date.now();
if (now >= entry.expiresAt) {
expectedWrites.delete(key);
return false;
}
return true;
}
/**
* audit fix (Case-M1):Windows / macOS 路径大小写不敏感(NTFS / APFS 默认),
* fs.watch 上报的 absPath 与 caller 路径 case 不一致是常态。`path.resolve` 只
* 做符号链接折叠 + 绝对化,**不**做大小写折叠。`Map.get(resolved)` 严格匹配
* 会让 self-write filter 在 case-mismatch 时漏命中 → renderer 把自己的写盘当
* 「外部修改」误报,用户选「用磁盘版」会覆盖刚保存的内容。
*
* 修:用 platform-aware 的 key —— Windows / darwin 统一 toLowerCase(),
* Linux(ext4 等默认大小写敏感)保持严格。这样 caller + watcher 路径走同一份
* 归一化,跨 case 命中。
*
* @param {string} p
* @returns {string}
*/
function pathKey(p) {
const resolved = path.resolve(p);
return (process.platform === 'win32' || process.platform === 'darwin')
? resolved.toLowerCase()
: resolved;
}
/** 清空 self-write 窗口(stopWatchingDir / 切目录时调一次)。 */
function clearExpectedWrites() {
expectedWrites.clear();
}
/** /**
* 条目列表的稳定指纹:folder 用 name;file 用 name + size + mtime。 * 条目列表的稳定指纹:folder 用 name;file 用 name + size + mtime。
* 只在这一个地方定义,两个调用点(updateSnapshot / emitFilesChanged)共用。 * 只在这一个地方定义,两个调用点(updateSnapshot / emitFilesChanged)共用。
@@ -194,7 +283,10 @@ function createFsWatcher(deps) {
safeSend({ safeSend({
error: result.error, error: result.error,
code: result.code, code: result.code,
dir, // Round 13 fix (M12):移除绝对路径字段。renderer 不消费它,
// 但 IPC 通道透传任意大字段增加无谓的序列化成本 + 通过
// DevTools / 抓包能拿到数据目录绝对路径(含用户名 / OneDrive
// 用户名等)。renderer 用 relDir 就能唯一定位。
relDir, relDir,
}); });
} }
@@ -204,13 +296,18 @@ function createFsWatcher(deps) {
// 恢复 ok → 清错误闩锁 // 恢复 ok → 清错误闩锁
clearErrorLatch(); clearErrorLatch();
const list = result.entries || []; const list = result.entries || [];
const snapshot = entriesSnapshot(list); // baseline 写入 + push 决策一起交给 emitFilesChanged,由 skipInitialEmit 控制。
if (snapshot !== lastFilesSnapshot) { //
lastFilesSnapshot = snapshot; // 历史 bug(Round 13 fix fce2d83 引发的回归):
if (!skipInitialEmit) { // 旧实现这里先写 baseline、再调 emitFilesChanged(list, dir)。emitFilesChanged
emitFilesChanged(list, dir); // 内部的 snapshot 比对看到 baseline 已相同、if 块不进、safeSend 跳过 —— 2s
} // 轮询(OneDrive / SMB 等 fs.watch 不可靠环境的唯一兜底)检测到的文件变化
} // 永远不通知 renderer,侧栏永久 stale。fs.watch 路径仍能通过 scheduleEmit
// 兜底,但在纯靠轮询的环境侧栏刷新完全失效。
// 修:让 emitFilesChanged 拥有 baseline + 推送决策,参数透传 skipInitialEmit。
// skipInitialEmit=true(启动时) → emitFilesChanged 写基线但不 push;
// skipInitialEmit=false(轮询) → 写基线 + push 到 renderer。
emitFilesChanged(list, dir, { skipInitialEmit });
} }
/** /**
@@ -231,8 +328,9 @@ function createFsWatcher(deps) {
* `relDir`(POSIX 相对路径),与 file:scan-dir handler 的返回形状对齐, * `relDir`(POSIX 相对路径),与 file:scan-dir handler 的返回形状对齐,
* 方便 renderer 兜底逻辑无需依赖 IPC 调用上下文。 * 方便 renderer 兜底逻辑无需依赖 IPC 调用上下文。
*/ */
async function emitFilesChanged(precomputed, dir) { async function emitFilesChanged(precomputed, dir, opts = {}) {
const targetDir = dir || watchedDir || resolveDataDir(); const targetDir = dir || watchedDir || resolveDataDir();
const skipInitialEmit = opts && opts.skipInitialEmit === true;
// audit fix (shared-M9):同样的 generation 防护。emitFilesChanged 自带 // audit fix (shared-M9):同样的 generation 防护。emitFilesChanged 自带
// scanDir 分支时同样有「scan 期间用户切目录 → 旧结果覆盖新基线」的竞态。 // scanDir 分支时同样有「scan 期间用户切目录 → 旧结果覆盖新基线」的竞态。
const myGen = dirGeneration; const myGen = dirGeneration;
@@ -261,19 +359,45 @@ function createFsWatcher(deps) {
safeSend({ safeSend({
error: result.error, error: result.error,
code: result.code, code: result.code,
dir: targetDir, // Round 13 fix (M12):移除绝对路径字段。renderer 不消费它,
// 但 IPC 通道透传任意大字段增加无谓的序列化成本 + 通过
// DevTools / 抓包能拿到数据目录绝对路径(含用户名 / OneDrive
// 用户名等)。renderer 用 relDir 就能唯一定位。
relDir, relDir,
}); });
} }
return; return;
} }
list = result.entries || []; list = result.entries || [];
// 同步基线,避免轮询把这次改动再报一遍
lastFilesSnapshot = entriesSnapshot(list);
} }
// Round 13 fix (M13):snapshot 写入必须在 generation 检查通过之后。
// 旧代码把 `lastFilesSnapshot = entriesSnapshot(list)` 写在 gen 检查
// 之前的 try 块里 —— 竞态场景:
// t=0 watcher A 开始 scanDir(dir1) → 拿 myGen=1
// t=1 用户切目录 → dirGeneration 推进到 2,watcher B 开始 scanDir(dir2)
// t=2 watcher A 的 scanDir 完成 → 写 lastFilesSnapshot = A 的列表
// t=3 watcher A 检查 gen → 不等 → 直接 return(不写 payload)
// t=4 watcher B 的 scanDir 完成 → 比较 lastFilesSnapshot(已是 A 的列表,
// 跟 B 的列表很可能不一样)→ 推送 B 的列表 ✓
// t=5 但是 lastFilesSnapshot 没被 B 写(push 路径没写基线)→ 下次 2s
// 轮询拿 B 的列表 → entriesSnapshot !== lastFilesSnapshot(A 的)→
// 又发一次 payload。
// 现在把 snapshot 写入移到 gen 检查通过后、push 前,让旧 watcher 直接
// return 时不留脏基线。
if (myGen !== dirGeneration) return; if (myGen !== dirGeneration) return;
// 成功路径同样补 dir 字段(FS-F11)。 if (list) {
safeSend({ entries: list, dir: targetDir, relDir: relOf(targetDir) }); const snapshot = entriesSnapshot(list);
// skipInitialEmit 用于启动时(updateSnapshot(dir, true))—— 静默同步基线
// 不向 renderer 推送,避免「刚加载就 emitFilesChanged 又被 renderer 当
// fs:changed 事件 → 触发 scanDir 重复请求」循环。
if (snapshot !== lastFilesSnapshot) {
lastFilesSnapshot = snapshot;
if (!skipInitialEmit) {
// 成功路径同样补 relDir 字段(FS-F11);移除绝对 dir 字段(M12)。
safeSend({ entries: list, relDir: relOf(targetDir) });
}
}
}
} }
function scheduleEmit() { function scheduleEmit() {
@@ -394,6 +518,25 @@ function createFsWatcher(deps) {
scheduleEmit(); scheduleEmit();
return; return;
} }
// audit fix (Round 16 M1):self-write filter —— file:write / file:create
// 自己刚写过的文件 500ms 内变更视为 self-write,不 emit 给 renderer。
// 慢盘(SMB / OneDrive)下 atomicWriteFile 触发 fs.watch 事件的时刻
// 可能早于 IPC 'file:write' 的 ok 响应回到 renderer,renderer 还没
// 更新 state.lastWriteMtimeMs 就收到 files:changed → 误判外部修改 →
// 弹「文件在外部被修改」对话框。dirWatcher 这层拦掉。
// 注意 Windows 上 filename 可能是绝对路径(含 watchedDir 前缀),
// Linux 是相对路径;统一 path.resolve(watchedDir, filename) 兜底。
if (watchedDir) {
let absPath;
try {
absPath = path.isAbsolute(filename) ? filename : path.join(watchedDir, filename);
} catch {
absPath = null;
}
if (absPath && isSelfWrite(absPath)) {
return;
}
}
// Windows 上可能触发多次事件,加 300ms 防抖合并 // Windows 上可能触发多次事件,加 300ms 防抖合并
scheduleEmit(); scheduleEmit();
}); });
@@ -471,6 +614,9 @@ function createFsWatcher(deps) {
try { dirWatcher.close(); } catch {} try { dirWatcher.close(); } catch {}
dirWatcher = null; dirWatcher = null;
} }
// audit fix (Round 16 M1):切目录 / 停监听时清掉 self-write 窗口 —— 旧
// 目录的 500ms 窗口不应泄漏到新目录(极小概率,但语义更干净)。
clearExpectedWrites();
stopDirPoll(); stopDirPoll();
// audit fix (Round 7 FS-F5):停止监听也重置退避计数,避免下次启动 // audit fix (Round 7 FS-F5):停止监听也重置退避计数,避免下次启动
// 接着用上一会话的指数退避值。 // 接着用上一会话的指数退避值。
@@ -487,6 +633,23 @@ function createFsWatcher(deps) {
pauseDirPolling, pauseDirPolling,
resumeDirPolling, resumeDirPolling,
stopWatchingDir, stopWatchingDir,
// audit fix (Round 16 M1):file:write / file:create 成功后调用,
// 告诉 fsWatcher 「这个文件接下来 500ms 内的 fs.watch 事件是我自己的,
// 别当外部修改推给 renderer」。
markExpectedWrite,
// 内部供 dirWatcher callback 调用以判断是否需要跳过 scheduleEmit;
// 也暴露给单测(tests/unit/fs-watcher.test.js)作为回归锚点,
// 避免后续重构把 case-M1 修复回退掉。生产路径只在内部用。
isSelfWrite,
/** 切目录 / stop 监听时清空 self-write 窗口 —— 测试也通过它做硬复位 */
clearExpectedWrites,
/**
* 测试钩子:暴露 updateSnapshot 用于单测绕过 fs.watch / 轮询触发 polling
* 路径,验证 push 决策(Round 13 fix fce2d83 修复的回归测试用)。
* 生产路径只通过 startWatchingDir / resumeDirPolling / 2s 轮询定时器调用,
* 故意没暴露给 renderer 直接调用 —— 上层不应该知道这个内部步骤。
*/
_test_updateSnapshot: updateSnapshot,
/** 测试 / 调试用:当前是否在监听某个目录 */ /** 测试 / 调试用:当前是否在监听某个目录 */
get watchedDir() { return watchedDir; }, get watchedDir() { return watchedDir; },
}; };

View File

@@ -86,15 +86,29 @@ const { friendlyFsError: sharedFriendlyFsError } = require('./shared/friendly-fs
// //
// renderer 端 subscribeIpc 仍然有 pagehide 主清理路径,这里只是 pagehide // renderer 端 subscribeIpc 仍然有 pagehide 主清理路径,这里只是 pagehide
// 顺序漂移时的兜底。 // 顺序漂移时的兜底。
//
// audit fix (Phase 2 review cleanupOn):每个 cleanupOn 调用原本各挂一个
// beforeunload listener —— onXxx 订阅 5 个就是 5 个 listener。生产路径
// subscribeIpc 只跑一次不累积,但 dev / HMR 每次 reload 都会再注册 5 个。
// 改成单一 beforeunload 监听 + 一个 Set 收集所有 unsub,listener 数与
// on* 数量解耦(HMR / devtools-reload 跑多少次都是 1 个 beforeunload 监听)。
const beforeUnloadUnsubs = new Set();
try {
window.addEventListener('beforeunload', () => {
for (const u of beforeUnloadUnsubs) {
try { u(); } catch { /* ignore */ }
}
beforeUnloadUnsubs.clear();
}, { once: true });
} catch { /* ignore */ }
function cleanupOn(channel, handler) { function cleanupOn(channel, handler) {
const beforeUnload = () => { const unsub = () => {
try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ } try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ }
try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ }
}; };
try { window.addEventListener('beforeunload', beforeUnload, { once: true }); } catch { /* ignore */ } beforeUnloadUnsubs.add(unsub);
return () => { return () => {
try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ } beforeUnloadUnsubs.delete(unsub);
try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ } try { unsub(); } catch { /* ignore */ }
}; };
} }
@@ -161,6 +175,8 @@ contextBridge.exposeInMainWorld('api', {
settingsSchema: { settingsSchema: {
DEFAULT_SETTINGS: settingsSchema.DEFAULT_SETTINGS, DEFAULT_SETTINGS: settingsSchema.DEFAULT_SETTINGS,
SETTINGS_UI_OPTIONS: settingsSchema.SETTINGS_UI_OPTIONS, SETTINGS_UI_OPTIONS: settingsSchema.SETTINGS_UI_OPTIONS,
// 抽常量让 renderer 也能用同一份事实源(bootstrap fallback / UI 默认值)。
AUTO_SAVE_DEFAULT_DEBOUNCE_MS: settingsSchema.AUTO_SAVE_DEFAULT_DEBOUNCE_MS,
}, },
/** /**

View File

@@ -37,6 +37,16 @@ const AI_ERROR = Object.freeze({
IPC_FAILED: 'IPC_FAILED', IPC_FAILED: 'IPC_FAILED',
GENERATION_STALE: 'GENERATION_STALE', GENERATION_STALE: 'GENERATION_STALE',
FILE_CHANGED: 'FILE_CHANGED', FILE_CHANGED: 'FILE_CHANGED',
// ---- 输入校验(main 在调用远端前自检)----
// Round 13 fix (H3):原裸字面量 'INVALID_PROMPT' / 'INVALID_CONTENT' /
// 'CONTENT_TOO_LARGE' / 'PROMPT_TOO_LARGE' 在 main/ai.js 直接 return,
// 绕开共享 AI_ERROR,renderer 端只能走通用 toast。
// 集中到这里后 preload 一次性通过 contextBridge 暴露。
INVALID_PROMPT: 'AI_INVALID_PROMPT',
INVALID_CONTENT: 'AI_INVALID_CONTENT',
CONTENT_TOO_LARGE: 'AI_CONTENT_TOO_LARGE',
PROMPT_TOO_LARGE: 'AI_PROMPT_TOO_LARGE',
}); });
module.exports = { AI_ERROR }; module.exports = { AI_ERROR };

View File

@@ -69,6 +69,12 @@ function friendlyFsError(code, fallback) {
// 目录」要 main 端自己抛业务码,不该让 errno mapping 揣测)。 // 目录」要 main 端自己抛业务码,不该让 errno mapping 揣测)。
case 'ENOENT': case 'ENOENT':
return '文件或目录不存在(可能已被移动、重命名或删除)'; return '文件或目录不存在(可能已被移动、重命名或删除)';
// audit fix (Round 17 H2):FS_TIMEOUT 不是 Node fs 内置 errno,是 main
// withTimeout() 包装层超时自造的 code。30s 内 fs 操作没完成(SMB 挂死 /
// 杀毒持锁 / 用户挂起后唤醒)→ 文案明确告诉用户是「磁盘暂时不可用」
// 而不是含糊的「未知错误」。
case 'FS_TIMEOUT':
return '磁盘暂时不可用(操作超时),请稍后重试或检查网络 / 移动硬盘连接';
default: default:
return fallback || '未知错误'; return fallback || '未知错误';
} }

View File

@@ -103,6 +103,11 @@ const DANGEROUS_STYLE_PATTERNS = [
/-moz-binding\s*:/i, /-moz-binding\s*:/i,
// CSS @import(外链资源加载 / CSP 绕过) // CSS @import(外链资源加载 / CSP 绕过)
/@import/i, /@import/i,
// Round 13 fix (M1):「@font-face { src: url(...) }」可远程加载任意字体。
// 浏览器加载字体时会把当前页面的 UA / Referer / 来源 URL 一并发出,
// 形成 IP 泄露 / 指纹面;恶意字体还能触发已知 CVE(如 TrueType 解析器
// 历史 bug)。Markdown 不需要自定义字体,命中则整条 style 属性剥掉。
/@font-face/i,
]; ];
// audit fix (Round 13 / Sec-M):CSS 属性 denylist —— 阻止攻击者把整窗当画布。 // audit fix (Round 13 / Sec-M):CSS 属性 denylist —— 阻止攻击者把整窗当画布。

View File

@@ -27,6 +27,18 @@
'use strict'; 'use strict';
/**
* 自动保存默认防抖延迟(ms)。停打后多久落盘。
* 同时也是旧版 autoSaveIntervalSec (3s / 10s) 的迁移目标 —— 语义从「轮询」变
* 「防抖」,所有「开启」档位统一映射到这个值,避免老用户升级后行为突变。
* 在 3 处使用:
* - DEFAULT_SETTINGS.autoSaveDebounceMs(首次安装的默认值)
* - coerceLoadedSettings() 迁移旧 autoSaveIntervalSec → autoSaveDebounceMs
* - renderer src/app.js bootstrap fallback(持久化值缺失时)
* 抽出来是为了「改默认值只改一处」,三处必须保持一致。
*/
const AUTO_SAVE_DEFAULT_DEBOUNCE_MS = 500;
/** /**
* 默认值(单一事实源)。所有键都在这里定义,缺一不可。 * 默认值(单一事实源)。所有键都在这里定义,缺一不可。
* @type {Readonly<Record<string, *>>} * @type {Readonly<Record<string, *>>}
@@ -40,7 +52,7 @@ const DEFAULT_SETTINGS = Object.freeze({
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' | 'mtime-desc'
autoSaveDebounceMs: 500, // 自动保存防抖延迟(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
aiWidth: null, // AI 中间面板拖拽后的宽度(px);null = 使用 CSS 默认 --w-ai aiWidth: null, // AI 中间面板拖拽后的宽度(px);null = 使用 CSS 默认 --w-ai
@@ -211,9 +223,24 @@ const SETTINGS_UI_OPTIONS = Object.freeze({
readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]), readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]),
// 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商; // 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商;
// 用户可填任意走该协议的 baseURL(中转、自部署、官方 API 都行)。 // 用户可填任意走该协议的 baseURL(中转、自部署、官方 API 都行)。
//
// defaults:当用户切到对应 provider 但 aiBaseUrl / aiModel 为空时,UI 用
// 这里给的提示值作为 placeholder(不是写盘默认值 —— 用户不主动保存就
// 不会落盘,避免「换 provider 就把 baseURL 改了」的侵入感)。原来散在
// src/settings-dialog.js 顶部的 AI_DEFAULTS,迁到这里与 schema 同源。
aiProvider: Object.freeze([ aiProvider: Object.freeze([
{ value: 'openai', label: 'OpenAI 兼容', hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等' }, {
{ value: 'anthropic', label: 'Anthropic 兼容', hint: '/v1/messages · Claude 系列 · 含第三方中转' }, value: 'openai',
label: 'OpenAI 兼容',
hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等',
defaults: Object.freeze({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }),
},
{
value: 'anthropic',
label: 'Anthropic 兼容',
hint: '/v1/messages · Claude 系列 · 含第三方中转',
defaults: Object.freeze({ baseUrl: 'https://api.anthropic.com', model: 'claude-opus-5' }),
},
]), ]),
}); });
@@ -298,6 +325,13 @@ async function validateKey(key, raw, opts = {}) {
if (raw === null || raw === undefined) { if (raw === null || raw === undefined) {
return { ok: false, error: `${key} 必须是数字` }; return { ok: false, error: `${key} 必须是数字` };
} }
// 拒绝 Boolean —— Number(true) === 1, Number(false) === 0;旧异步路径
// 漏 typeof 守卫,autoSaveDebounceMs: true 会静默变 1ms(每个 keystroke
// 都保存);splitRatio: false 会变 0(无预览面板)。同步路径 sanitizeSync
// 早就修过(Round 4 fix),这里镜像对称。
if (typeof raw === 'boolean') {
return { ok: false, error: `${key} 必须是数字` };
}
if (typeof raw === 'string' && raw.trim() === '') { if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字` }; return { ok: false, error: `${key} 必须是数字` };
} }
@@ -329,13 +363,27 @@ async function validateKey(key, raw, opts = {}) {
// 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。 // 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。
// 现在 undefined 走「视为 null」分支。 // 现在 undefined 走「视为 null」分支。
if (raw === null || raw === undefined) return { ok: true, value: null }; if (raw === null || raw === undefined) return { ok: true, value: null };
// 拒绝 Boolean —— 与 'number' 分支对称(Round 13 fix)。
if (typeof raw === 'boolean') {
return { ok: false, error: `${key} 必须是数字或 null` };
}
if (typeof raw === 'string' && raw.trim() === '') { if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字或 null` }; return { ok: false, error: `${key} 必须是数字或 null` };
} }
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` }; if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` };
let v = n; let v = n;
// Round 13 fix:与同步 sanitizeSync 对称支持 clamp / round —— 否则
// 异步路径会拒绝「拖动侧栏到 500」(超 max 480),同步路径静默 clamp;
// 同一字段 update() reject / load() accept,UI 行为漂移。
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) { if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` }; return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
} }
@@ -393,13 +441,13 @@ function coerceLoadedSettings(raw) {
// 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖) // 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖)
// 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位 // 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位
// (旧 3 秒、10 秒)一律映射到新的 500ms 默认值,避免老用户升级后自动保存 // (旧 3 秒、10 秒)一律映射到新的 AUTO_SAVE_DEFAULT_DEBOUNCE_MS,避免老用户
// 突然变得非常激进(1s 内反复触发)或太迟(10s 才一次)。 // 升级后自动保存突然变得非常激进(1s 内反复触发)或太迟(10s 才一次)。
if ('autoSaveIntervalSec' in raw) { if ('autoSaveIntervalSec' in raw) {
const old = raw.autoSaveIntervalSec; const old = raw.autoSaveIntervalSec;
if (!('autoSaveDebounceMs' in raw)) { if (!('autoSaveDebounceMs' in raw)) {
const offish = old === 0 || old === '0' || old === false; const offish = old === 0 || old === '0' || old === false;
raw.autoSaveDebounceMs = offish ? 0 : 500; raw.autoSaveDebounceMs = offish ? 0 : AUTO_SAVE_DEFAULT_DEBOUNCE_MS;
} }
delete raw.autoSaveIntervalSec; delete raw.autoSaveIntervalSec;
} }
@@ -560,6 +608,7 @@ module.exports = {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
SETTINGS_SCHEMA, SETTINGS_SCHEMA,
SETTINGS_UI_OPTIONS, SETTINGS_UI_OPTIONS,
AUTO_SAVE_DEFAULT_DEBOUNCE_MS,
validateKey, validateKey,
validateAndSanitize, validateAndSanitize,
coerceLoadedSettings, coerceLoadedSettings,

View File

@@ -158,6 +158,14 @@ export class AiChatPanel {
this._currentRequestId = null; this._currentRequestId = null;
this._syncSubmitButton(); this._syncSubmitButton();
} }
// 优化 (Round 14):close 也清 stage 定时器。hung 请求场景下
// _triggerSubmit 的 finally 永远不会跑,stageTimer 仍在 fire 调
// this._notify,会用 stages[stageIdx].text 覆盖 close() 刚刚写出的
// '' / idle chip —— 用户看到关 dock 后 chip 还在升级文案,体验割裂。
if (this._stageTimer) {
clearInterval(this._stageTimer);
this._stageTimer = null;
}
// 关掉 dock 时清掉状态栏 chip —— 否则 chip 会留下上次的"已应用"或"AI 请求失败" // 关掉 dock 时清掉状态栏 chip —— 否则 chip 会留下上次的"已应用"或"AI 请求失败"
// 残留,让用户误以为是新请求的结果 // 残留,让用户误以为是新请求的结果
this._notify('', 'idle'); this._notify('', 'idle');
@@ -194,6 +202,13 @@ export class AiChatPanel {
this._canceling = false; this._canceling = false;
this._currentRequestId = null; this._currentRequestId = null;
if (this._cancelled) this._cancelled.clear(); if (this._cancelled) this._cancelled.clear();
// 优化 (Round 14):reset 也清 stage 定时器 —— controller 在
// onFileChanged / closeAll 调 reset,旧 panel 残留的定时器 fire 时
// 会调过时的 this._notify 覆盖新 panel 已经写出的 chip 文案。
if (this._stageTimer) {
clearInterval(this._stageTimer);
this._stageTimer = null;
}
const input = this.element.querySelector('.ai-chat-input'); const input = this.element.querySelector('.ai-chat-input');
if (input) { if (input) {
input.disabled = !this._enabled; input.disabled = !this._enabled;
@@ -312,6 +327,34 @@ export class AiChatPanel {
this._syncSubmitButton(); this._syncSubmitButton();
if (input) input.disabled = true; if (input) input.disabled = true;
// 优化 (Round 14):AI 请求飞行期 chip 文案分阶段升级。
// 主进程默认超时 300s,普通网络往返 2~5s 体感就让人焦虑;服务慢时用户
// 只能盯着一个静态的「正在发送请求…」 猜后台在干嘛。按 elapsed 时间
// 推进 4 个 stage,逐步把"等待"语义升级成"正在处理 / 服务较慢 / 非常慢",
// 配合 running 脉冲(CSS)持续闪烁给视觉反馈,让用户感觉系统在干活。
// 单 setInterval 每 2s 推进一次,避免起 4 个 setTimeout 各自清理。
// 全部 stage ≤ 60 字(app.js#updateStatusAi 截断保护),保证状态栏窄时不撑爆。
const startTs = Date.now();
const stages = [
{ afterMs: 0, text: '正在发送请求…' },
{ afterMs: 8000, text: 'AI 正在处理…' },
{ afterMs: 25_000, text: 'AI 响应较慢,请耐心等待' },
{ afterMs: 60_000, text: 'AI 响应非常慢,可点取消重试' },
];
let stageIdx = 0;
const stageTimer = setInterval(() => {
if (this._currentRequestId !== requestId) {
clearInterval(stageTimer);
return;
}
const elapsed = Date.now() - startTs;
while (stageIdx < stages.length - 1 && elapsed >= stages[stageIdx + 1].afterMs) {
stageIdx += 1;
}
this._notify(stages[stageIdx].text, 'running');
}, 2000);
this._stageTimer = stageTimer;
try { try {
const result = await this.onSubmit({ prompt, requestId }); const result = await this.onSubmit({ prompt, requestId });
// 已被新请求顶掉(用户点了"取消"或再次提交):静默 // 已被新请求顶掉(用户点了"取消"或再次提交):静默
@@ -341,6 +384,8 @@ export class AiChatPanel {
console.error('[ai-chat-panel] onSubmit threw:', e); console.error('[ai-chat-panel] onSubmit threw:', e);
this._notify('AI 修改失败,请稍后重试', 'error'); this._notify('AI 修改失败,请稍后重试', 'error');
} finally { } finally {
clearInterval(stageTimer);
if (this._stageTimer === stageTimer) this._stageTimer = null;
if (this._currentRequestId === requestId) { if (this._currentRequestId === requestId) {
this._submitting = false; this._submitting = false;
this._canceling = false; this._canceling = false;
@@ -449,5 +494,11 @@ export class AiChatPanel {
this._canceling = false; this._canceling = false;
this._currentRequestId = null; this._currentRequestId = null;
this._cancelled = null; this._cancelled = null;
// 优化 (Round 14):清掉飞行期 stage 定时器,避免 destroy 后定时器
// 还在 fire 调过时的 this._notify(onNotify 闭包可能指向旧 UI 入口)。
if (this._stageTimer) {
clearInterval(this._stageTimer);
this._stageTimer = null;
}
} }
} }

View File

@@ -189,16 +189,25 @@ export function createAiController({
* 点「应用」,会把 A 的 diff region 算进 B 的内容里(可能命中 / 可能不命中 → * 点「应用」,会把 A 的 diff region 算进 B 的内容里(可能命中 / 可能不命中 →
* 即使命中也是把 A 的修改写到 B,数据丢失)。 * 即使命中也是把 A 的修改写到 B,数据丢失)。
* 现在所有 setPendingDiff 都带上 myFile,apply 前再核对一次。 * 现在所有 setPendingDiff 都带上 myFile,apply 前再核对一次。
* @returns {{ ok: true } | { ok: false, reason: string }} *
* audit fix:返回字段从 `reason` 改名为 `message`。原因:app.js save flow
* 里 `reason` 是「state code」(STALE / NO_CHANGES / DELEGATED 等用于
* decideSaveToast 分支的短串),而这里 `reason` 实际存的是人类可读
* 字符串 —— 同名不同语义,调用方读 `check.reason` 还得猜它是 code 还是
* message。改成 `message` 后与 main IPC 错误形状 `{ok:false, error, message}`
* 对齐,调用方一眼就知道是直接给 toast 的文本(这里 `error` 不存在 —— AI
* 流这几个 state 没有跨进程错误码概念,只有给用户看的中文)。
*
* @returns {{ ok: true } | { ok: false, message: string }}
*/ */
function ensurePendingDiffMatchesCurrentFile() { function ensurePendingDiffMatchesCurrentFile() {
if (!pendingDiff) return { ok: false, reason: '没有待应用的 AI 修改' }; if (!pendingDiff) return { ok: false, message: '没有待应用的 AI 修改' };
const expected = pendingDiff.forFilePath; const expected = pendingDiff.forFilePath;
const current = state.currentFile && state.currentFile.path; const current = state.currentFile && state.currentFile.path;
if (expected == null || current == null || expected !== current) { if (expected == null || current == null || expected !== current) {
return { return {
ok: false, ok: false,
reason: '当前文件已切换,原 AI 修改已失效(请重新提交)', message: '当前文件已切换,原 AI 修改已失效(请重新提交)',
}; };
} }
return { ok: true }; return { ok: true };
@@ -226,6 +235,28 @@ export function createAiController({
let diffPanel = null; let diffPanel = null;
function bindMountPoint(chatElement, diffElement, splitterElement = null) { function bindMountPoint(chatElement, diffElement, splitterElement = null) {
// Round 13 fix (H9):bind 重入前先让 in-flight 状态整体失效 —— 旧实现
// 只 destroy 旧 panel,没 ++requestGeneration / 清 currentRequestId /
// 清 pendingDiff,老 handleSubmit await 后还会写「新 panel」的
// pendingDiff(闭包捕获 panel 变量引用)。现在递增 generation 让旧
// in-flight 自然走 stale 分支丢弃;同时清掉 pendingDiff 避免新 panel
// 一上来就显示老 diff。
requestGeneration++;
if (currentRequestId && chatPanel && typeof chatPanel.cancelRequest === 'function') {
try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ }
}
currentRequestId = null;
pendingDiff = null;
// audit fix (2026-09-21):重置 diffOpenState 让 syncBodyFlag 在新面板上能正确
// 触发 onDiffOpenChange(true)。之前只调 onDiffOpenChange(false) 通知外部,
// 但闭包变量 diffOpenState 没复位 —— 后续 setPendingDiff(non-null) → syncBodyFlag
// 比较 `has !== diffOpenState`,若上次是 true → 永远 true,下一次打开面板不再回调。
// 生产路径只 bind 一次不触发;HMR / 未来 runtime remount 才会暴露。
diffOpenState = false;
if (typeof onDiffOpenChange === 'function') {
try { onDiffOpenChange(false); } catch { /* ignore */ }
}
// 3rd-agent fix:重复 bind 要先销毁旧实例,否则旧 listener 残留、 // 3rd-agent fix:重复 bind 要先销毁旧实例,否则旧 listener 残留、
// 旧 _submitting 状态无法被新 controller 感知。 // 旧 _submitting 状态无法被新 controller 感知。
if (chatPanel && typeof chatPanel.destroy === 'function') { if (chatPanel && typeof chatPanel.destroy === 'function') {
@@ -426,7 +457,7 @@ export function createAiController({
// 那 pendingDiff 仍存在但属于 A。下面这层 forFilePath 校验兜底。 // 那 pendingDiff 仍存在但属于 A。下面这层 forFilePath 校验兜底。
const check = ensurePendingDiffMatchesCurrentFile(); const check = ensurePendingDiffMatchesCurrentFile();
if (!check.ok) { if (!check.ok) {
if (typeof toast === 'function') toast(check.reason, 'error', 3000); if (typeof toast === 'function') toast(check.message, 'error', 3000);
setPendingDiff(null); setPendingDiff(null);
return; return;
} }
@@ -559,7 +590,7 @@ export function createAiController({
// audit H163:与 handleApplyRegion 同款校验,挡住「跨文件覆盖」数据丢失。 // audit H163:与 handleApplyRegion 同款校验,挡住「跨文件覆盖」数据丢失。
const check = ensurePendingDiffMatchesCurrentFile(); const check = ensurePendingDiffMatchesCurrentFile();
if (!check.ok) { if (!check.ok) {
if (typeof toast === 'function') toast(check.reason, 'error', 3000); if (typeof toast === 'function') toast(check.message, 'error', 3000);
setPendingDiff(null); setPendingDiff(null);
return; return;
} }
@@ -649,7 +680,7 @@ export function createAiController({
// 与 handleApply* 一致:先确认 pendingDiff 还属于当前文件 // 与 handleApply* 一致:先确认 pendingDiff 还属于当前文件
const guard = ensurePendingDiffMatchesCurrentFile(); const guard = ensurePendingDiffMatchesCurrentFile();
if (!guard.ok) { if (!guard.ok) {
if (typeof toast === 'function') toast(guard.reason, 'warning', 2500); if (typeof toast === 'function') toast(guard.message, 'warning', 2500);
setPendingDiff(null); setPendingDiff(null);
return; return;
} }
@@ -822,9 +853,15 @@ export function createAiController({
/** /**
* 编辑器内容变化时:重算 diff(让用户看到"已应用部分还剩多少") * 编辑器内容变化时:重算 diff(让用户看到"已应用部分还剩多少")
* P1-3 fix (audit):100ms 防抖 —— 连续 keystroke 只跑一次 DP * P1-3 fix (audit):防抖 —— 连续 keystroke 只跑一次 DP
* (pickDiffOptions 把 tokenDiff 限制在 ≤80k / ≤120k 行乘积,但每个 * (pickDiffOptions 把 tokenDiff 限制在 ≤80k / ≤120k 行乘积,但每个
* keystroke 仍触发同步 DP 会卡顿)。 * keystroke 仍触发同步 DP 会卡顿)。
*
* 优化 (Round 14):100ms → 300ms —— 用户连续打字时
* "每个 keystroke 都触发 DP + 全量 innerHTML 重渲染" 是可见卡顿。
* 300ms 让用户停手后才跑,体感上不卡;setDiff 内部已经把重渲染延后到
* 下一帧(详见 ai-diff-panel.js#scheduleRender),这里只需保证
* 「最后一次按键 → 一次重算 + 重渲染」即可。
*/ */
function onContentChanged() { function onContentChanged() {
if (!pendingDiff) return; if (!pendingDiff) return;
@@ -832,7 +869,7 @@ export function createAiController({
recomputeTimer = setTimeout(() => { recomputeTimer = setTimeout(() => {
recomputeTimer = null; recomputeTimer = null;
recomputeDiffFromBase(); recomputeDiffFromBase();
}, 100); }, 300);
} }
/** /**

View File

@@ -99,10 +99,34 @@ export class AiDiffPanel {
/** /**
* 渲染/更新 diff。diff = null → 显示空状态。 * 渲染/更新 diff。diff = null → 显示空状态。
*
* 优化 (Round 14):重渲染延后到下一个 requestAnimationFrame + 同帧合并。
* 同一帧内的多次 setDiff 调用只渲染最后一次(user typing 时 onContentChanged
* 防抖后会连续触发多次 setDiff,合并后只跑一次 innerHTML)。null 也走这条
* 路径(关面板有视觉连续性,不在 sync 阶段提前 hide,避免同帧内出现
* 「先显示 → 再隐藏」的闪烁)。
*
* @param {object|null} diff * @param {object|null} diff
*/ */
setDiff(diff) { setDiff(diff) {
this._diff = diff; this._diff = diff;
// 记下本帧待渲染的目标;下一帧渲染时如果又被覆盖就以最新为准
this._pendingDiff = diff;
if (this._renderFrame != null) return;
this._renderFrame = requestAnimationFrame(() => {
this._renderFrame = null;
const target = this._pendingDiff;
this._pendingDiff = undefined;
this._renderNow(target);
});
}
/**
* 同步渲染路径:setDiff 入队后的真正执行体。
* 抽出来让 setDiff 的同帧合并逻辑(rAF + 末值覆盖)单独维护一份。
* @param {object|null} diff
*/
_renderNow(diff) {
// 每次 setDiff 之前先清掉上一轮的 click 监听器,否则重复 setDiff 会泄漏。 // 每次 setDiff 之前先清掉上一轮的 click 监听器,否则重复 setDiff 会泄漏。
if (this._cleanup) this._cleanup(); if (this._cleanup) this._cleanup();
this._cleanup = null; this._cleanup = null;
@@ -264,5 +288,13 @@ export class AiDiffPanel {
destroy() { destroy() {
if (this._cleanup) this._cleanup(); if (this._cleanup) this._cleanup();
this._cleanup = null; this._cleanup = null;
// 取消挂起的 rAF:避免 destroy 后 _renderFrame 回调 fire 时拿过时的
// this.element / onApplyRegion 等闭包渲染到旧 DOM / 调旧回调(用户切文件
// / 重新 bind 时新 panel 已经接管,旧 panel 的 rAF 仍在排队)。
if (this._renderFrame != null) {
cancelAnimationFrame(this._renderFrame);
this._renderFrame = null;
}
this._pendingDiff = undefined;
} }
} }

View File

@@ -47,6 +47,12 @@ const LOCAL_AI_ERROR_FALLBACK = Object.freeze({
ERR_PROVIDER: 'AI_PROVIDER_ERROR', ERR_PROVIDER: 'AI_PROVIDER_ERROR',
ERR_FORMAT: 'AI_BAD_RESPONSE', ERR_FORMAT: 'AI_BAD_RESPONSE',
ERR_CANCELLED: 'AI_CANCELLED', ERR_CANCELLED: 'AI_CANCELLED',
// Round 13 fix (H3):与 shared/ai-errors.js 对齐的本地副本,单测 / preload
// 注入失败时使用。tests/unit/ai-errors.test.js 会校验两边字面量一致。
INVALID_PROMPT: 'AI_INVALID_PROMPT',
INVALID_CONTENT: 'AI_INVALID_CONTENT',
CONTENT_TOO_LARGE: 'AI_CONTENT_TOO_LARGE',
PROMPT_TOO_LARGE: 'AI_PROMPT_TOO_LARGE',
}); });
export const AI_ERROR = SHARED_AI_ERROR || LOCAL_AI_ERROR_FALLBACK; export const AI_ERROR = SHARED_AI_ERROR || LOCAL_AI_ERROR_FALLBACK;
@@ -67,6 +73,10 @@ export function aiErrorMessage(code, fallback) {
case AI_ERROR.ERR_PROVIDER: return 'AI 服务返回异常'; case AI_ERROR.ERR_PROVIDER: return 'AI 服务返回异常';
case AI_ERROR.ERR_FORMAT: return 'AI 响应格式无法解析'; case AI_ERROR.ERR_FORMAT: return 'AI 响应格式无法解析';
case AI_ERROR.ERR_CANCELLED: return '已取消 AI 请求'; case AI_ERROR.ERR_CANCELLED: return '已取消 AI 请求';
case AI_ERROR.INVALID_PROMPT: return '修改要求不能为空';
case AI_ERROR.INVALID_CONTENT: return '当前文件内容无效';
case AI_ERROR.CONTENT_TOO_LARGE: return '文档过大,请拆分后再让 AI 修改';
case AI_ERROR.PROMPT_TOO_LARGE: return '提示词过长,请简化要求';
default: return fallback || 'AI 修改失败'; default: return fallback || 'AI 修改失败';
} }
} }

View File

@@ -13,6 +13,10 @@ import { SettingsDialog } from './settings-dialog.js';
import { showToast } from './feedback.js'; import { showToast } from './feedback.js';
import { decideSaveToast } from './save-toast.js'; import { decideSaveToast } from './save-toast.js';
import { syncDirtyFromEditor as syncDirtyFromEditorPure } from './dirty-sync.js'; import { syncDirtyFromEditor as syncDirtyFromEditorPure } from './dirty-sync.js';
// 救援文件工具:5 处独立复制的「生成救援名 + 写盘 + toast」逻辑
// 集中到一处,命名规则 / 错误文案(特别是 friendlyFsError 传 r.message
// 而非 r.error 业务码)只改一处即可。详见 src/rescue.js 注释。
import { rescueDirtyContent } from './rescue.js';
import { showConfirmDialog } from './confirm-dialog.js'; import { showConfirmDialog } from './confirm-dialog.js';
import { isOpen as modalIsOpen } from './modal-stack.js'; import { isOpen as modalIsOpen } from './modal-stack.js';
import { mountKeyboardShortcuts } from './shortcuts.js'; import { mountKeyboardShortcuts } from './shortcuts.js';
@@ -157,6 +161,7 @@ const els = {
statusMeta: document.getElementById('status-meta'), statusMeta: document.getElementById('status-meta'),
statusCount: document.getElementById('status-count'), statusCount: document.getElementById('status-count'),
statusMode: document.getElementById('status-mode'), statusMode: document.getElementById('status-mode'),
statusModeLabel: document.getElementById('status-mode-label'),
statusStats: document.getElementById('status-stats'), statusStats: document.getElementById('status-stats'),
statusScroll: document.getElementById('status-scroll'), statusScroll: document.getElementById('status-scroll'),
statusHeading: document.getElementById('status-heading'), statusHeading: document.getElementById('status-heading'),
@@ -170,6 +175,9 @@ const els = {
btnTheme: document.getElementById('btn-theme'), btnTheme: document.getElementById('btn-theme'),
btnPin: document.getElementById('btn-pin'), btnPin: document.getElementById('btn-pin'),
btnSave: document.getElementById('btn-save'), btnSave: document.getElementById('btn-save'),
// 关闭当前文件(仅清空视图,磁盘文件不动):放工具栏「操作组」紧邻保存按钮,
// 语义上「保存」与「关闭」是相邻操作。沿用 .btn-icon,disabled 态走现有规则。
btnCloseFile: document.getElementById('btn-close-file'),
btnModePreview: document.getElementById('btn-mode-preview'), btnModePreview: document.getElementById('btn-mode-preview'),
btnModeEdit: document.getElementById('btn-mode-edit'), btnModeEdit: document.getElementById('btn-mode-edit'),
btnModeSplit: document.getElementById('btn-mode-split'), btnModeSplit: document.getElementById('btn-mode-split'),
@@ -310,6 +318,34 @@ function syncSaveButton() {
// 修复:syncSaveButton 内联调一次 updateWindowTitle,让标题/按钮/状态栏三处 // 修复:syncSaveButton 内联调一次 updateWindowTitle,让标题/按钮/状态栏三处
// 反馈始终同步。lastSentDirtyToMain 已在 helper 内部去重 IPC,这里只刷 DOM。 // 反馈始终同步。lastSentDirtyToMain 已在 helper 内部去重 IPC,这里只刷 DOM。
updateWindowTitle(); updateWindowTitle();
// 「关闭当前文件」按钮的 disabled 态跟 state.currentFile 走 —— 把它和
// syncSaveButton 串到同一出口,openFile / clearCurrentFile 不必各自记得再调一次。
// 这条调用非常便宜(几个 DOM 属性),不影响 hot path。
syncCloseFileButton();
}
/**
* 同步「关闭当前文件」按钮的 disabled 态:无文件可关时置灰,避免误导用户。
* 不需要 pressed 态(这是命令式入口,不是 toggle),不需要 IPC 通知。
* 走 .btn-icon 的现有 :disabled 样式(fg-tertiary + opacity:0.4 + cursor not-allowed)。
*
* 调用方:syncSaveButton / openFile / clearCurrentFile 末尾(与 syncSaveButton 复用同一
* 触发点 —— 任何"当前文件"变化都会经由它们之一)。
*/
function syncCloseFileButton() {
if (!els.btnCloseFile) return;
const hasFile = !!state.currentFile;
els.btnCloseFile.disabled = !hasFile;
els.btnCloseFile.setAttribute('aria-disabled', hasFile ? 'false' : 'true');
// title / aria-label 同步:屏幕阅读器对 disabled 按钮仍会读 aria-label;
// 无文件时明确说「无打开文件」,避免读屏用户听到「关闭当前文件」后去按一个禁用的按钮。
if (hasFile) {
els.btnCloseFile.title = '关闭当前文件 (Ctrl+W)';
els.btnCloseFile.setAttribute('aria-label', '关闭当前文件');
} else {
els.btnCloseFile.title = '关闭当前文件(无打开文件)';
els.btnCloseFile.setAttribute('aria-label', '关闭当前文件(无打开文件)');
}
} }
/** /**
@@ -377,6 +413,9 @@ function handleAiDiffOpenChange(isOpen) {
// 强制切到 edit(即使当前已经在 edit 也无副作用 —— applyEditorMode 内部走 _setDOM)。 // 强制切到 edit(即使当前已经在 edit 也无副作用 —— applyEditorMode 内部走 _setDOM)。
// focus: false 避免偷焦点:用户可能在 AI 输入框里编辑 prompt,不应被打断。 // focus: false 避免偷焦点:用户可能在 AI 输入框里编辑 prompt,不应被打断。
applyEditorMode('edit', { focus: false }); applyEditorMode('edit', { focus: false });
// AI 修改预览打开时重置面板宽度 = 可用区 50%
// —— 用户每次打开都默认对半分,drag 比例仅在本次会话内有效
resetAiPanelWidthToHalf();
} else { } else {
// 解除锁;按持久化的 state.editorMode 走正常的 md/non-md 禁用逻辑 // 解除锁;按持久化的 state.editorMode 走正常的 md/non-md 禁用逻辑
// (非 md 文件下 preview / split 仍会被禁用,md 文件恢复可选)。 // (非 md 文件下 preview / split 仍会被禁用,md 文件恢复可选)。
@@ -919,6 +958,59 @@ function clearCurrentFile() {
if (ai) ai.onFileChanged(null); if (ai) ai.onFileChanged(null);
} }
/**
* 关闭当前打开的文件(视图回到 empty-state,但磁盘文件保持不变)。
*
* 与 clearCurrentFile 的区别:本函数处理"用户主动关闭"的脏状态对话框,
* 把「保存 / 放弃 / 取消」三选一决策交给用户。clearCurrentFile 内部已经
* 处理了状态清理(currentFile=null / viewer/editor unload / 高亮清除),
* 所以 closeCurrentFile 只做"是否允许清空"的判断 + 复用 clearCurrentFile。
*
* 不弹「文件已关闭」toast:用户主动点的关闭按钮,给 toast 是噪音
* (与 clearCurrentFile 的"无 toast"约定一致 —— 见 line 887)。
*
* dirty 检查复用 openFile 的"同步 editor.isDirty() → state.isDirty"
* 守门(IME 合成期间 state.isDirty 可能 stale,导致 CJK 用户按 Ctrl+W
* 跳过脏对话框静默丢失 IME 字符)。
*
* 调用方:工具栏「关闭文件」按钮 + Ctrl+W 快捷键 + (未来)文件列表右键。
*/
async function closeCurrentFile() {
if (!state.currentFile) return; // 已经没打开文件 → 静默 no-op
// IME 合成期同步 state.isDirty,与 openFile / save 走同一守门。
syncDirtyFromEditor();
if (state.isDirty) {
const choice = await showConfirmDialog({
title: '未保存的改动',
message: `当前文件「${state.currentFile.name}」有未保存的改动。\n\n关闭前请选择如何处理。`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消' },
{ value: 'discard', label: '放弃改动', variant: 'danger' },
{ value: 'save_and_close', label: '保存并关闭', variant: 'primary' },
],
});
// 用户取消 / 关闭对话框 → 不关文件,原样保留
if (choice === 'cancel' || choice == null) return;
if (choice === 'save_and_close') {
// 走 save() 主路径:拿到失败原因(冲突 / IME / 文件没了),让用户
// 看到完整反馈;save() 失败时不关闭(避免编辑器被清空后又没保存上)
const ok = await save();
if (!ok) return;
} else {
// 'discard':复位脏态 + editor 基线 + 保存按钮(与 openFile 的
// save_and_open/discard 分支同款:避免 auto-save 飞行期拿旧
// lastSavedMtimeMs 误判为「外部修改」、或 syncSaveButton 没刷 UI)
cancelScheduledAutoSave();
state.isDirty = false;
state.lastSavedMtimeMs = null;
editor.markSaved();
syncSaveButton();
}
}
clearCurrentFile();
}
/** /**
* 从磁盘重新读取并替换当前文件的内容(外部修改覆盖本地)。 * 从磁盘重新读取并替换当前文件的内容(外部修改覆盖本地)。
* *
@@ -1065,6 +1157,9 @@ const editorToolbar = new EditorToolbar({
}, },
onChange: (mode) => { onChange: (mode) => {
settingsStore.update({ editorMode: mode }); settingsStore.update({ editorMode: mode });
// 注意:splitRatio=0.5 的重置在 applyEditorMode 内部做(visualMode 闸),
// 这里不需要重复写 —— onChange / bootstrap / reload / AI lock 都会走 applyEditorMode,
// 单点维护。
applyEditorMode(mode); applyEditorMode(mode);
}, },
}); });
@@ -1113,6 +1208,25 @@ function applyEditorMode(mode, opts) {
// 非 md 文件:把视觉模式强制回落到 'edit',但不写 state.editorMode。 // 非 md 文件:把视觉模式强制回落到 'edit',但不写 state.editorMode。
const isMd = state.currentFileIsMarkdown; const isMd = state.currentFileIsMarkdown;
const visualMode = isMd ? mode : 'edit'; const visualMode = isMd ? mode : 'edit';
// 视觉模式从非 split 切到 split 时重置 splitRatio=0.5 —— 用户期待「双栏」= 50/50
// (每次进入双栏都从默认比例起步,drag 只在本次会话内有效)。
// 用 DOM 上的 prevVisualMode(document.body.dataset.editorMode)而不是 state.editorMode:
// - state.editorMode 会在 AI 打开时被覆盖为 'edit'(handleAiDiffOpenChange(true) →
// applyEditorMode('edit')),与用户真正看到的视觉模式可能脱节;
// - DOM 是 CSS 渲染的唯一依据,在 _setDOM 同步写入;这里在 setMode 之前读,正好是
// 「进入前的视觉状态」,符合「看到双栏的瞬间重置」语义。
// 这同时覆盖 md ↔ non-md 重命名场景:把 .md 改 .txt 时视觉退回 edit,再改回 .md 时
// 视觉从 edit 重新进入 split,闸触发重置。
// bootstrap 时 currentFile=null → isMd=false → visualMode='edit',闸不触发(视觉不显示 split);
// 用户打开第一个 md 文件时 applyEditorMode 触发,visualMode='split',DOM 之前是 'edit',
// 闸触发 —— 视觉上用户看到 split 的瞬间就是 50/50。
// settingsStore.update 是乐观写盘(缓存同步更新、IPC 异步),MutationObserver 作为
// microtask 在当前同步块之后才 drain;reset 写在这里是先于 _setDOM,便于阅读。
if (visualMode === 'split' && document.body.dataset.editorMode !== 'split') {
settingsStore.update({ splitRatio: 0.5 });
}
// editorToolbar.setMode 内部已 _setDOM 写 body[data-editor-mode] + 高亮按钮; // editorToolbar.setMode 内部已 _setDOM 写 body[data-editor-mode] + 高亮按钮;
// 不要再单独 dataset,否则两边写同一属性会产生「两次 setAttribute」的 // 不要再单独 dataset,否则两边写同一属性会产生「两次 setAttribute」的
// MutationObserver 噪音,调试时易误以为有别的代码在动 DOM。 // MutationObserver 噪音,调试时易误以为有别的代码在动 DOM。
@@ -1125,8 +1239,13 @@ function applyEditorMode(mode, opts) {
// 切到 edit / split 时聚焦编辑器 —— 但外部 reload 路径不希望被 focus 偷走 // 切到 edit / split 时聚焦编辑器 —— 但外部 reload 路径不希望被 focus 偷走
const wantFocus = opts ? opts.focus !== false : true; const wantFocus = opts ? opts.focus !== false : true;
if (wantFocus && (visualMode === 'edit' || visualMode === 'split')) { if (wantFocus && (visualMode === 'edit' || visualMode === 'split')) {
// 异步聚焦:等 DOM 更新后再 focus,否则可能焦点丢失 // 异步聚焦:等 DOM 更新后再 focus,否则可能焦点丢失。
requestAnimationFrame(() => editor.focus()); // 与 scrollFrame 同款风格:track 住 handle,连按 Ctrl+E 时只 focus 一次。
if (editorFocusFrame) cancelAnimationFrame(editorFocusFrame);
editorFocusFrame = requestAnimationFrame(() => {
editorFocusFrame = null;
editor.focus();
});
} }
// 模式按钮可用性:非 md 文件禁用 preview / split(edit 仍可用) // 模式按钮可用性:非 md 文件禁用 preview / split(edit 仍可用)
@@ -1163,8 +1282,7 @@ function updateStatusMode() {
|| 'split'; || 'split';
const labels = { preview: '预览', edit: '编辑', split: '双栏' }; const labels = { preview: '预览', edit: '编辑', split: '双栏' };
// 文字写到 #status-mode-label 子 span(chip 内的 SVG 图标保留) // 文字写到 #status-mode-label 子 span(chip 内的 SVG 图标保留)
const labelEl = document.getElementById('status-mode-label'); if (els.statusModeLabel) els.statusModeLabel.textContent = labels[mode] || mode;
if (labelEl) labelEl.textContent = labels[mode] || mode;
els.statusMode.dataset.mode = mode; els.statusMode.dataset.mode = mode;
// audit M (a11y):SR 只读 aria-label 不读 textContent;同步让屏幕阅读器用户 // audit M (a11y):SR 只读 aria-label 不读 textContent;同步让屏幕阅读器用户
// 听到「切换视图模式(当前:预览)」,与可见 chip 文字一致。 // 听到「切换视图模式(当前:预览)」,与可见 chip 文字一致。
@@ -1260,6 +1378,10 @@ function updateStatusStats() {
* - split:viewer 的进度(双栏同步滚动,两边值一致,读者视角) * - split:viewer 的进度(双栏同步滚动,两边值一致,读者视角)
*/ */
let scrollFrame = null; let scrollFrame = null;
// 切模式(Ctrl+E / 点 mode chip)后用 rAF 等 DOM 更新完再 focus 编辑器;
// 与 scrollFrame 同款:track 住 handle,连续切模式时丢弃上一个未 fire 的帧,
// 避免多次 focus() 在同帧内排队、最后一帧执行多次(audit F1)。
let editorFocusFrame = null;
function updateStatusScroll() { function updateStatusScroll() {
if (!els.statusScroll) return; if (!els.statusScroll) return;
els.statusScroll.hidden = false; els.statusScroll.hidden = false;
@@ -1316,6 +1438,7 @@ new Toolbar({
btnMin: els.btnMin, btnMin: els.btnMin,
btnMax: els.btnMax, btnMax: els.btnMax,
btnClose: els.btnClose, btnClose: els.btnClose,
btnCloseFile: els.btnCloseFile,
}, },
onThemeClick: () => cycleTheme(), onThemeClick: () => cycleTheme(),
onPinClick: async () => { onPinClick: async () => {
@@ -1334,14 +1457,21 @@ new Toolbar({
onMinimize: () => window.api.minimizeWindow(), onMinimize: () => window.api.minimizeWindow(),
onToggleMaximize: () => window.api.toggleMaximizeWindow(), onToggleMaximize: () => window.api.toggleMaximizeWindow(),
onClose: () => window.api.closeWindow(), onClose: () => window.api.closeWindow(),
// 关闭当前文件:与窗口关闭按钮(btnClose → onClose → closeWindow)区分。
// 这里走 closeCurrentFile():内部有"未保存改动"对话框;无文件时按钮 disabled
// 不触发(disabled 浏览器层面 click 不派发)。
onCloseFile: () => closeCurrentFile(),
}); });
/** /**
* 保存按钮(手动保存入口) * 保存按钮(手动保存入口)
*/ */
if (els.btnSave) { if (els.btnSave) {
// Round 13 fix (LOW app.js #1):原 `return save()` 在箭头函数里把 save()
// 的 Promise 返回给 addEventListener 回调——返回值被 listener 忽略,是
// 误导性表达。改成显式调用 + 让 listener 返回 void。
els.btnSave.addEventListener('click', () => { els.btnSave.addEventListener('click', () => {
return save(); save();
}); });
} }
@@ -1390,6 +1520,12 @@ if (els.statusPath) {
*/ */
function mountAiPanel() { function mountAiPanel() {
if (!els.btnAi) return; if (!els.btnAi) return;
// audit fix (M1):重复 mount 时先拆旧实例 —— createAiController 持有 chat
// panel 7 listener + diff panel + recomputeTimer + IPC 订阅,覆盖引用会让旧
// 实例变孤儿,等 GC 期间旧监听器仍响应事件(看到的是「AI 点了没反应」)。
if (ai && typeof ai.destroy === 'function') {
try { ai.destroy(); } catch { /* ignore */ }
}
// 创建控制器(在 settings 加载完后) // 创建控制器(在 settings 加载完后)
ai = createAiController({ ai = createAiController({
api: window.api, api: window.api,
@@ -1713,6 +1849,10 @@ async function save(opts = {}) {
state.currentFile = { ...state.currentFile, mtimeMs: reloadResult.mtimeMs, size: reloadResult.size }; state.currentFile = { ...state.currentFile, mtimeMs: reloadResult.mtimeMs, size: reloadResult.size };
state.isDirty = false; state.isDirty = false;
syncSaveButton(); syncSaveButton();
// Round 13 fix (M17):discard_local 与 reloadCurrentFileFromDisk
// 对称调 applyEditorMode —— 不调会让 editor 仍处于 IME 合成期
// / 用户切到 edit-mode 之前的视图状态,与 state.editorMode 漂移。
applyEditorMode(state.editorMode, { focus: false });
// audit fix (Round 9):和 reloadCurrentFileFromDisk 一致,丢弃本地 // audit fix (Round 9):和 reloadCurrentFileFromDisk 一致,丢弃本地
// 切换到外部版本后必须通知 AI controller 清 pendingDiff —— 否则 AI // 切换到外部版本后必须通知 AI controller 清 pendingDiff —— 否则 AI
// 可能基于 baseContent(本地)算 diff 后 setExternalContent 把本地内容 // 可能基于 baseContent(本地)算 diff 后 setExternalContent 把本地内容
@@ -1785,11 +1925,6 @@ async function save(opts = {}) {
// 注意:先 clearCurrentFile 也意味着 UI 立刻清空(编辑器 / viewer // 注意:先 clearCurrentFile 也意味着 UI 立刻清空(编辑器 / viewer
// 卸载),用户看到「文件没了」+ 一条「正在救回」toast,几秒后 toast // 卸载),用户看到「文件没了」+ 一条「正在救回」toast,几秒后 toast
// 变「已救回到 xxx」或「救回失败」。这比「脏内容静默丢失」好得多。 // 变「已救回到 xxx」或「救回失败」。这比「脏内容静默丢失」好得多。
const originalName = targetName || 'untitled.md';
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); // 含 .mmm
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
// Phase 2 audit 修复 (P2 Bug #2):必须在 clearCurrentFile **之前** // Phase 2 audit 修复 (P2 Bug #2):必须在 clearCurrentFile **之前**
// 取最新内容 —— clearCurrentFile 会 unload editor view,之后 // 取最新内容 —— clearCurrentFile 会 unload editor view,之后
// editor.getContent() 返回 ''。flight 期间用户输入的字符都在 // editor.getContent() 返回 ''。flight 期间用户输入的字符都在
@@ -1803,15 +1938,13 @@ async function save(opts = {}) {
showToast('文件已被删除;正在救回未保存内容…', 'warning', 3000); showToast('文件已被删除;正在救回未保存内容…', 'warning', 3000);
// 异步救回(fire-and-forget:不阻塞 save() 返回;race 安全因为 // 异步救回(fire-and-forget:不阻塞 save() 返回;race 安全因为
// state.currentFile 已是 null,后续 onFilesChanged 不会再走 rescue 分支) // state.currentFile 已是 null,后续 onFilesChanged 不会再走 rescue 分支)
window.api.createFile(rescueName, latestContent).then((rescueResult) => { // 命名 / toast 文案 / friendlyFsError 错误码传递逻辑全部交给 rescue.js,
if (rescueResult && rescueResult.ok) { // 这里只关心「我有 content、想救回」一件事。
showToast(`未保存内容已救回到「${rescueName}」`, 'success', 4500); rescueDirtyContent({
} else { api: window.api,
showToast(`未保存内容丢失(救回失败:${window.api.friendlyFsError(rescueResult?.code, rescueResult?.error)})`, 'error', 4500); originalName: targetName,
} content: latestContent,
}).catch((e) => { event: '文件已被删除',
console.error('[app] save FILE_NOT_FOUND 救回异常:', e);
showToast('未保存内容丢失(救回异常)', 'error', 4500);
}); });
return { ok: false, reason: 'FILE_NOT_FOUND' }; return { ok: false, reason: 'FILE_NOT_FOUND' };
} }
@@ -1847,12 +1980,18 @@ async function save(opts = {}) {
// - 不等(飞行期用户敲了新字符,半步保存)→ state.isDirty = true, // - 不等(飞行期用户敲了新字符,半步保存)→ state.isDirty = true,
// 走 Phase N-A1 的「半步保存」兜底:用户主动 Ctrl+S 补刀 / 关窗走 // 走 Phase N-A1 的「半步保存」兜底:用户主动 Ctrl+S 补刀 / 关窗走
// main.js confirmDiscardIfDirty 的 rendererDirty re-check // main.js confirmDiscardIfDirty 的 rendererDirty re-check
const currentDoc = editor.view.state.doc.toString(); //
state.isDirty = (currentDoc !== content); // 防御:editor 在 await window.api.writeFile 期间可能被 pagehide 拆掉
editor.markSaved(); // (dispose() 置 view = null)—— 那种情况下文已经要走了,不必再更新
state.lastSavedMtimeMs = result.mtimeMs; // state / UI,命中则跳过即可。
state.lastWriteMtimeMs = result.mtimeMs; // P1-2:用 mtime 而非 Date.now() 作为回声抑制锚点 if (editor.view) {
syncSaveButton(); const currentDoc = editor.view.state.doc.toString();
state.isDirty = (currentDoc !== content);
editor.markSaved();
state.lastSavedMtimeMs = result.mtimeMs;
state.lastWriteMtimeMs = result.mtimeMs; // P1-2:用 mtime 而非 Date.now() 作为回声抑制锚点
syncSaveButton();
}
// 更新当前文件的 size/mtime(让侧边栏和状态栏反映最新值) // 更新当前文件的 size/mtime(让侧边栏和状态栏反映最新值)
state.currentFile = { ...state.currentFile, mtimeMs: result.mtimeMs, size: result.size }; state.currentFile = { ...state.currentFile, mtimeMs: result.mtimeMs, size: result.size };
@@ -1960,7 +2099,11 @@ async function openFile(file) {
// `myEpoch !== openEpoch` 检查会因 openEpoch 已被新调用顶到 N+1 而失败, // `myEpoch !== openEpoch` 检查会因 openEpoch 已被新调用顶到 N+1 而失败,
// 导致整个加载链在中途放弃状态变更(双击同一个文件,editor/viewer 不更新)。 // 导致整个加载链在中途放弃状态变更(双击同一个文件,editor/viewer 不更新)。
// 直接返回原 promise,等它正常走完;新调用方复用同一结果。 // 直接返回原 promise,等它正常走完;新调用方复用同一结果。
if (openInFlight && openInFlight.path === file.path) { // audit fix (Case-M1):Windows / macOS 路径大小写不敏感 —— 用户输入的 case
// 与 IPC 返回的 canonical case 可能不一致。`===` 会漏命中,导致同一文件
// 被并发打开两次(双 editor 抖动、state.currentFile 终态不确定)。统一走
// pathsEqual(utils.js 已有 case-fold 实现)。
if (openInFlight && pathsEqual(openInFlight.path, file.path)) {
return openInFlight.promise; return openInFlight.promise;
} }
@@ -1982,6 +2125,15 @@ async function openFile(file) {
// 编辑的 IME 字符随 view 销毁静默丢失。详见 syncDirtyFromEditor 注释块。 // 编辑的 IME 字符随 view 销毁静默丢失。详见 syncDirtyFromEditor 注释块。
syncDirtyFromEditor(); syncDirtyFromEditor();
// 第二阶段代码审查 (Phase 2 review #5):在第一个 await 之前抓「用户当前打开的文件」
// + 脏态快照。下面的 dirty 对话框 / save() await 期间,clearCurrentFile 可能被
// 触发(onFilesChanged race、外部删除、关窗路径、rename 完成),把 state.currentFile
// 拍成 null、state.isDirty 拍成 false。FILE_NOT_FOUND 分支里如果继续读
// state.currentFile / state.isDirty,rescue 检查会永远短路,dirty 内容静默丢失。
// 用快照判断「我们当时打算 open 的那个文件是谁」才是正确的语义。
const targetFileSnapshot = state.currentFile ? { ...state.currentFile } : null;
const targetIsDirtySnapshot = !!state.isDirty;
// 切换到不同文件且当前脏 → 提示 // 切换到不同文件且当前脏 → 提示
if (state.isDirty && state.currentFile && state.currentFile.path !== file.path) { if (state.isDirty && state.currentFile && state.currentFile.path !== file.path) {
const choice = await showConfirmDialog({ const choice = await showConfirmDialog({
@@ -2020,7 +2172,7 @@ async function openFile(file) {
let stillDirtyAfterRetry = false; let stillDirtyAfterRetry = false;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (!state.isDirty) break; if (!state.isDirty) break;
if (!state.currentFile || state.currentFile.path === file.path) break; if (!state.currentFile || pathsEqual(state.currentFile.path, file.path)) break;
ok = await save({ silent: true }); ok = await save({ silent: true });
if (myEpoch !== openEpoch) return; if (myEpoch !== openEpoch) return;
if (!ok) break; if (!ok) break;
@@ -2059,7 +2211,7 @@ async function openFile(file) {
// 关窗时主进程的 rendererDirty 还是 true,会要求用户保存一个他们已经放弃的版本。 // 关窗时主进程的 rendererDirty 还是 true,会要求用户保存一个他们已经放弃的版本。
syncSaveButton(); syncSaveButton();
} }
} else if (state.isDirty && state.currentFile && state.currentFile.path === file.path) { } else if (state.isDirty && state.currentFile && pathsEqual(state.currentFile.path, file.path)) {
// Phase 2 audit 修复 (P0 BUG #1):点同一文件 + dirty → 也走「保存 / 放弃 / 取消」对话框。 // Phase 2 audit 修复 (P0 BUG #1):点同一文件 + dirty → 也走「保存 / 放弃 / 取消」对话框。
// 原代码 `path !== file.path` 把同文件情况短路,编辑器直接被磁盘版本覆盖 → 未 // 原代码 `path !== file.path` 把同文件情况短路,编辑器直接被磁盘版本覆盖 → 未
// 保存字符静默丢失(用户高频误点 / focus 切换都会触发)。提示文案改成「重新加载会丢 // 保存字符静默丢失(用户高频误点 / focus 切换都会触发)。提示文案改成「重新加载会丢
@@ -2089,7 +2241,7 @@ async function openFile(file) {
editor.markSaved(); editor.markSaved();
syncSaveButton(); syncSaveButton();
} }
} else if (state.currentFile && state.currentFile.path === file.path) { } else if (state.currentFile && pathsEqual(state.currentFile.path, file.path)) {
// Phase 2 audit 修复:同文件 + 干净 → no-op,避免无谓的 readFile + editor.loadFile // Phase 2 audit 修复:同文件 + 干净 → no-op,避免无谓的 readFile + editor.loadFile
// 重新加载(编辑器内容 = 磁盘内容,刷新没意义,徒增 IO)。如果用户是想 reload // 重新加载(编辑器内容 = 磁盘内容,刷新没意义,徒增 IO)。如果用户是想 reload
// 外部修改过的文件,应该走工具栏「重新加载」按钮(reloadCurrentFileFromDisk), // 外部修改过的文件,应该走工具栏「重新加载」按钮(reloadCurrentFileFromDisk),
@@ -2115,22 +2267,26 @@ async function openFile(file) {
// 的 createFile(rescueName, editor.getContent()))把脏内容救到数据目录。 // 的 createFile(rescueName, editor.getContent()))把脏内容救到数据目录。
// 注意:必须在 clearCurrentFile **之前**取 editor.getContent() —— clearCurrentFile // 注意:必须在 clearCurrentFile **之前**取 editor.getContent() —— clearCurrentFile
// 会 unload editor view,之后 editor.getContent() 返回 ''。 // 会 unload editor view,之后 editor.getContent() 返回 ''。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) { //
const originalName = state.currentFile.name || 'untitled.md'; // 第二阶段代码审查 (Phase 2 review #5):用上方抓的 targetFileSnapshot
const base = originalName.replace(/\.md$/i, ''); // + targetIsDirtySnapshot 代替直接读 state.currentFile / state.isDirty。
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); // 原因:readFile 之前发生过 dirty 对话框 / save() await,clearCurrentFile
const suffix = Math.random().toString(36).slice(2, 6); // 可能在这段窗口里被触发(onFilesChanged race、fs-watcher rescue 删文件、
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`; // 关窗路径),把 state.currentFile 拍成 null、state.isDirty 拍成 false。
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : ''; // 原检查 `state.isDirty && state.currentFile && state.currentFile.path === file.path`
window.api.createFile(rescueName, rescueContent).then((r) => { // (pathsEqual 兜底 Windows / macOS 大小写)在 race 命中时永远短路 →
if (r && r.ok) { // dirty 内容被静默丢失。用快照保持「我们当时打算 open 的那个文件是谁」
showToast(`文件已被删除,未保存内容已救回到「${rescueName}」`, 'warning', 4500); // 这个意图不变。
} else { if (targetIsDirtySnapshot && targetFileSnapshot && pathsEqual(targetFileSnapshot.path, file.path)) {
showToast(`文件已被删除;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500); // 命名 / 写盘 / toast 文案统一走 src/rescue.js(含 friendlyFsError
} // 错误码 vs message 的语义判断)。注意:必须在 clearCurrentFile 之前
}).catch((e) => { // 调 rescueDirtyContent 之前的 editor.getContent() —— clearCurrentFile
console.error('[app] openFile rescue createFile IPC 异常:', e); // 会 unload view,之后 getContent() 返回 ''。
showToast('文件已被删除;未保存内容丢失', 'error', 4500); rescueDirtyContent({
api: window.api,
originalName: targetFileSnapshot.name,
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
event: '文件已被删除',
}); });
} else { } else {
showToast('文件已被删除', 'warning'); showToast('文件已被删除', 'warning');
@@ -2140,7 +2296,14 @@ async function openFile(file) {
// 外部删除、fs-watcher 还没刷掉的条目 B,readFile(B) 撞 FILE_NOT_FOUND, // 外部删除、fs-watcher 还没刷掉的条目 B,readFile(B) 撞 FILE_NOT_FOUND,
// 却把 A 一起清了:clearCurrentFile 会 editor.unload() 并把 isDirty 置 false, // 却把 A 一起清了:clearCurrentFile 会 editor.unload() 并把 isDirty 置 false,
// A 的未保存内容既没救回也不会在关窗时告警 → 静默丢失。 // A 的未保存内容既没救回也不会在关窗时告警 → 静默丢失。
if (!state.currentFile || state.currentFile.path === file.path) { //
// 第二阶段代码审查:clearCurrentFile 检查也用快照判断「消失的是不是当初
// 打算 open 的那个文件」。state.currentFile 为 null 时 clearCurrentFile 是
// no-op(line 2249 的早退路径),原表达式 `!state.currentFile || ...` 已经
// 兼容 race;这里改成显式读快照让两条 rescue / clear 分支的判断口径一致,
// 未来读代码时不必在两处分别解释为什么一个用 state 一个用 snapshot。
const matchesTarget = targetFileSnapshot && pathsEqual(targetFileSnapshot.path, file.path);
if (!state.currentFile || matchesTarget) {
clearCurrentFile(); clearCurrentFile();
} }
} else if (result.error === 'FILE_TOO_LARGE') { } else if (result.error === 'FILE_TOO_LARGE') {
@@ -2604,6 +2767,24 @@ async function openSettingsDialog(options = {}) {
// toolbar 自动保存按钮自己处理;这里不再重复 apply,settingsStore.change 事件统一同步。 // toolbar 自动保存按钮自己处理;这里不再重复 apply,settingsStore.change 事件统一同步。
// 见 [[feedback-prefer-near-affected-area]]。 // 见 [[feedback-prefer-near-affected-area]]。
}, },
// 实时预览:用户在设置里切配色 / 字号 / 行距,立刻反映到 app 主题。
// 走 CSS / dataset,不走 settingsStore(不走就不持久化、不会触发 onChanged),
// 取消时由 onPreviewRevert 把视觉回退到打开对话框瞬间。
// AI 字段(provider / baseUrl / key / model / systemPrompt)不走预览——
// 那是「下次请求要用的参数」,实时预览反而会让人误以为已经发出去。
onPreview: (changes) => {
if ('themePalette' in changes) {
applyPalette(changes.themePalette);
}
if ('readerFontSize' in changes || 'readerLineHeight' in changes) {
applyReaderSettings(changes);
}
},
// 取消时把视觉回滚到基线(基线是 open() 时记录的「打开对话框瞬间」的值)。
onPreviewRevert: (baseline) => {
applyPalette(baseline.themePalette);
applyReaderSettings(baseline);
},
}); });
try { try {
await dialog.open(options); await dialog.open(options);
@@ -2625,10 +2806,23 @@ async function openSettingsDialog(options = {}) {
/** /**
* 应用阅读字号 / 行距到 CSS 变量(挂在 documentElement,独立于主题) * 应用阅读字号 / 行距到 CSS 变量(挂在 documentElement,独立于主题)
* 这样切主题不会重置用户的字号 / 行距 * 这样切主题不会重置用户的字号 / 行距
*
* @param {object} [partial] - 可选的覆盖值。键 readerFontSize / readerLineHeight。
* - 不传 → 两个维度都从 settingsStore 读(设置启动 / 落盘后的常规路径)
* - 部分字段 → 该字段用 partial 里的值;未携带的字段仍然回退到 settingsStore
*
* 用于设置对话框实时预览:settings-dialog 维护一个累计视觉快照
* (palette/fs/lh 三个维度任一切换都更新整体),onPreview 总是传完整快照;
* 这种 full-partial 路径也支持只携带一个字段 —— 比如以后想做「单维度 patch 式
* 预览」时也接得住。
*/ */
function applyReaderSettings() { function applyReaderSettings(partial) {
const fs = settingsStore.get('readerFontSize'); const fs = partial && 'readerFontSize' in partial
const lh = settingsStore.get('readerLineHeight'); ? partial.readerFontSize
: settingsStore.get('readerFontSize');
const lh = partial && 'readerLineHeight' in partial
? partial.readerLineHeight
: settingsStore.get('readerLineHeight');
if (typeof fs === 'number' && Number.isFinite(fs)) { if (typeof fs === 'number' && Number.isFinite(fs)) {
document.documentElement.style.setProperty('--reader-font-size', `${fs}px`); document.documentElement.style.setProperty('--reader-font-size', `${fs}px`);
} }
@@ -2684,7 +2878,10 @@ function syncBehaviorPrefsUI(settings) {
} }
// 2. 自动保存 chip 文案 + (关 → 取消 pending;开 → 等下次 onChange 重新调度) // 2. 自动保存 chip 文案 + (关 → 取消 pending;开 → 等下次 onChange 重新调度)
const ms = Number.isFinite(settings.autoSaveDebounceMs) ? settings.autoSaveDebounceMs : 500; // audit fix:fallback 用 schema 同一份常量而不是硬编码 500,改默认值只改一处。
const ms = Number.isFinite(settings.autoSaveDebounceMs)
? settings.autoSaveDebounceMs
: window.api.settingsSchema.AUTO_SAVE_DEFAULT_DEBOUNCE_MS;
if (ms !== lastAutosaveMs) { if (ms !== lastAutosaveMs) {
lastAutosaveMs = ms; lastAutosaveMs = ms;
// 用状态点 dot 的 dim/highlight 替代旧「自动保存: 开/关」冗余文案 —— 开启态 // 用状态点 dot 的 dim/highlight 替代旧「自动保存: 开/关」冗余文案 —— 开启态
@@ -2794,6 +2991,10 @@ function scheduleAutoSave(debounceMs) {
* 这与 fileList / viewer / editor 的 .dispose() 同款「统一释放入口」模式。 * 这与 fileList / viewer / editor 的 .dispose() 同款「统一释放入口」模式。
*/ */
const ipcUnsubscribes = []; const ipcUnsubscribes = [];
// audit fix (Phase 6 H2):mountKeyboardShortcuts 返回 document keydown 的
// dispose 闭包,bootstrap 必须 capture 它并交给 pagehide —— 否则重复 bootstrap
// (未来 settings / dataDir 切换走热重载路径时)会堆 listener。
let disposeShortcuts = null;
function subscribeIpc() { function subscribeIpc() {
// 文件列表变化(外部添加 / 删除 / 修改) // 文件列表变化(外部添加 / 删除 / 修改)
// 用签名缓存去重:内容真正变化才 setFiles + updateFileCount, // 用签名缓存去重:内容真正变化才 setFiles + updateFileCount,
@@ -2835,7 +3036,13 @@ function subscribeIpc() {
// 上一 handler 仍在飞?记下要补扫的 relDir 后直接 return。 // 上一 handler 仍在飞?记下要补扫的 relDir 后直接 return。
if (inFlightToken !== 0) { if (inFlightToken !== 0) {
console.debug('[app] fs:changed burst dropped (handler in flight)'); console.debug('[app] fs:changed burst dropped (handler in flight)');
if (payloadRel) pendingRescanRel = payloadRel; // Round 13 fix (H11):只有新事件与当前显示目录相关时才覆盖 pendingRescanRel。
// 旧版无条件 `pendingRescanRel = payloadRel`(以及下方 inFlight 路径的
// 同样无条件赋值)会让「在子目录里编辑时收到根目录变化」覆盖掉子目录里
// 的 pending —— 用户切回子目录时看不到该刷的列表。
if (payloadRel === state.currentRelDir) {
pendingRescanRel = payloadRel;
}
return; return;
} }
inFlightToken = myToken; inFlightToken = myToken;
@@ -2870,7 +3077,10 @@ function subscribeIpc() {
// Stage 8:fs-watcher 只盯「当前显示目录」,payload.relDir 字段标识事件来源; // Stage 8:fs-watcher 只盯「当前显示目录」,payload.relDir 字段标识事件来源;
// 与 state.currentRelDir 不匹配的事件(理论不会发生 —— 我们已经切了 watchDir, // 与 state.currentRelDir 不匹配的事件(理论不会发生 —— 我们已经切了 watchDir,
// 但 polling 期间用户可能在另一目录触发变化)一律忽略,避免子目录事件把根目录列表刷掉。 // 但 polling 期间用户可能在另一目录触发变化)一律忽略,避免子目录事件把根目录列表刷掉。
const payloadRel = typeof relDir === 'string' ? relDir : ''; // Round 13 fix (H11):payloadRel 已在入口处解构(line ~2914),这里复用,
// 删除重复 const 声明 —— 旧版在 try 块顶部又声明一次会让 lint 报
// 'payloadRel' is already declared in upper scope。
// payloadRel === state.currentRelDir 检查覆盖范围不变。
if (payloadRel !== state.currentRelDir) { if (payloadRel !== state.currentRelDir) {
// audit fix:原版静默 return,未来如果出现「切目录 debounce 错位」类 bug // audit fix:原版静默 return,未来如果出现「切目录 debounce 错位」类 bug
// 会让侧栏停在旧视图而用户毫无线索。给一条 console.debug 让 DevTools 能 // 会让侧栏停在旧视图而用户毫无线索。给一条 console.debug 让 DevTools 能
@@ -2969,29 +3179,14 @@ function subscribeIpc() {
return; return;
} }
if (state.isDirty) { if (state.isDirty) {
// P1-4:未保存内容不能静默丢失。尝试用 file:create 把当前内容写到 // P1-4:未保存内容不能静默丢失。命名 / 写盘 / toast / friendlyFsError
// 数据目录里的一个 rescue 文件(`<原名>-rescued-<时间戳>-<随机>.md`)。 // 错误码 vs message 的处理全部收口到 src/rescue.js(详见该文件注释 + audit
// 这样用户至少可以在侧栏找到它,手动改名 / 粘贴回去。 // renderer-M9 的同秒重名问题)。这里只关心「有 content、想救回」。
// audit fix (renderer-M9):原版用秒级 ISO 时间戳(slice(0,19)), rescueDirtyContent({
// 同一秒内两次删除会让 rescueName 重名,createFile 走 EEXIST 分支 api: window.api,
// 让用户「未保存内容丢失」。改用毫秒 + 4 字符随机后缀,碰撞概率 originalName: state.currentFile?.name,
// 约 1 / 1.6M(即使 ms 撞上,4 字符 base36 仍有 1.68M 空间)。 content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
const originalName = state.currentFile?.name || 'untitled.md'; event: '原文件已被删除',
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); // 含 .mmm
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
const content = (typeof editor.getContent === 'function') ? editor.getContent() : '';
window.api.createFile(rescueName, content).then((r) => {
if (r && r.ok) {
showToast(`原文件已被删除,未保存内容已救回到「${rescueName}」`, 'warning', 4500);
} else {
showToast(`原文件已被删除;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500);
}
}).catch((e) => {
// IPC 协议层异常(非 createFile 的 result.error)—— 至少留一条 console 便于排查
console.error('[app] rescue createFile IPC 异常:', e);
showToast('原文件已被删除;未保存内容丢失', 'error', 4500);
}); });
} else { } else {
showToast('当前文件已被删除', 'warning'); showToast('当前文件已被删除', 'warning');
@@ -3028,10 +3223,13 @@ function subscribeIpc() {
if (updated && state.lastSavedMtimeMs != null && updated.mtimeMs > state.lastSavedMtimeMs) { if (updated && state.lastSavedMtimeMs != null && updated.mtimeMs > state.lastSavedMtimeMs) {
// 抑制「刚保存引发的回声」(P1-2):按 mtime 比对而非时间窗。 // 抑制「刚保存引发的回声」(P1-2):按 mtime 比对而非时间窗。
// - 干净态:±5ms 容差覆盖 fs stat 抖动(NTFS/ReFS),吞无意义的 echo。 // - 干净态:±5ms 容差覆盖 fs stat 抖动(NTFS/ReFS),吞无意义的 echo。
// - 脏态(Phase N-A4):只 skip 精确等于,>0ms 差异一律走冲突对话框。 // - 脏态(Phase N-A4 → audit 2026-09-21):从严格 === 放宽到 ±1500ms,
// 覆盖 FAT32 / exFAT(2 秒桶)/ HFS+(1 秒)/ SMB(1-2 秒)下
// 自身保存的 mtime 截断 —— 否则用户保存后会被误判为「外部修改」弹
// 冲突对话框。1500ms 容差几乎不影响真实外部修改检测:
// 外部进程改文件的窗口远大于 1.5s,且用户可手动 reload。
if (state.lastWriteMtimeMs > 0 if (state.lastWriteMtimeMs > 0
&& ((state.isDirty && updated.mtimeMs === state.lastWriteMtimeMs) && Math.abs(updated.mtimeMs - state.lastWriteMtimeMs) <= 1500) {
|| (!state.isDirty && Math.abs(updated.mtimeMs - state.lastWriteMtimeMs) < 5))) {
return; // 自身写盘回声 return; // 自身写盘回声
} }
if (state.isDirty) { if (state.isDirty) {
@@ -3345,6 +3543,30 @@ function getSidebarWidth() {
return Number.isFinite(w) && w > 0 ? w : 220; return Number.isFinite(w) && w > 0 ? w : 220;
} }
/**
* AI 修改预览打开时把面板宽度重置为可用区 50%。
*
* 用户每次打开 AI 修改预览都默认对半分 —— 与编辑窗口的 splitRatio=0.5 同源设计:
* 打开就是默认比例,drag 只在本次会话内有效。
*
* 宽度计算:容器宽度 - 侧栏 - sb-splitter(4) - ai-splitter(4) = 可用区,
* AI 面板取其中一半,clamp 到 mountAiSplitter 的合法范围 [220, 720]。
*
* 与 mountAiSplitter 一致写到 :root(所有模式共用 --w-ai)。
*/
function resetAiPanelWidthToHalf() {
if (!els.appShell) return;
const containerW = els.appShell.getBoundingClientRect().width;
if (!containerW) return;
const sidebarW = getSidebarWidth();
// 4px = sb-splitter, 4px = ai-splitter
const fixedCols = sidebarW + 4 + 4;
const available = Math.max(1, containerW - fixedCols);
const half = Math.round(available * 0.5);
const clamped = Math.max(220, Math.min(720, half));
document.documentElement.style.setProperty('--w-ai', clamped + 'px');
}
/** editor + viewer 那段可用像素空间(fr_space)。减掉所有固定列: /** editor + viewer 那段可用像素空间(fr_space)。减掉所有固定列:
* sidebar + sb-splitter(4) + #splitter(4) + AI panel + ai-splitter(4)。 * sidebar + sb-splitter(4) + #splitter(4) + AI panel + ai-splitter(4)。
* 这正是 split + AI 模式下拖 #splitter 报错的 * 这正是 split + AI 模式下拖 #splitter 报错的
@@ -3377,7 +3599,6 @@ function mountSplitter() {
const MIN_VIEWER_WIDTH = 200; // 阅读视图下限 const MIN_VIEWER_WIDTH = 200; // 阅读视图下限
let currentEditorWidth = 0; let currentEditorWidth = 0;
let currentRatio = clampSplitRatio(settingsStore.get('splitRatio'));
function clampEditorWidth(width) { function clampEditorWidth(width) {
const frSpace = getEditorFrSpace(); const frSpace = getEditorFrSpace();
@@ -3412,13 +3633,14 @@ function mountSplitter() {
// audit #1:写盘失败要让用户看到 —— 否则下次启动布局错乱无迹可循。 // audit #1:写盘失败要让用户看到 —— 否则下次启动布局错乱无迹可循。
onDragEnd: () => { onDragEnd: () => {
const ratio = getCurrentRatio(); const ratio = getCurrentRatio();
currentRatio = ratio;
settingsStore.update({ splitRatio: Number(ratio.toFixed(3)) }).catch((e) => { settingsStore.update({ splitRatio: Number(ratio.toFixed(3)) }).catch((e) => {
showToast(`布局比例保存失败:${e?.message || '未知错误'}`, 'error', 3500); showToast(`布局比例保存失败:${e?.message || '未知错误'}`, 'error', 3500);
}); });
}, },
// mount 时按 saved ratio 计算 editor 宽度(与拖动走同一道 clamp 闸)。 // mount 时按 saved ratio 计算 editor 宽度(与拖动走同一道 clamp 闸)。
initialSize: () => getEditorFrSpace() * currentRatio, // 每都从 settingsStore 重读 —— 这样切换到双栏时 onChange 写入的 0.5
// 会被 MutationObserver 触发的 rebalance 立即拾取,避免停在旧 ratio。
initialSize: () => getEditorFrSpace() * clampSplitRatio(settingsStore.get('splitRatio')),
}); });
// AI 面板 / 视图模式切换时 fr_space 也会变(+ AI 360 + ai-splitter 4), // AI 面板 / 视图模式切换时 fr_space 也会变(+ AI 360 + ai-splitter 4),
@@ -3757,9 +3979,11 @@ async function bootstrap() {
} }
// 3. 绑定静态事件 // 3. 绑定静态事件
els.linkOpenFolder.addEventListener('click', () => { if (els.linkOpenFolder) {
openDataFolder(); els.linkOpenFolder.addEventListener('click', () => {
}); openDataFolder();
});
}
if (els.linkSwitchFolder) { if (els.linkSwitchFolder) {
els.linkSwitchFolder.addEventListener('click', () => { els.linkSwitchFolder.addEventListener('click', () => {
switchDataFolder(); switchDataFolder();
@@ -3841,7 +4065,7 @@ async function bootstrap() {
mountEditorContextMenu(); mountEditorContextMenu();
// 8. 加载全局快捷键 // 8. 加载全局快捷键
mountKeyboardShortcuts({ disposeShortcuts = mountKeyboardShortcuts({
onFocusSearch: () => els.searchInput?.focus(), onFocusSearch: () => els.searchInput?.focus(),
onClearSearch: () => { onClearSearch: () => {
if (els.searchInput && document.activeElement === els.searchInput) { if (els.searchInput && document.activeElement === els.searchInput) {
@@ -3913,6 +4137,10 @@ async function bootstrap() {
} }
window.location.reload(); window.location.reload();
}, },
// 关闭当前打开的文件:与工具栏 onCloseFile 共用同一个 closeCurrentFile()。
// closeCurrentFile() 内部已经处理「未保存改动」三选一对话框 + 同步 dirty;
// 无文件时该函数内部直接 return,不会弹空对话框。
onCloseFile: () => closeCurrentFile(),
}); });
// 注意:原 beforeunload 监听已删除(P2-11)。 // 注意:原 beforeunload 监听已删除(P2-11)。
@@ -3950,6 +4178,12 @@ async function bootstrap() {
} }
} }
ipcUnsubscribes.length = 0; ipcUnsubscribes.length = 0;
// audit fix (Phase 6 H2):全局快捷键的 document keydown listener —— 与
// ipcUnsubscribes 同源对称:mount 时 capture,pagehide 时统一拆。
if (typeof disposeShortcuts === 'function') {
try { disposeShortcuts(); } catch (e) { console.warn('[app] disposeShortcuts failed:', e); }
disposeShortcuts = null;
}
// AI 控制器:chat panel 7 个 listener + AbortController + recomputeTimer // AI 控制器:chat panel 7 个 listener + AbortController + recomputeTimer
if (typeof ai !== 'undefined' && ai && typeof ai.destroy === 'function') { if (typeof ai !== 'undefined' && ai && typeof ai.destroy === 'function') {
try { ai.destroy(); } catch (e) { console.warn('[app] ai.destroy failed:', e); } try { ai.destroy(); } catch (e) { console.warn('[app] ai.destroy failed:', e); }
@@ -3970,6 +4204,10 @@ async function bootstrap() {
cancelAnimationFrame(scrollFrame); cancelAnimationFrame(scrollFrame);
scrollFrame = null; scrollFrame = null;
} }
if (typeof editorFocusFrame !== 'undefined' && editorFocusFrame) {
cancelAnimationFrame(editorFocusFrame);
editorFocusFrame = null;
}
if (typeof livePreviewTimer !== 'undefined' && livePreviewTimer) { if (typeof livePreviewTimer !== 'undefined' && livePreviewTimer) {
clearTimeout(livePreviewTimer); clearTimeout(livePreviewTimer);
livePreviewTimer = null; livePreviewTimer = null;

View File

@@ -32,12 +32,14 @@ const editorThemeSpec = {
'.cm-content': { '.cm-content': {
caretColor: 'var(--accent) !important', caretColor: 'var(--accent) !important',
// 水平内边距: // 水平内边距:
// - 左 0:让文字紧贴 gutter 右侧开始,与 .markdown-body 文字起点对齐 // - 左 2px:与 lineWrapping 默认 4px 左边距叠加后第一个字符渲染在
// (gutter 自身宽度由 CodeMirror 自适应行号位数,约 30-40px, // x=gutter.right+2 处 —— 视觉上几乎看不出差别,但点击 gutter 边线时
// 与 viewer 的 padding-left 32px 偏差 < 10px,可接受) // posAtCoords 返回 pos=0(落在 padding 内)而不是 pos=1(落在第一个
// 字符上)。配合下方的 .cm-foldGutter { display:none } 修复
// 「鼠标点不了一行最前面位置」的 bug。
// - 右 --space-7:与 viewer 的右侧内边距对齐 // - 右 --space-7:与 viewer 的右侧内边距对齐
// 垂直保留 --space-5:编辑器比 viewer 顶部留白小一半,写的时候更紧凑 // 垂直保留 --space-5:编辑器比 viewer 顶部留白小一半,写的时候更紧凑
padding: 'var(--space-5) var(--space-7) var(--space-5) 0', padding: 'var(--space-5) var(--space-7) var(--space-5) 2px',
}, },
'.cm-cursor, .cm-dropCursor': { '.cm-cursor, .cm-dropCursor': {
// base theme 给 .cm-cursor 设了黑色,&dark 下设 #528bff(oneDark 的 cursor 蓝)。 // base theme 给 .cm-cursor 设了黑色,&dark 下设 #528bff(oneDark 的 cursor 蓝)。
@@ -82,6 +84,47 @@ const editorThemeSpec = {
'.cm-gutter': { '.cm-gutter': {
padding: '0 6px 0 0', padding: '0 6px 0 0',
}, },
// .cm-foldGutter:basicSetup 默认带 foldGutter(),它在 .cm-gutters 里渲染一个
// 折叠箭头列(chevron)。该列默认宽度 ~16px,但 .cm-gutters 的 CSS width 是
// 钉死 32px(只够装下 lineNumbers gutter),所以 foldGutter 实际渲染宽度
// 溢出 gutter 边界 ~10px,盖在 cm-line 的开头字符上。
//
// 后果有两个:
// 1. 视觉上 chevron 跟「行首第一个字符」重叠,markdown 文本看起来有奇怪的
// 三角形压在第一个字前面。
// 2. 鼠标点击 chevron 区域(用户以为是「行首最前面」的位置)时,hit target
// 是 foldGutter 内部的 SPAN(不是 cm-line)。CM6 的 mousedown 监听挂
// 在 view.contentDOM,gutter 是 contentDOM 的兄弟节点、事件不会冒泡到
// contentDOM —— CM6 完全不处理这次点击,光标位置不变。
// 用户体感:「鼠标点不了一行的最前面的位置」。
//
// 折叠功能本身仍然由 foldGutter() 提供 keymap(Ctrl+Shift+[ / ] 等),
// 这里只通过 CSS 隐藏 chevron 渲染部分;键盘 fold / unfold 命令照常工作。
// 对 markdown 编辑场景来说(不像代码文件那样频繁折叠函数/类),视觉上
// 干掉 chevron 比保留更清爽,行首点击的命中问题也彻底消失。
// 加 !important 兜底——basicSetup 注入的样式也是通过 EditorView.theme,
// 同特异性下源序胜负不可靠。
'.cm-foldGutter': {
display: 'none !important',
},
// .cm-line:覆盖 base theme 的 `padding: 0 2px 0 6px`,把左内边距归零。
// 原 6px 左侧内边距会形成「行首死区」——鼠标点击那一段 6px 区域时,
// CodeMirror 的 posAtCoords 会把光标定位到该位置所属的最近字符位置,
// 但点击发生在 padding 内、还没有实际文字时,部分浏览器(尤其 Chromium
// 在 cm-line 的 contenteditable 内)会把 click coords 落在 gutter 边界与
// 文字起点之间这段空白上,无法可靠地放置光标到「该行的绝对最前面」。
// 把左侧 padding 改为 0 后,gutter 右侧紧贴的就是第一个字符位置,
// 点击 gutter 边线 → 光标直接落到该行最左侧(位置 0)。
// 右侧保留 2px 让选中态在行末看起来不贴边。
// 加 !important 是因为 base theme 与我们的 .cm-line 同为 (0,1,0),
// CSS 源顺序才能分胜负,加 important 后我们的规则无视源序始终胜出。
//
// 注意:折叠 gutter 的 chevron 已用 display:none 干掉(见上),所以
// cm-line 元素真正从 x=gutter.right 开始延伸;这条 padding 规则确保
// 内容从 cm-line 左缘紧贴渲染,「行首第一个字」与 cm-line 边界对齐。
'.cm-line': {
padding: '0 2px 0 0 !important',
},
'.cm-scroller': { '.cm-scroller': {
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
lineHeight: '1.7', lineHeight: '1.7',
@@ -121,6 +164,14 @@ export const __editorThemeSpecForTests = {
get caretColor() { return editorThemeSpec['.cm-content'].caretColor; }, get caretColor() { return editorThemeSpec['.cm-content'].caretColor; },
get panelsBackgroundColor() { return editorThemeSpec['.cm-panels'].backgroundColor; }, get panelsBackgroundColor() { return editorThemeSpec['.cm-panels'].backgroundColor; },
get panelsColor() { return editorThemeSpec['.cm-panels'].color; }, get panelsColor() { return editorThemeSpec['.cm-panels'].color; },
// 行单元 padding:覆盖 base theme `padding: 0 2px 0 6px`,把左内边距归零,
// 避免 gutter 右侧留 6px 死区让光标无法放到该行的绝对最前面。
get linePadding() { return editorThemeSpec['.cm-line'].padding; },
// foldGutter 隐藏规则:basicSetup 默认 foldGutter() 会渲染 chevron 并
// 溢出 32px gutter 的边界 ~10px,盖在 cm-line 的开头字符上,导致点击
// 行首最前面位置时 hit target 是 gutter SPAN 而非 cm-line,CM6 不处理
// 这次点击,光标位置不变。display:none + !important 兜底。
get foldGutterDisplay() { return editorThemeSpec['.cm-foldGutter'].display; },
}; };
/** /**

View File

@@ -269,7 +269,13 @@ export class FileList {
*/ */
setActive(filePath) { setActive(filePath) {
const next = filePath || null; const next = filePath || null;
if (next === this.activePath) return; // audit fix (Case-M1):上一版用 === —— Windows / macOS 路径大小写不敏感,
// `next`(IPC 回来的 canonical case)与 `this.activePath`(先前设置的 case)
// 不一致时早返回生效,per-li classList 更新被跳过,旧激活项残留 .is-active。
// 改走 pathsEqual:与下面 per-li 匹配用同一份 case-fold 实现,
// 「早返回条件」与「实际高亮条件」天然对齐,不会再出现「activePath 已换、
// DOM 高亮还停在上一项」的撕裂状态。
if (pathsEqual(next, this.activePath)) return;
this.activePath = next; this.activePath = next;
if (!this.element) return; if (!this.element) return;
// audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入 // audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入
@@ -319,7 +325,7 @@ export class FileList {
* 1) searchTerm 存在 → fuzzy 排序覆盖 sortMode(用户明确在搜) * 1) searchTerm 存在 → fuzzy 排序覆盖 sortMode(用户明确在搜)
* 2) searchTerm 为空 → 走 sortMode * 2) searchTerm 为空 → 走 sortMode
* *
* 这样 "wel" 仍能在 mtime-desc 模式下把 welcome.md 排前,而不是按修改时间硬排。 * 这样 "readme" 仍能在 mtime-desc 模式下把 README.md 排前,而不是按修改时间硬排。
*/ */
applyFilter() { applyFilter() {
const term = this.searchTerm; const term = this.searchTerm;

View File

@@ -22,6 +22,9 @@ 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 } from './utils.js';
// 救援工具:与 src/app.js 共享同一份命名 / toast / friendlyFsError 逻辑,
// 这里只关心「我有 content、想救回」。详见 src/rescue.js 注释。
import { rescueDirtyContent } from './rescue.js';
// 审计修复 (Round 11 deep-fix P1-1):create / delete / rename 三个回调的入口 // 审计修复 (Round 11 deep-fix P1-1):create / delete / rename 三个回调的入口
// 在读 state.isDirty 之前必须先 syncDirtyFromEditor —— 否则 CJK 用户在 IME // 在读 state.isDirty 之前必须先 syncDirtyFromEditor —— 否则 CJK 用户在 IME
// 合成期调「新建 / 删除 / 重命名」时,state.isDirty 是 stale false,跳过 // 合成期调「新建 / 删除 / 重命名」时,state.isDirty 是 stale false,跳过
@@ -166,22 +169,15 @@ export function createFileOps(ctx) {
// → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged // → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged
// rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。 // rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) { if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
const originalName = state.currentFile.name || 'untitled.md';
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : '';
// 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。 // 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。
api.createFile(rescueName, rescueContent).then((r) => { // 命名 / toast 文案 / friendlyFsError 错误码 vs message 的判断
if (r && r.ok) { // 全部收口到 src/rescue.js(之前这里手动传 r?.message || r?.error
showToast(`文件已不在;未保存内容已救回到「${rescueName}」`, 'warning', 4500); // 是 audit Round 16 R2 修过的关键 bug —— 业务码不能给用户看)。
} else { rescueDirtyContent({
showToast(`文件已不在;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500); api,
} originalName: state.currentFile.name,
}).catch((e) => { content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
console.error('[file-ops] delete FILE_NOT_FOUND rescue IPC 异常:', e); event: '文件已不在',
showToast('文件已不在;未保存内容丢失', 'error', 4500);
}); });
} else { } else {
showToast('文件已不存在', 'warning'); showToast('文件已不存在', 'warning');
@@ -255,24 +251,16 @@ export function createFileOps(ctx) {
// → dirty 内容不能直接 clearCurrentFile 丢。先取 editor.getContent() // → dirty 内容不能直接 clearCurrentFile 丢。先取 editor.getContent()
// 救到 rescue 文件,再清。复用 onFilesChanged rescue 的 createFile 模式。 // 救到 rescue 文件,再清。复用 onFilesChanged rescue 的 createFile 模式。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) { if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
const originalName = state.currentFile.name || 'untitled.md'; // 与文件其它 IPC 调用对齐用 ctx.api(createFile / renameFile / deleteFile
const base = originalName.replace(/\.md$/i, ''); // 都从 ctx 解构),不再直连 window.api。命名 / 写盘 / toast 收口到
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); // src/rescue.js,自动处理 friendlyFsError 错误码 vs message 的语义
const suffix = Math.random().toString(36).slice(2, 6); // (之前这里是漏掉 `r?.message ||` 的 bug —— 用户会看到「救回失败:
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`; // INVALID_NAME」这种原始业务码而不是中文提示)。
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : ''; rescueDirtyContent({
// audit fix (Round 4 F6):与文件其它 IPC 调用对齐用 ctx.api(createFile / api,
// renameFile / deleteFile 都从 ctx 解构),不再直连 window.api。若未来 originalName: state.currentFile.name,
// IPC 桥被换 mock / 拦截器,rescue 路径与主路径走同一来源,行为一致。 content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
api.createFile(rescueName, rescueContent).then((r) => { event: '原文件已被删除',
if (r && r.ok) {
showToast(`原文件已被删除,未保存内容已救回到「${rescueName}」`, 'warning', 4500);
} else {
showToast(`原文件已被删除;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500);
}
}).catch((e) => {
console.error('[file-ops] rename FILE_NOT_FOUND rescue IPC 异常:', e);
showToast('原文件已被删除;未保存内容丢失', 'error', 4500);
}); });
} else { } else {
showToast('文件已不存在', 'warning'); showToast('文件已不存在', 'warning');

View File

@@ -74,6 +74,12 @@ export class MarkdownEditor {
this._composing = false; this._composing = false;
this.editableCompartment = null; this.editableCompartment = null;
this.themeCompartment = null; this.themeCompartment = null;
// Round 13 fix (H8):dispose 时也 abort viewAbort,确保即便 loadFile
// 路径漏调(极端情况)contentDOM 上的 IME listener 也能摘掉。
if (this._viewAbort) {
try { this._viewAbort.abort(); } catch { /* ignore */ }
this._viewAbort = null;
}
}); });
} }
@@ -110,6 +116,18 @@ export class MarkdownEditor {
} }
this._destroyView(); 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 state = EditorState.create({ const state = EditorState.create({
@@ -227,15 +245,13 @@ export class MarkdownEditor {
} }
} }
}; };
this.view.contentDOM.addEventListener('compositionstart', this._onCompositionStart); // Round 13 fix (H8):用 this._viewAbort.signal 一次性管所有 listener,
this.view.contentDOM.addEventListener('compositionend', this._onCompositionEnd); // 下次 loadFile / dispose 时整体 abort。旧版 addAbort 路径在 dispose 时
this.view.contentDOM.addEventListener('compositioncancel', this._onCompositionCancel); // 才 removeEventListener,期间多次 loadFile 会让多份 IME handler 累积
this.addAbort(() => { // 在 contentDOM 上。signal 路径里 removeEventListener 由浏览器自动处理。
if (!this.view || !this.view.contentDOM) return; this.view.contentDOM.addEventListener('compositionstart', this._onCompositionStart, { signal: contentSignal });
this.view.contentDOM.removeEventListener('compositionstart', this._onCompositionStart); this.view.contentDOM.addEventListener('compositionend', this._onCompositionEnd, { signal: contentSignal });
this.view.contentDOM.removeEventListener('compositionend', this._onCompositionEnd); this.view.contentDOM.addEventListener('compositioncancel', this._onCompositionCancel, { signal: contentSignal });
this.view.contentDOM.removeEventListener('compositioncancel', this._onCompositionCancel);
});
} }
/** /**
@@ -430,6 +446,15 @@ export class MarkdownEditor {
} }
_destroyView() { _destroyView() {
// audit fix (M2):loadFile 走 _destroyView → 立刻建新 _viewAbort;旧信号若
// 不 abort,挂在新 view contentDOM 上的 compositionstart/end/cancel listener
// 仍绑着。view.destroy() 只解 CM6 自己的 DOM,**不**摘我们手动 add 的 listener。
// detach 后 listener 不会 fire(事件 target 已不在 DOM),但 listener↔signal
// 循环推迟旧 view 整棵子树的 GC。
if (this._viewAbort) {
try { this._viewAbort.abort(); } catch { /* ignore */ }
this._viewAbort = null;
}
// audit fix (renderer-M4):先取消可能挂着的 rAF —— 否则 destroy 后 rAF // audit fix (renderer-M4):先取消可能挂着的 rAF —— 否则 destroy 后 rAF
// 回调里读 this.view / this.onScrollCallback 还能命中内存中尚未 GC 的 // 回调里读 this.view / this.onScrollCallback 还能命中内存中尚未 GC 的
// 引用,触发到旧组件实例的回调链。 // 引用,触发到旧组件实例的回调链。

View File

@@ -367,7 +367,12 @@ export class MarkdownViewer {
const abs = resolvePath(baseDir, decoded); const abs = resolvePath(baseDir, decoded);
if (!abs) continue; // '../..' 越界 if (!abs) continue; // '../..' 越界
// 安全边界:解析后的路径必须在数据目录内。越界直接剥 src,不暴露外部文件路径。 // 安全边界:解析后的路径必须在数据目录内。越界直接剥 src,不暴露外部文件路径。
if (dataDir && !isPathInside(abs, dataDir)) { // audit fix (Sec-M2):旧版 `if (dataDir && !isPathInside(...))` 在 dataDir
// 为 null(getDataDir IPC 失败 / 启动早期 / window.api 被换)时**跳过整段
// 边界检查**,任意解析后的绝对路径都会被改写成 file:// URL 喂给浏览器 →
// 文件系统枚举 oracle(恶意 md 可以用 `<img src="../victim.png">` 探测
// 哪些路径指向合法图片)。改 fail-closed:dataDir 不可用时一律不下发 src。
if (!dataDir || !isPathInside(abs, dataDir)) {
img.removeAttribute('src'); img.removeAttribute('src');
// 保留视觉占位:把 alt 提到 title,并加一个占位属性让 CSS 可选择 // 保留视觉占位:把 alt 提到 title,并加一个占位属性让 CSS 可选择
img.setAttribute('data-blocked', 'external'); img.setAttribute('data-blocked', 'external');

88
src/rescue.js Normal file
View File

@@ -0,0 +1,88 @@
// 救援文件工具 —— 共享给 save() / openFile() / onFilesChanged / file-ops
// (deleteFile / renameFile) 五条路径使用。
//
// 背景:原逻辑「生成 `<base>-rescued-<stamp>-<suffix>.md` 名字 + 写盘 +
// 弹 toast」在四处独立复制(约 90 行重复代码),命名规则不统一时极易漂移。
// 抽到本文件后:
// - 命名规则 / 随机后缀长度 / 时间戳精度 只改一处
// - 错误文案(特别是 friendlyFsError 接受 r.message 还是 r.error)一处修
// - 各调用方只关心「我有 content、想救回」一件事
//
// 设计要点:
// - buildRescueName 是同步、纯函数,方便测试
// - rescueDirtyContent 是 fire-and-forget:写盘失败不抛、不阻塞调用方
// (save() / openFile() 入口已用 try/catch 兜底,但 rescue 路径本身不能
// 把 unhandled rejection 抛进全局)
// - 不假设 api 是 window.api 还是 ctx.api —— 任意带 createFile 方法的即可
import { showToast } from './feedback.js';
/**
* 生成救援文件名
*
* 格式:`<原名去后缀>-rescued-<ISO 时间戳含毫秒>-<4 字符随机>.md`
*
* 设计动机(audit renderer-M9):毫秒精度 + 4 字符 base36 随机后缀让同一
* 秒内多次删除产生重名的概率约 1/1.6M(ms 撞上后还有 1.68M 空间)—— 旧版
* 用秒级 ISO 时间戳(slice(0,19))会让 createFile 走 EEXIST 分支,用户
* 「未保存内容丢失」。
*
* @param {string} originalName - 原文件名(含或不含 .md 后缀都行)
* @returns {string}
*/
export function buildRescueName(originalName) {
const base = (originalName || 'untitled.md').replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); // 含 .mmm
const suffix = Math.random().toString(36).slice(2, 6);
return `${base}-rescued-${stamp}-${suffix}.md`;
}
/**
* 把「编辑器里待救回的 dirty 内容」异步写到数据目录的救援文件。
* 失败回退到 toast 提示用户;不会抛错(fire-and-forget)。
*
* @param {object} args
* @param {object} args.api - IPC API(window.api 或 file-ops ctx.api 任一带 createFile 即可)
* @param {string} args.originalName - 原文件名(用于命名救援文件)
* @param {string} args.content - 待写入的编辑器内容(调用方负责在 clearCurrentFile 之前取好)
* @param {string} args.event - 触发救援的事件描述,用于 toast 文案前缀
* (例:「文件已不在」「原文件已被删除」「文件已被删除」)。
* 成功路径:`${event},未保存内容已救回到「${rescueName}」`
* 失败路径:`${event};未保存内容丢失(救回失败:${friendlyFsError(...)})`
* IPC 异常:`${event};未保存内容丢失`
* @returns {string} 救援文件名(同步返回,不等 IPC 完成)
*/
export function rescueDirtyContent({ api, originalName, content, event }) {
const rescueName = buildRescueName(originalName);
// 第二阶段代码审查 (Phase 2 review #1):显式用 { initialContent } 对象形式,
// 不再依赖 preload.js createFile 的 string-overload 兼容分支。
// 该 overload 是「向后兼容老调用」的临时垫片,未来若被移除(强约束新签名),
// 所有 5 条 rescue 路径会静默失败 —— 用户只在 toast 上看到「未保存内容丢失」,
// 完全不知道是 payload shape 不匹配。这里走与 main/file-ops.js#resolveFileName
// 对齐的新签名,消除隐式依赖。
api.createFile(rescueName, { initialContent: content }).then((r) => {
if (r && r.ok) {
showToast(`${event},未保存内容已救回到「${rescueName}」`, 'warning', 4500);
} else {
// 注意:friendlyFsError 的第二个参数是「fallback 中文明文」,
// 必须传 r?.message(IPC handler 已塞好的中文),不能传 r?.error
// (业务码字符串如 'INVALID_NAME')—— 否则用户看到的是原始业务码
// 而非可读中文。统一收口在这里一处,调用方不再需要关心这个细节。
//
// 第二阶段代码审查 (Phase 2 review #2):走注入的 api 参数而非 window.api。
// 之前用 window.api?.friendlyFsError 与本文件「不假设 api 是 window.api
// 还是 ctx.api」的契约矛盾 —— createFile 走注入 api,friendlyFsError
// 又走 window.api,未来若 ctx.api 与 window.api 不一致(DI / 测试桩)
// 会 break。在生产里 window.api 与 ctx.api 同一对象所以无害,但破坏了
// 抽象一致性。
const friendly = api && typeof api.friendlyFsError === 'function'
? api.friendlyFsError(r?.code, r?.message || r?.error)
: (r?.message || r?.error || '未知错误');
showToast(`${event};未保存内容丢失(救回失败:${friendly})`, 'error', 4500);
}
}).catch((e) => {
console.error('[rescue] createFile IPC 异常:', e);
showToast(`${event};未保存内容丢失`, 'error', 4500);
});
return rescueName;
}

View File

@@ -26,23 +26,39 @@
* 根据调用方 silent 标志 + IIFE 返回结果决定是否弹 toast。 * 根据调用方 silent 标志 + IIFE 返回结果决定是否弹 toast。
* *
* 决策矩阵: * 决策矩阵:
* - silent=false + ok=true(无 STALE / NO_CHANGES 标记) → 「已保存」(success, 2500ms) * - silent=false + ok=true(无 STALE / NO_CHANGES / NO_FILE / DELEGATED 标记)
* - silent=false + reason='STALE'(无论 ok) → 「文件已切换,未保存到磁盘」(warning) * → 「已保存」(success, 2500ms)
* Phase 2 audit 修复 (P1 #3):STALE 不再等同「写盘失败」。飞行期用户切 * - silent=false + reason='STALE' + ok=true → 「已保存」(success, 2500ms)
* 走时 writeFile 已 resolve,data 可能已落 targetPath。ok=true 让关窗路径 * - silent=false + reason='STALE' + ok=false → 「文件已切换,未保存到磁盘」(warning)
* 能正确退出;用户视角的反馈(manual Ctrl+S silent=false)应该总是 STALE * 上述三条 STALE 行为是 Round 13 fix (M20) 与早期 Phase 2 audit 修复对比下的
* 提示(因为 editor 已不在 targetPath 上),文案保持中性「文件已切换,未 * 最终方案:
* 保存到磁盘」—— 如果用户想知道数据到底有没有存,去看 A 文件本身。 * - Phase 2 audit (P1 #3):原代码无条件 `{ok:false, reason:'STALE'}`,但
* - silent=false + reason='NO_CHANGES' → 「已是最新」(info, 1500ms) * writeFile 在 STALE 检查前已 resolve —— 数据其实已落 targetPath
* (result.ok=true)。关窗路径(onSaveRequest silent=true →
* reportSaveResult → main.js confirmDiscardIfDirty)会把 ok=false 误判
* 为「保存失败」并弹原生对话框挡住用户退出。修复:按 writeFile 实际结果
* 区分 ok=true/false 让关窗路径能正确退出。
* - Round 13 fix (M20):扩展 Phase 2 修复到 toast 反馈侧 —— STALE+ok=true
* 「data 已落盘」弹 success(「已保存」),false 才弹 STALE 文案「让用户
* 知道没存上 + 为什么」。早期文案一律「文件已切换,未保存到磁盘」会把
* 「data 已落盘」误导成「没存」(用户去 A 目录看明明文件已更新)。
* - Phase 2 + Round 13 之所以分两步:ok 字段负责「关窗是否拦截」(程序能
* 力),toast 文案负责「用户感知」(界面语义)。两边解耦后用户既能成功退
* 出,又拿到跟实际盘面一致的反馈。
* - silent=false + reason='NO_CHANGES' → 「已是最新」(info, 1500ms)
* fix(audit 2026-08):auto-save 静默写盘后 state.isDirty=false,用户手动 * fix(audit 2026-08):auto-save 静默写盘后 state.isDirty=false,用户手动
* 点保存按钮落入 !state.isDirty 早退路径,旧逻辑静默 return 让用户觉得 * 点保存按钮落入 !state.isDirty 早退路径,旧逻辑静默 return 让用户觉得
* 「按钮坏了 / 没保存上」—— 实际文件已最新。给一个简短 info 让点击有反馈。 * 「按钮坏了 / 没保存上」—— 实际文件已最新。给一个简短 info 让点击有反馈。
* auto-save silent=true 路径不影响(silent 早退)。 * auto-save silent=true 路径不影响(silent 早退)。
* - silent=false + reason='NO_FILE' → 「没有打开文件」(info, 1500ms) * - silent=false + reason='NO_FILE' → 「没有打开文件」(info, 1500ms)
* fix(audit Round 7 S-3):旧版与 NO_CHANGES 共用「已是最新」文案,但语义 * fix(audit Round 7 S-3):旧版与 NO_CHANGES 共用「已是最新」文案,但语义
* 错位 ——「已是最新」暗示「有文件且未改动」,用户视角「什么都没打开, * 错位 ——「已是最新」暗示「有文件且未改动」,用户视角「什么都没打开,
* 按 Ctrl+S 当然没东西存」需要准确的「没有打开文件」。auto-save silent * 按 Ctrl+S 当然没东西存」需要准确的「没有打开文件」。auto-save silent
* 路径不影响。 * 路径不影响。
* - silent=false + reason='DELEGATED' → 不弹(inner 已弹过)
* audit fix (Round 8 S-1):force_overwrite 把写盘委托给递归 inner save(),
* inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责
* 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。
* - silent=true → 啥都不弹(auto-save / 关窗 silent=true 静默场景) * - silent=true → 啥都不弹(auto-save / 关窗 silent=true 静默场景)
* - 其它失败 reason(CONFLICT_* / FILE_NOT_FOUND / PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED * - 其它失败 reason(CONFLICT_* / FILE_NOT_FOUND / PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED
* / WRITE_FAILED / IPC_ERROR)→ 错误 toast 已在 IIFE 各分支按 result.error 弹过, * / WRITE_FAILED / IPC_ERROR)→ 错误 toast 已在 IIFE 各分支按 result.error 弹过,
@@ -57,6 +73,14 @@ export function decideSaveToast(silent, result, showToast) {
if (silent) return; if (silent) return;
if (!result) return; if (!result) return;
if (result.reason === 'STALE') { if (result.reason === 'STALE') {
// 决策见 JSDoc 决策矩阵。STALE 必须按 ok 分流:ok=true 走 success 让用户
// 知道「data 已落盘」;ok=false 走 warning 明确告知「data 没存上 + 为什么」。
// 不能「STALE 一律走 warning」(误导 data 已落盘的情况),也不能
// 「STALE 一律走 success」(掩盖真实失败)。
if (result.ok) {
showToast('已保存', 'success', 2500);
return;
}
showToast('文件已切换,未保存到磁盘', 'warning', 2500); showToast('文件已切换,未保存到磁盘', 'warning', 2500);
return; return;
} }

View File

@@ -29,12 +29,41 @@ const READER_LINE_HEIGHTS = SETTINGS_UI_OPTIONS.readerLineHeight ?? [];
const THEME_PALETTES = SETTINGS_UI_OPTIONS.themePalette ?? []; const THEME_PALETTES = SETTINGS_UI_OPTIONS.themePalette ?? [];
const AI_PROVIDERS = SETTINGS_UI_OPTIONS.aiProvider ?? []; const AI_PROVIDERS = SETTINGS_UI_OPTIONS.aiProvider ?? [];
// 默认 baseURL / model:按 provider 切换「placeholder」提示用户 /**
// OpenAI 兼容:baseURL 含 /v1;Anthropic:baseURL 不含 /v1 * 取 provider 对应的「占位符默认值」(baseURL + model)。当用户切到对应
const AI_DEFAULTS = { * provider 但 aiBaseUrl / aiModel 仍为空时,UI 把这里给的字符串渲染成
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }, * placeholder —— 不是写盘默认值,用户不主动保存就不会落盘,避免
anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-opus-5' }, * 「换 provider 就把 baseURL 改了」的侵入感。
}; *
* 数据来源:shared/settings-schema.js#SETTINGS_UI_OPTIONS.aiProvider[*].defaults
* (原 src/settings-dialog.js 顶部的 AI_DEFAULTS,迁到 schema 与 label/hint 同源)。
*
* @param {string} providerValue - 'openai' | 'anthropic'
* @returns {{baseUrl: string, model: string}}
*/
function getAiProviderDefaults(providerValue) {
const match = AI_PROVIDERS.find((p) => p?.value === providerValue);
const defaults = match?.defaults;
if (defaults && typeof defaults.baseUrl === 'string' && typeof defaults.model === 'string') {
return defaults;
}
// schema 桥断 / defaults 字段缺失:兜底成 openai 那套,避免 UI 抛错。
// 这里不抛 —— placeholder 缺失最多显示空白,用户主动填才是真值。
return AI_PROVIDERS.find((p) => p?.value === 'openai')?.defaults
|| { baseUrl: '', model: '' };
}
// 行距数值格式化:去掉 JS 的浮点尾巴,但整数也补上 .0 让 "2" → "2.0"。
// 旧版 lh.toFixed(2).replace(/0$/, '') 在 2.0 时输出 "2."(先去掉一个尾 0,
// 留下 "2.0",再匹配尾部 "0" 去掉 → "2.")。统一走这个函数,保证卡片标签
// 和右上角数值芯片显示一致。
function formatLineHeight(n) {
if (!Number.isFinite(n)) return String(n);
return Number.isInteger(n) ? n.toFixed(1) : String(n);
}
// 默认 baseURL / model:迁到 shared/settings-schema.js#SETTINGS_UI_OPTIONS.aiProvider[*].defaults
// (与 provider 的 label / hint 同源),取数走 getAiProviderDefaults()。
// 配色 swatch 用 CSS 自定义属性渲染真实色块,避免硬编码颜色导致配色变更时不一致。 // 配色 swatch 用 CSS 自定义属性渲染真实色块,避免硬编码颜色导致配色变更时不一致。
// 每个 palette 的 strip 由 bg + accent + fg 三段组成,给用户直观的视觉印象。 // 每个 palette 的 strip 由 bg + accent + fg 三段组成,给用户直观的视觉印象。
@@ -80,19 +109,55 @@ const CHECK_SVG = `
</svg> </svg>
`; `;
/**
* 把 API Key input 的 dataset.defaultValue 属性删掉。
*
* 为什么必删(Sec-M3 修复):reveal flow 会先把真 Key 写进 input 的
* `data-default-value` 属性,再用 `.value = dataset.defaultValue` 暴露给用户。
* 关 dialog 时如果不删,XSS / DevTools / 截图 / 复制粘贴都能
* `document.querySelector('#settings-ai-api-key').dataset.defaultValue`
* 直接读出真 Key —— 绕过 arm/reveal 的 5 秒窗口防护。
*
* 抽成单独函数是为了单测(settings-dialog 没有专门的测试文件,类 mount 路径
* 太重,但这个纯函数可以脱 DOM 测)。null-safe:不传 / 传非元素 都静默 no-op。
*
* @param {HTMLInputElement|{dataset?:DOMStringMap}|null|undefined} input
*/
export function clearAiKeyDefaultValue(input) {
if (!input || !input.dataset) return;
delete input.dataset.defaultValue;
}
export class SettingsDialog { export class SettingsDialog {
/** /**
* @param {object} options * @param {object} options
* @param {object} options.api - window.api * @param {object} options.api - window.api
* @param {object} options.settingsStore - SettingsStore 实例 * @param {object} options.settingsStore - SettingsStore 实例
* @param {(changes: object) => void|Promise<void>} options.onChanged - 保存后回调 * @param {(changes: object) => void|Promise<void>} options.onChanged - 保存后回调
* @param {(changes: object) => void} [options.onPreview] - 视觉实时预览回调。
* 外观段 picker(配色 / 字号 / 行距)每次切换都同步调用一次,把对应字段
* 立刻应用到全局 UI(CSS / dataset),不走 store、不持久化。
* 失败/缺失时静默降级——预览失败不应阻塞用户在设置里的交互。
* @param {(baseline: object) => void} [options.onPreviewRevert] - 取消预览时
* 把视觉回滚到打开对话框瞬间的基线。仅在用户取消(Esc / × / 背景 / [取消])
* 时被调用;保存路径不动视觉(settingsStore.update + onChanged 已把新值落定)。
*/ */
constructor({ api, settingsStore, onChanged }) { constructor({ api, settingsStore, onChanged, onPreview, onPreviewRevert }) {
this.api = api; this.api = api;
this.settingsStore = settingsStore; this.settingsStore = settingsStore;
this.onChanged = onChanged; this.onChanged = onChanged;
this.onPreview = typeof onPreview === 'function' ? onPreview : null;
this.onPreviewRevert = typeof onPreviewRevert === 'function' ? onPreviewRevert : null;
this.modal = null; this.modal = null;
this._saving = false; this._saving = false;
// 视觉预览基线:打开对话框瞬间的 {themePalette, readerFontSize, readerLineHeight}。
// 取消时把视觉回滚到这个值;保存时清掉(不再回滚,新值已落盘)。
this._previewBaseline = null;
// 视觉预览累计态:用户逐次改 picker 时,每次 firePreview 都基于这个快照——
// 不是只发「刚刚变化的字段」。原因见 _bindAppearancePickers 里的注释:
// 单发 fs 不带 lh 会让 app.applyReaderSettings 在 lh 维度回退到 settingsStore,
// 但 store 没被预览路径更新,所以 lh(以及任何未累计的字段)会闪回旧值。
this._previewState = null;
} }
/** /**
@@ -131,6 +196,23 @@ export class SettingsDialog {
this._opening = true; this._opening = true;
const initial = this.settingsStore.getAll(); const initial = this.settingsStore.getAll();
// 视觉预览基线:用户点开对话框瞬间的配色/字号/行距。
// 取消路径(Esc / × / 背景 / [取消] 按钮)需要把视觉回滚到这里 —— 用户
// 在 picker 里改了一通但没保存,应该看到 app 还原到打开前的状态而不是
// 留在「试过的某个中间值」。
// 只覆盖视觉字段,不含 AI / 表单输入等配置型字段——后者没有「实时预览」
// 语义,回滚反而会让用户失去填写中的内容。
this._previewBaseline = {
themePalette: initial.themePalette,
readerFontSize: initial.readerFontSize,
readerLineHeight: initial.readerLineHeight,
};
// 视觉预览累计态:以基线为起点,每次 picker 切换都更新对应字段。
// 见构造函数注释 —— 必须三个维度都携带,否则单维度 partial 会让
// app.applyReaderSettings 在未携带维度上回退到 settingsStore,
// 而 store 在预览期未被更新,导致「在设置里改两下、字号被回弹」的诡异现象。
this._previewState = { ...this._previewBaseline };
// 拿到 config.json 的文件名 + 所在目录名 —— API Key hint 用, // 拿到 config.json 的文件名 + 所在目录名 —— API Key hint 用,
// 让用户知道数据存在哪个文件、能一键打开核对。 // 让用户知道数据存在哪个文件、能一键打开核对。
// //
@@ -228,6 +310,17 @@ export class SettingsDialog {
} }
_render(initial, resolve) { _render(initial, resolve) {
// 「外观」段的 schema 默认值 —— 单一事实源。reset-appearance 按钮、按钮
// disabled 联动、click 处理器都从这里取,避免 3 处字面量漂移。
// 桥缺失时兜底成字面量(与 DEFAULT_SETTINGS 中标注的默认值对齐)。
const schemaDefaults = (typeof window !== 'undefined'
&& window.api?.settingsSchema?.DEFAULT_SETTINGS) || {};
this._appearanceDefaults = {
palette: schemaDefaults.themePalette || 'default',
fs: schemaDefaults.readerFontSize || 17,
lh: schemaDefaults.readerLineHeight || 1.85,
};
const bodyHtml = [ const bodyHtml = [
this._renderAppearanceSection(initial), this._renderAppearanceSection(initial),
this._renderAiSection(initial), this._renderAiSection(initial),
@@ -295,6 +388,79 @@ export class SettingsDialog {
// 外观段:3 个视觉选择器(配色 / 字号 / 行距)+ 实时预览 // 外观段:3 个视觉选择器(配色 / 字号 / 行距)+ 实时预览
this._bindAppearancePickers(overlay, signal); this._bindAppearancePickers(overlay, signal);
// 「外观」段:「恢复默认」按钮
//
// 把 3 个视觉字段(配色 / 字号 / 行距)一次性复位到 schema 默认值。
// 行为对齐已有「清空 AI 配置」按钮:只改 UI 状态、不直接保存 —— 用户仍要点
// [保存] 才落盘;取消时 onPreviewRevert 把视觉回滚到 baseline。
//
// 实现策略:仅勾选「当前未选中」的 radio 然后 dispatch change,由
// _bindAppearancePickers 里已有的 change 监听器统一负责后续副作用(同步
// .is-selected / 更新 value label / 改 .md-preview CSS 变量 / 累加
// _previewState / firePreview)。不在这里另写一套同步逻辑,避免双路径漂移。
const resetAppearanceBtn = overlay.querySelector('[data-action="reset-appearance"]');
if (resetAppearanceBtn) {
const { palette: dPalette, fs: dFs, lh: dLh } = this._appearanceDefaults;
// 按 name + value 字面量找 radio —— 不依赖 CSS.escape,schema 值都在
// enum 白名单内可控;万一边界值(如 themePalette 未来带特殊字符)也安全。
const findRadio = (name, value) => {
const radios = overlay.querySelectorAll(`input[name="${name}"]`);
for (const r of radios) if (r.value === String(value)) return r;
return null;
};
// picker 切换后刷新按钮 disabled 态:当前 3 个字段已全部为默认值时禁用,
// 避免「点了没反应」的体感。也挂在 this 上供 _bindAppearancePickers 的
// change 监听器回调(picker 切换后立即重算)。
const updateResetBtnState = () => {
const curPalette = overlay.querySelector('input[name="theme-palette"]:checked')?.value;
const curFs = parseInt(overlay.querySelector('input[name="reader-font-size"]:checked')?.value || '0', 10);
const curLh = parseFloat(overlay.querySelector('input[name="reader-line-height"]:checked')?.value || '0');
const atDefault = curPalette === dPalette
&& curFs === dFs
&& Math.abs(curLh - dLh) < 1e-9;
resetAppearanceBtn.disabled = atDefault;
if (atDefault) resetAppearanceBtn.setAttribute('aria-disabled', 'true');
else resetAppearanceBtn.removeAttribute('aria-disabled');
};
resetAppearanceBtn.addEventListener('click', () => {
// 与「清空 AI 配置」同款 IME 守卫:picker 区域里若有 input 处于 IME
// 合成期(用户按拼音未 commit),先 blur 让候选词落地,避免直接改
// radio 把 IME 中间态吞掉、键盘焦点丢失到 picker 之外。
const active = document.activeElement;
if (active && active instanceof Element
&& active.closest('.picker-grid')
&& typeof active.blur === 'function') {
active.blur();
}
const paletteRadio = findRadio('theme-palette', dPalette);
const fsRadio = findRadio('reader-font-size', dFs);
const lhRadio = findRadio('reader-line-height', dLh);
// 仅勾选「当前未选中」的 radio + dispatch change;已选中跳过(避免
// 不必要的监听器触发)。change 监听器是单一通道:syncSelectedClass、
// label 更新、预览 CSS、_previewState 累加、firePreview 全在那里跑。
[paletteRadio, fsRadio, lhRadio].forEach((r) => {
if (r && !r.checked) {
r.checked = true;
r.dispatchEvent(new Event('change', { bubbles: true }));
}
});
// 3 个 radio 都已是默认值时按钮应禁用 —— click 后立即重算。
updateResetBtnState();
showToast('已恢复外观默认值,点保存后生效', 'info', 1800);
}, { signal });
// 暴露给 _bindAppearancePickers 的 change 监听器:用户改 picker 后实时
// 联动「恢复默认」按钮的 disabled 态(例如从默认改成非默认要立即解禁,
// 反之回到默认要立即禁用)。
this._updateResetAppearanceBtn = updateResetBtnState;
}
// audit fix (H13):textarea 不能用模板里的 escapeAttr(systemPrompt) 注入内容—— // audit fix (H13):textarea 不能用模板里的 escapeAttr(systemPrompt) 注入内容——
// escapeAttr 把 < > & 转成 &lt; &gt; &amp;,再次打开设置时用户看到的是字面 // escapeAttr 把 < > & 转成 &lt; &gt; &amp;,再次打开设置时用户看到的是字面
// &amp;lt;b&amp;gt; 这种"双重转义"内容。现在 textarea 模板里只渲染空 body, // &amp;lt;b&amp;gt; 这种"双重转义"内容。现在 textarea 模板里只渲染空 body,
@@ -303,7 +469,23 @@ export class SettingsDialog {
if (aiSystemPromptEl) aiSystemPromptEl.value = initial.aiSystemPrompt || ''; if (aiSystemPromptEl) aiSystemPromptEl.value = initial.aiSystemPrompt || '';
// AI Key 显示/隐藏 // AI Key 显示/隐藏
// Round 13 fix (H10):API Key 不能写到模板 HTML 的 value 属性 —— DevTools /
// 截图 / 共享屏幕能读到(已经存在共享屏幕泄漏风险)。模板里只放
// data-default-value 占位,mount 后用 DOM property .value 赋值。
// 关闭 modal 时再清空 input.value,磁盘上 Key 不会被 innerHTML 重新序列化泄漏。
//
// audit fix (Sec-M3):旧版「写到 dataset 后用 JS 拷到 .value」依然把 Key
// 留在 DOM 属性里 —— 已存在的 XSS(恶意 .md 笔记通过 marked / DOMPurify
// 漏过)可以 `document.querySelector('#settings-ai-api-key').dataset.defaultValue`
// 直接读出来。这绕过了 arm/reveal 的 5 秒窗口防护。
// 修:消费完立即 `delete dataset.defaultValue` —— DOM attribute 不留任何 Key 痕迹。
const aiKeyInput = overlay.querySelector('#settings-ai-api-key'); const aiKeyInput = overlay.querySelector('#settings-ai-api-key');
if (aiKeyInput) {
aiKeyInput.value = aiKeyInput.dataset.defaultValue || '';
// 把占位从 DOM 里去掉,防止任何后续 XSS / DevTools 嗅探读到。
// 同步也清 .value —— 关闭 modal 时同样清一遍,双保险。
clearAiKeyDefaultValue(aiKeyInput);
}
const aiKeyToggle = overlay.querySelector('[data-action="toggle-ai-key"]'); const aiKeyToggle = overlay.querySelector('[data-action="toggle-ai-key"]');
if (aiKeyInput && aiKeyToggle) { if (aiKeyInput && aiKeyToggle) {
aiKeyToggle.addEventListener('click', () => { aiKeyToggle.addEventListener('click', () => {
@@ -324,7 +506,12 @@ export class SettingsDialog {
// 对话框关闭时立即清空 API Key input,避免被 DevTools / 截图 / 复制粘贴泄露 // 对话框关闭时立即清空 API Key input,避免被 DevTools / 截图 / 复制粘贴泄露
if (typeof modal.setOnClose === 'function') { if (typeof modal.setOnClose === 'function') {
modal.setOnClose(() => { modal.setOnClose(() => {
if (aiKeyInput) aiKeyInput.value = ''; if (aiKeyInput) {
// 双保险:清 value 同时清 dataset 属性(Sec-M3 修复)。
// 关 dialog 后属性仍可能停留在 DOM 上被 XSS 读走。
aiKeyInput.value = '';
clearAiKeyDefaultValue(aiKeyInput);
}
}); });
} }
@@ -336,7 +523,7 @@ export class SettingsDialog {
const aiBaseUrlHint = overlay.querySelector('#settings-ai-base-url-hint'); const aiBaseUrlHint = overlay.querySelector('#settings-ai-base-url-hint');
if (aiProviderRadios.length && aiBaseUrlInput && aiModelInput) { if (aiProviderRadios.length && aiBaseUrlInput && aiModelInput) {
const applyHint = (providerValue) => { const applyHint = (providerValue) => {
const defaults = AI_DEFAULTS[providerValue] || AI_DEFAULTS.openai; const defaults = getAiProviderDefaults(providerValue);
aiBaseUrlInput.placeholder = defaults.baseUrl; aiBaseUrlInput.placeholder = defaults.baseUrl;
aiModelInput.placeholder = defaults.model; aiModelInput.placeholder = defaults.model;
// 句式统一:只讲协议路径是否含 /v1 前缀。"兼容"信息已经在卡片 label 表达。 // 句式统一:只讲协议路径是否含 /v1 前缀。"兼容"信息已经在卡片 label 表达。
@@ -443,6 +630,45 @@ export class SettingsDialog {
} }
modal.setOnClose((value) => { modal.setOnClose((value) => {
// Round 13 fix (H10):关闭 modal 前把 AI Key input 的 value 清空,
// 防止 modal.overlay 节点残留期间 DevTools / 截图能读到 API Key。
// data-default-value 是占位字符串,下次开 dialog 时 _render 会重新赋值。
// audit fix (Sec-M3):dataset 占位属性也会携带 Key 文本 —— 关 dialog 时
// 同步 delete,任何后续 XSS 都不能再 `dataset.defaultValue` 读出来。
// 下次开 dialog 由 _render 重新写入 dataset,再次 mount 后立刻 delete(见上)。
try {
const aiKeyEl = overlay.querySelector('#settings-ai-api-key');
if (aiKeyEl) {
aiKeyEl.value = '';
clearAiKeyDefaultValue(aiKeyEl);
}
} catch { /* overlay 已 unmount 时 querySelector 会抛,走忽略即可 */ }
// 视觉预览回滚:用户取消(Esc / × / 背景 / [取消] 按钮 → value !== 'save')
// 时把当前视觉状态退回打开对话框瞬间的基线。否则用户在 picker 里试了几种
// 配色/字号又不想保存,会发现 app 留在「中间值」上。
//
// 保存路径(value === 'save'):settingsStore.update 已经把最终值落盘并
// 触发 onChanged,DOM 状态与落盘值一致,**不要**回滚 —— 否则就会出现
// 「已保存但视觉又回到旧值」的灵异现象。
//
// 同步 / 异步两种形态都接住:onPreviewRevert 形如 `applyPalette(baseline.x)`
// 是同步的,但保留 async 接口给未来扩展(比如要等待主题过渡动画再 resolve)。
if (value !== 'save' && this._previewBaseline && this.onPreviewRevert) {
const baseline = this._previewBaseline;
try {
const ret = this.onPreviewRevert(baseline);
if (ret && typeof ret.then === 'function') ret.catch((e) => {
console.warn('[settings-dialog] onPreviewRevert 失败(视觉可能未回滚):', e);
});
} catch (e) {
console.warn('[settings-dialog] onPreviewRevert 同步抛错(视觉可能未回滚):', e);
}
}
this._previewBaseline = null;
// 累计态也清掉:避免二次注册 / 重复 close 路径误触发 firePreview。
this._previewState = null;
this.modal = null; this.modal = null;
// 关闭即重置:成功路径(modal.close('save'))和用户取消(Esc / × / 背景) // 关闭即重置:成功路径(modal.close('save'))和用户取消(Esc / × / 背景)
// 都会走这里,不重置的话下次开 dialog 时 _saving=true 会让保存按钮永远禁用。 // 都会走这里,不重置的话下次开 dialog 时 _saving=true 会让保存按钮永远禁用。
@@ -460,6 +686,13 @@ export class SettingsDialog {
const fs = READER_FONT_SIZES.includes(initial.readerFontSize) ? initial.readerFontSize : 17; const fs = READER_FONT_SIZES.includes(initial.readerFontSize) ? initial.readerFontSize : 17;
const lh = READER_LINE_HEIGHTS.includes(initial.readerLineHeight) ? initial.readerLineHeight : 1.85; const lh = READER_LINE_HEIGHTS.includes(initial.readerLineHeight) ? initial.readerLineHeight : 1.85;
// 「恢复默认」按钮的初始 disabled 态:当前 3 个视觉字段都已经是 schema 默认值时禁用。
// 默认值在 _render 入口已统一计算到 this._appearanceDefaults。
const { palette: dPalette, fs: dFs, lh: dLh } = this._appearanceDefaults;
const appearanceAtDefault = palette === dPalette
&& fs === dFs
&& Math.abs(lh - dLh) < 1e-9;
// 配色卡片:5 列横排,每张展示该 palette 的 bg + accent 二色条 // 配色卡片:5 列横排,每张展示该 palette 的 bg + accent 二色条
const paletteCards = THEME_PALETTES.map((p) => { const paletteCards = THEME_PALETTES.map((p) => {
const colors = PALETTE_PREVIEW_COLORS[p.value] || PALETTE_PREVIEW_COLORS.default; const colors = PALETTE_PREVIEW_COLORS[p.value] || PALETTE_PREVIEW_COLORS.default;
@@ -498,7 +731,7 @@ export class SettingsDialog {
<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}">
<input type="radio" name="reader-line-height" value="${n}" ${isSelected ? 'checked' : ''}> <input type="radio" name="reader-line-height" value="${n}" ${isSelected ? 'checked' : ''}>
<span class="lineheight-preview" aria-hidden="true">行距预览<br>第二行示例<br>第三行示例</span> <span class="lineheight-preview" aria-hidden="true">行距预览<br>第二行示例<br>第三行示例</span>
<span class="lineheight-card-label">${n.toFixed(2).replace(/0$/, '')}</span> <span class="lineheight-card-label">${formatLineHeight(n)}</span>
<span class="picker-card-check" aria-hidden="true">${CHECK_SVG}</span> <span class="picker-card-check" aria-hidden="true">${CHECK_SVG}</span>
</label> </label>
`; `;
@@ -517,6 +750,13 @@ export class SettingsDialog {
<h3 class="settings-section-title">外观</h3> <h3 class="settings-section-title">外观</h3>
<p class="settings-section-subtitle">配色风格、阅读字号与行距</p> <p class="settings-section-subtitle">配色风格、阅读字号与行距</p>
</div> </div>
<button
type="button"
class="btn-link settings-section-action"
data-action="reset-appearance"
title="把配色 / 阅读字号 / 阅读行距恢复到默认设置"
${appearanceAtDefault ? 'disabled aria-disabled="true"' : ''}
>恢复默认</button>
</header> </header>
<div class="form-row"> <div class="form-row">
<div class="picker-group"> <div class="picker-group">
@@ -584,7 +824,7 @@ print('hello')</code></pre>
// 直接给 textarea.value 赋值(绕过 entity 编解码,保留用户输入的原貌)。 // 直接给 textarea.value 赋值(绕过 entity 编解码,保留用户输入的原貌)。
// initial.aiSystemPrompt || ''; // initial.aiSystemPrompt || '';
const currentHints = AI_DEFAULTS[provider] || AI_DEFAULTS.openai; const currentHints = getAiProviderDefaults(provider);
// AI 服务卡片:整宽单列堆叠(grid 1fr),每张只显示 icon + 名称 + hint。 // AI 服务卡片:整宽单列堆叠(grid 1fr),每张只显示 icon + 名称 + hint。
// //
@@ -657,10 +897,10 @@ print('hello')</code></pre>
id="settings-ai-api-key" id="settings-ai-api-key"
class="form-input form-input-mono" class="form-input form-input-mono"
type="password" type="password"
value="${escapeAttr(apiKey)}"
placeholder="sk-..." placeholder="sk-..."
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
data-default-value="${escapeAttr(apiKey)}"
> >
<button <button
class="btn btn-secondary btn-with-icon" class="btn btn-secondary btn-with-icon"
@@ -807,6 +1047,31 @@ print('hello')</code></pre>
} }
}; };
// 把视觉字段实时推给 app —— picker 切到哪个,整个 app 立刻是哪个;
// 取消由 modal.setOnClose 里调 onPreviewRevert 回滚。
//
// **必须传累计态**:dialog 内 _previewState 跟踪 3 个维度(palette/fs/lh)
// 当前最新的值,firePreview 永远把全部 3 个字段一起发出去。原因是 app 端
// applyReaderSettings(partial) 在 partial 缺字段时会回退到 settingsStore
// 当前值;但「预览」路径不走 store,store 在预览期不更新 —— 单发 fs 不带
// lh 时,app 就会拿 store 里的旧 lh 覆盖掉前一次预览的 lh,用户切两下
// picker 会看到字号/行距互相「吃掉」。改成累计态后三个维度都基于一个
// 一致的快照,不会再撞这个 race。
//
// 失败兜底:onPreview 抛错时记 warn,绝不阻断 picker 后续交互(设置还能用)。
const firePreview = () => {
if (!this.onPreview || !this._previewState) return;
const snapshot = { ...this._previewState };
try {
const ret = this.onPreview(snapshot);
if (ret && typeof ret.then === 'function') ret.catch((e) => {
console.warn('[settings-dialog] onPreview 失败(不影响对话框使用):', e);
});
} catch (e) {
console.warn('[settings-dialog] onPreview 同步抛错(不影响对话框使用):', e);
}
};
// 同步切 .is-selected:找到 radio 所在的 picker-grid, // 同步切 .is-selected:找到 radio 所在的 picker-grid,
// 把所有卡片的 .is-selected 重新对一次 :checked 状态 // 把所有卡片的 .is-selected 重新对一次 :checked 状态
const syncSelectedClass = (radio) => { const syncSelectedClass = (radio) => {
@@ -833,6 +1098,14 @@ print('hello')</code></pre>
const opt = THEME_PALETTES.find((p) => p.value === target.value); const opt = THEME_PALETTES.find((p) => p.value === target.value);
valueLabel.textContent = opt ? opt.label : target.value; valueLabel.textContent = opt ? opt.label : target.value;
} }
if (this._previewState) this._previewState.themePalette = target.value;
firePreview();
// picker 切换后立即刷新「恢复默认」按钮的 disabled 态(用户改了一档
// 就解禁;改回默认值就禁用)。_updateResetAppearanceBtn 由 _render
// 在 _bindAppearancePickers 之后挂上,初次挂载时未设置亦无副作用。
if (typeof this._updateResetAppearanceBtn === 'function') {
this._updateResetAppearanceBtn();
}
} else if (name === 'reader-font-size') { } else if (name === 'reader-font-size') {
syncSelectedClass(target); syncSelectedClass(target);
const fs = parseInt(target.value, 10); const fs = parseInt(target.value, 10);
@@ -840,17 +1113,33 @@ print('hello')</code></pre>
if (valueLabel) valueLabel.textContent = `${fs} px`; if (valueLabel) valueLabel.textContent = `${fs} px`;
const lh = parseFloat(overlay.querySelector('input[name="reader-line-height"]:checked')?.value || '1.85'); const lh = parseFloat(overlay.querySelector('input[name="reader-line-height"]:checked')?.value || '1.85');
updatePreviewStyle(fs, lh); updatePreviewStyle(fs, lh);
if (this._previewState && Number.isFinite(fs)) {
this._previewState.readerFontSize = fs;
firePreview();
}
if (typeof this._updateResetAppearanceBtn === 'function') {
this._updateResetAppearanceBtn();
}
} else if (name === 'reader-line-height') { } else if (name === 'reader-line-height') {
syncSelectedClass(target); syncSelectedClass(target);
const lh = parseFloat(target.value); const lh = parseFloat(target.value);
const valueLabel = overlay.querySelector('#lineheight-value-label'); const valueLabel = overlay.querySelector('#lineheight-value-label');
if (valueLabel) valueLabel.textContent = String(lh); if (valueLabel) valueLabel.textContent = formatLineHeight(lh);
const fs = parseInt(overlay.querySelector('input[name="reader-font-size"]:checked')?.value || '17', 10); const fs = parseInt(overlay.querySelector('input[name="reader-font-size"]:checked')?.value || '17', 10);
updatePreviewStyle(fs, lh); updatePreviewStyle(fs, lh);
if (this._previewState && Number.isFinite(lh)) {
this._previewState.readerLineHeight = lh;
firePreview();
}
if (typeof this._updateResetAppearanceBtn === 'function') {
this._updateResetAppearanceBtn();
}
} else if (name === 'ai-provider') { } else if (name === 'ai-provider') {
// AI provider 的 is-selected 同样需要同步(picker 通用) // AI provider 的 is-selected 同样需要同步(picker 通用)
syncSelectedClass(target); syncSelectedClass(target);
// placeholder / hint 的更新在 aiProviderRadios 的 change 里处理 // placeholder / hint 的更新在 aiProviderRadios 的 change 里处理
// AI 不走 onPreview —— 模型配置是「会发出去的请求参数」类,不是「视觉偏好」,
// 实时预览它会让人误以为已经把请求发出去了。
} }
}, { signal }); }, { signal });
} }

View File

@@ -1,6 +1,6 @@
// 全局快捷键 // 全局快捷键
// //
// 10 个常用快捷键(详见下文清单): // 11 个常用快捷键(详见下文清单):
// - Ctrl/Cmd + F 聚焦文件搜索框(编辑器内让给 CM6 查找面板) // - Ctrl/Cmd + F 聚焦文件搜索框(编辑器内让给 CM6 查找面板)
// - Ctrl/Cmd + S 保存当前编辑的文件(脏状态时) // - Ctrl/Cmd + S 保存当前编辑的文件(脏状态时)
// - Ctrl/Cmd + E 切换视图模式(preview → edit → split) // - Ctrl/Cmd + E 切换视图模式(preview → edit → split)
@@ -10,6 +10,7 @@
// - Ctrl/Cmd + Shift+T 切换主题 // - Ctrl/Cmd + Shift+T 切换主题
// - Ctrl/Cmd + Shift+F 切换聚焦模式(隐藏工具栏/侧栏/状态栏) // - Ctrl/Cmd + Shift+F 切换聚焦模式(隐藏工具栏/侧栏/状态栏)
// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6) // - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6)
// - Ctrl/Cmd + W 关闭当前打开的文件(无文件时 no-op;模态打开时吞掉)
// - Esc(搜索框内) 清空搜索 // - Esc(搜索框内) 清空搜索
// //
// 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时), // 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时),
@@ -46,6 +47,7 @@ function isInsideEditor(e) {
* @param {() => void} [handlers.onToggleFocusMode] - Ctrl/Cmd+Shift+F * @param {() => void} [handlers.onToggleFocusMode] - Ctrl/Cmd+Shift+F
* @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)
*/ */
export function mountKeyboardShortcuts({ export function mountKeyboardShortcuts({
onFocusSearch, onFocusSearch,
@@ -58,6 +60,7 @@ export function mountKeyboardShortcuts({
onToggleFocusMode, onToggleFocusMode,
onToggleAi, onToggleAi,
onReload, onReload,
onCloseFile,
}) { }) {
// audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。 // audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。
// 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount // 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount
@@ -200,6 +203,29 @@ export function mountKeyboardShortcuts({
if (onReload) onReload(); if (onReload) onReload();
return; return;
} }
// Ctrl/Cmd + W → 关闭当前打开的文件。
// 与 Ctrl+R 不同:必须 preventDefault —— 否则 Electron 默认行为是直接
// 关闭 BrowserWindow,把应用整个干掉(不是"关文件")。这也让 SUPPRESS_KEYS
// 在模态打开时的 e.preventDefault() 守卫有意义:模态期吞掉 Ctrl+W
// (参见 M1 audit 的注释),否则模态连同未保存数据一起被关。
// 必须在 modalOpen 分支之前:modalOpen 分支对 'w' 已做 suppress,但仍要走
// 到下面这条普通分支(如果模态期被吞就 return 在 modal 分支里;不 return
// 则落到这里)。实际效果:模态期被吞、不调 onCloseFile;非模态期调 onCloseFile。
if ((e.key === 'w' || e.key === 'W') && !e.shiftKey && !e.altKey) {
// audit fix (Modal-L3):上面 line 101 的 SUPPRESS_KEYS 分支条件是
// `modalOpen && !inEditor` —— 当用户在 modal 打开期间点回了编辑器
// (modal 与编辑器不互斥),inEditor=true 分支被跳过,Ctrl+W 落到这里
// 触发 onCloseFile → 文件被关(脏状态还会问保存/丢弃/取消),modal 仍挂着。
// 这条分支独立再补一道 modalOpen 守卫:modal 期间无论焦点在哪都吞掉 Ctrl+W。
if (modalOpen) {
e.preventDefault();
return;
}
e.preventDefault();
if (onCloseFile) onCloseFile();
return;
}
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);

View File

@@ -76,7 +76,7 @@
--w-sidebar: 220px; --w-sidebar: 220px;
--sidebar-width: var(--w-sidebar); --sidebar-width: var(--w-sidebar);
--w-sidebar-narrow: 200px; --w-sidebar-narrow: 200px;
--w-reader-max: 720px; --w-reader-max: none;
/* 阅读排版:默认值与现有 --fs-lg / --lh-loose 同步; /* 阅读排版:默认值与现有 --fs-lg / --lh-loose 同步;
在 <html> 行内 style 上由 renderer 动态覆盖,切主题不会重置 */ 在 <html> 行内 style 上由 renderer 动态覆盖,切主题不会重置 */

View File

@@ -411,6 +411,11 @@ body.has-app-icon .app-title::before {
background: var(--bg-hover); background: var(--bg-hover);
/* 关掉 dirty-pulse 动画:避免 is-saving + is-dirty 叠加造成闪烁 */ /* 关掉 dirty-pulse 动画:避免 is-saving + is-dirty 叠加造成闪烁 */
animation: none; animation: none;
/* Round 13 fix (M22):正在保存时锁住点击 —— 否则双击 save 会触发两次
writeFile,第二次 mtime 比预期新 → FILE_CHANGED_EXTERNALLY 误判。
savingCount 防并发应该兜底,但鼠标双击事件触发极快,savingCount 在第二次
进入前未必 ++;UI 层锁一道最稳。 */
pointer-events: none;
} }
/* a11y:prefers-reduced-motion 下保持稳定视觉,不透明度也保留 */ /* a11y:prefers-reduced-motion 下保持稳定视觉,不透明度也保留 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -423,14 +428,33 @@ body.has-app-icon .app-title::before {
/* 主题按钮图标:按当前主题显示"代表当前状态"的那一态—— /* 主题按钮图标:按当前主题显示"代表当前状态"的那一态——
暗色主题下显示月亮,亮色主题下显示太阳(点击图标会切到对侧主题)。 暗色主题下显示月亮,亮色主题下显示太阳(点击图标会切到对侧主题)。
两个 SVG 同时存在于 DOM,靠 [data-theme] 切换可见性,免 JS 维护。 */
动画:两个 SVG 同位置叠加,靠 opacity + 180° rotate 切换。
- opacity 让图形柔和渐入渐出(避免硬切)
- rotate 让图形过渡时有"翻转"暗示:从太阳转到月亮是 180° 翻转的语义
- 用 position:absolute 把两者钉到 button 中心,flex 自然布局让其中一个盖在另一个之上
用 [data-theme] 选择器控制「哪一态」当前显示,状态切换由 renderer
通过 toggleTheme() 改 body.dataset.theme 完成,CSS 自动追平。 */
#btn-theme {
position: relative;
}
#btn-theme .theme-icon { #btn-theme .theme-icon {
display: none; position: absolute;
inset: 0;
margin: auto;
display: block;
opacity: 0;
transform: rotate(-90deg) scale(0.6);
transition:
opacity var(--transition-fast),
transform var(--transition-fast);
} }
body[data-theme="dark"] #btn-theme .theme-icon-moon, body[data-theme="dark"] #btn-theme .theme-icon-moon,
body[data-theme="light"] #btn-theme .theme-icon-sun { body[data-theme="light"] #btn-theme .theme-icon-sun {
display: block; opacity: 1;
transform: rotate(0) scale(1);
} }
/* (audit fix Round 9: .toolbar-status-dot 死 CSS,详见 04-toolbar.css 顶部注释) */ /* (audit fix Round 9: .toolbar-status-dot 死 CSS,详见 04-toolbar.css 顶部注释) */
@@ -463,8 +487,109 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
pointer-events: none; pointer-events: none;
} }
/* ============================================================
Toolbar Search(设计优化 2026-09:从侧栏 header 迁来)
------------------------------------------------------------
把搜索文件框从侧栏上移到底部顶栏首位,让侧栏垂直空间专注于文件列表
(侧栏同时把「+ 新建笔记」合并进 sort 行 —— 双重省空间)。
- flex:0 1 240px:理想宽度 240px,可缩不可涨 —— 窗口变窄时它先缩,
不挤右侧按钮组;不抢占侧栏上方视觉重量
- min-width:160px:极限窄窗口下保留可用宽度(低于此值键盘用户看不清)
- max-width:320px:超宽窗口里也不无止境扩张,避免视觉失衡
- 顶栏背景 + 输入框对比:bg-card 让搜索框"沉下去",与工具栏按钮区分层级
CSS 顺序说明:base 规则必须放在 media query 之前,否则等特异性下后者
会被覆盖。
============================================================ */
.toolbar-search {
position: relative;
display: flex;
align-items: center;
/* belt-and-suspenders:toolbar-right 已设 no-drag,但显式再标一次,
防止未来重构时把 .toolbar-search 误放进 .drag-region */
-webkit-app-region: no-drag;
flex: 0 1 240px;
min-width: 160px;
max-width: 320px;
height: 30px;
/* 与后面按钮组之间留点呼吸 —— 视觉上把搜索框归到"独立区",不和按钮混在一起 */
margin-right: 2px;
}
.toolbar-search .search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--fg-secondary);
pointer-events: none;
transition: color var(--transition-fast);
}
.toolbar-search .search-input {
width: 100%;
height: 30px;
/* 右侧多 4px 内边距:input[type="search"] 在 Webkit / Chromium 会渲染
内置的「×」清除按钮,避开它和占位文字重叠 */
padding: 0 30px 0 32px;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
color: var(--fg-primary);
transition:
border-color var(--transition-fast),
background var(--transition-fast),
box-shadow var(--transition-fast);
/* 搜索框是输入区,需要能选中文本。工具栏其余区域 user-select:none(避免拖拽时
高亮一片),这里覆盖掉只对输入区解禁 —— 比整个 toolbar 开 selection 干净 */
-webkit-user-select: text;
user-select: text;
}
.toolbar-search .search-input::placeholder {
color: var(--fg-tertiary);
}
.toolbar-search .search-input:hover {
border-color: var(--border-default);
}
.toolbar-search .search-input:focus {
/* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-glow);
background: var(--bg-elevated);
}
@supports (focus-visible) {
.toolbar-search .search-input:focus:not(:focus-visible) {
box-shadow: none;
}
.toolbar-search .search-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
}
/* 输入框 focus 时把左侧放大镜也跟着亮起来 —— 视觉一致性。
用 :focus-within 而不是兄弟选择器(.search-input:focus + .search-icon),
因为 DOM 里 icon 在 input 前面,兄弟选择器匹配不到 */
.toolbar-search:focus-within .search-icon {
color: var(--accent);
}
/*
聚焦模式隐藏整个 toolbar 时,搜索框随之隐藏 —— 此时连侧栏都没有,
搜索框无处可用。这与 09-misc.css 的 `body[data-focus-mode="true"] .toolbar { ... }`
配套,无需单独写规则。
窄窗口(< 820px)下,工具栏按钮文字隐藏但搜索框保留(核心功能)。
*/
/* 窄窗口(< 820px):toolbar 文字标签隐藏,只保留图标。 /* 窄窗口(< 820px):toolbar 文字标签隐藏,只保留图标。
与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌 */ 与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌。
CSS 顺序:media query 必须在 base .toolbar-search 之后,等特异性下后者覆盖前者 */
@media (max-width: 820px) { @media (max-width: 820px) {
.btn-icon-text .btn-label { .btn-icon-text .btn-label {
display: none; display: none;
@@ -473,6 +598,23 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
/* 没文字时收紧 padding,让按钮保持图标按钮的紧凑感 */ /* 没文字时收紧 padding,让按钮保持图标按钮的紧凑感 */
padding: 0 6px; padding: 0 6px;
} }
/* 820px 时所有文字按钮已退化为图标按钮,省下约 100px;
搜索框也按比例收紧到 160~200px,给 title 留更多空间 */
.toolbar-search {
flex: 0 1 200px;
min-width: 140px;
}
}
/* 极窄窗口(< 720px):与侧栏窄屏阈值对齐。搜索框最小化到只保留图标宽度,
用户仍能用 Ctrl+F 聚焦输入(即使视觉上更紧凑,键盘可达性不丢)。
::placeholder 无法设 content(浏览器不支持),自然截断即可 —— 输入框太窄时
占位文字会被输入框边缘裁掉,但输入体验(点 / Ctrl+F / Esc 清空)都还在 */
@media (max-width: 720px) {
.toolbar-search {
flex: 0 1 140px;
min-width: 96px;
}
} }
/* ============================================================ /* ============================================================

View File

@@ -15,23 +15,21 @@
z-index: var(--z-sidebar); z-index: var(--z-sidebar);
/* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */ /* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */
min-width: 0; min-width: 0;
/* container query 锚点:让 .sidebar 内部元素能根据侧栏实际宽度自适应。
用途:当侧栏拖到 ~240px 以下时,「新建笔记」内联按钮的"新建笔记"文字
自动隐藏、只保留 + 图标,给 sort 按钮(「名称」「修改时间」)腾空间。
230px 是经验阈值:sort 文字 + icon + 内边距刚好能完整显示的临界点。
命名空间 "sidebar" 与下面 @container 配套,避免与未来其他容器冲突 */
container-type: inline-size;
container-name: sidebar;
} }
.sidebar-header { /* (.sidebar-header 已删除 —— 2026-09 设计优化:搜索框上移到工具栏,
/* 紧凑的 header —— 上下 12px、左右 14px,让搜索框有呼吸但不浪费空间 */ 侧栏垂直空间让给文件列表 + 排序条。首行直接是 sort + 新建笔记合并行) */
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: var(--space-2);
}
/* 新建笔记行:放在 .sidebar-header 下方、排序条上方的独立主操作行。 /* (.sidebar-new-row 已删除 —— 2026-09 设计优化:把「新建笔记」按钮合并到
左右 padding 用 10px(与 .sidebar-sort / .file-list 对齐), .sidebar-sort 同一行的右端(用 .sidebar-new-btn--inline + margin-left:auto),
让「创建」按钮和它作用的列表视觉上是一组;搜索框保留 14px 更深的内嵌来强调 */ 节省侧栏首屏的垂直空间。原独立行的顶部呼吸转移到了 .sidebar-sort 的 padding-top) */
.sidebar-new-row {
padding: 10px 10px 8px;
}
/* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮 /* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮
——accent 文字 + 微微的 accent 底色 + 1px accent 边框,hover/active 进一步加深。 ——accent 文字 + 微微的 accent 底色 + 1px accent 边框,hover/active 进一步加深。
@@ -78,77 +76,86 @@
outline-offset: 1px; outline-offset: 1px;
} }
.search-wrap { /* 「新建笔记」按钮的紧凑内联变体(.sidebar-new-btn--inline):
position: relative; 与 .sidebar-sort-btn 并排在 .sidebar-sort 行内,靠 margin-left:auto 推到右端。
flex: 1; 设计目标:
min-width: 0; 1) 与 sort 按钮同高(覆盖 base 的固定 height:34px → 自适应高度),
} 否则 34px 的新建笔记会撑爆整行、视觉上"跳"出来,盖过 sort 按钮;
2) 宽度按内容(flex:0 0 auto),不抢 sort 按钮的弹性空间;
.search-icon { 3) icon 同步缩到 13px(base 是 14px),与 sort 按钮视觉重量一致;
position: absolute; 4) 保留 accent 文字 + 软底色 + 1px accent 边框的主操作语义不变。
left: 10px; —— base 类继续被侧栏底部 AI 按钮(#btn-ai)复用,仍是 34px 全宽语义。 */
top: 50%; .sidebar-new-btn--inline {
transform: translateY(-50%); flex: 0 0 auto;
/* 用 secondary 替代 tertiary:搜索框是核心入口,图标应该一眼看清 */ margin-left: auto;
color: var(--fg-secondary); width: auto;
pointer-events: none; height: auto;
transition: color var(--transition-fast); /* 5/6 padding 与 sort 按钮对齐:整行按钮横向 padding 一致,视觉上"一排",
} 而不是 sort 紧、新建笔记松。同节奏点击区也更整齐 */
padding: 5px 6px;
.search-input {
width: 100%;
/* 收紧内边距、改为 8px/0/8px/32px;高度由 line-height + 7px*2 padding 决定 ≈ 30px */
padding: 7px 10px 7px 32px;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm); font-size: var(--fs-sm);
color: var(--fg-primary); letter-spacing: var(--tracking-wide);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast); }
.sidebar-new-btn--inline svg {
width: 13px;
height: 13px;
}
/* 内联态下取消 base 的 scale 反馈 —— 整行按钮高度统一,按下时整行一起缩更协调;
若只缩新建笔记,按下瞬间会显得"错位跳出",与同行 sort 按钮的 active 态不齐 */
.sidebar-new-btn--inline:active {
transform: none;
} }
.search-input::placeholder { /* 窄侧栏适配:当侧栏 ≤260px 时,「新建笔记」文字隐藏,只显 + 图标,
/* 占位符原本用 fg-muted(暗色 ~2:1 几乎看不见),提到 fg-secondary 让"搜索文件..." 给 sort 按钮腾出 ~50px 空间,「修改时间」能完整显示。
提示文字可见 —— 用户至少能看清这里有输入框 */ container query(而不是 @media viewport):跟随 .sidebar 实际渲染宽度,
color: var(--fg-tertiary); 跟主窗口大小解耦。@supports 检测兼容性:旧 Chromium 不支持 container
} query 会跳过规则,回退到全文字(最差也是 ellipsis,不算破图)
阈值 260px 是经验值:sort 「修改时间」4 字 + 新建笔记 4 字同时显示需要
.search-input:hover { ~250px+ 侧栏宽度(实测 240px 时 sort 仍被截断),取 260px 给点余量 */
border-color: var(--border-default); @supports (container-type: inline-size) {
} @container sidebar (max-width: 260px) {
.sidebar-new-btn--inline span {
.search-input:focus { /* 视觉上隐藏文字,但保留 a11y / 屏幕阅读器朗读「新建笔记」 */
/* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */ position: absolute;
outline: none; width: 1px;
border-color: var(--accent); height: 1px;
box-shadow: var(--shadow-glow); padding: 0;
background: var(--bg-elevated); margin: -1px;
} overflow: hidden;
@supports (focus-visible) { clip: rect(0, 0, 0, 0);
.search-input:focus:not(:focus-visible) { white-space: nowrap;
box-shadow: none; border: 0;
} }
.search-input:focus-visible { .sidebar-new-btn--inline {
outline: 2px solid var(--accent); /* 只显图标时,按钮收紧到 icon + 少量水平内边距即可 */
outline-offset: 1px; padding: 5px 8px;
}
} }
} }
.search-input:focus + .search-icon { /* (search-wrap / search-icon / search-input 已移到 04-toolbar.css —— 2026-09
color: var(--accent); 设计优化:搜索框从侧栏上移到工具栏,样式跟随 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;
/* gap 2px:sort 按钮之间 2px(连成一组 —— 都是"列表的工具"),
margin-left:auto 把"新建笔记"推到最后,自然形成「sort 组」与「主操作」的分组 */
gap: 2px; gap: 2px;
/* 顶 header 12px、底 6px —— 让工具条与列表的视觉关联 > 与 header 的关联 */ /* 顶 8px / 底 8px:sort 是侧栏首屏第一行,工具栏底边已经有 ~8px breathing,
padding: 6px 10px 8px 10px; 这里再给 8px 顶距足够拉开层次(10px → 8px 省 2px 给文件列表);
上下对称让 sort 行居中感更强,border-bottom 自然分隔 */
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
@@ -159,7 +166,11 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 5px; gap: 5px;
padding: 5px 8px; /* 横向 padding 8px → 6px:sort 标签"修改时间" 4 字 + icon 在默认 220px 侧栏下
需要 ~83px,原 5/8 padding + 4px row gap 把每个 sort 按钮挤到 80px,
「修改时间」就被截断成「修改时」。收到 6px 后单 sort 按钮宽度降到 76px,
省下来的 4×2=8px 让 sort 行整体宽松,可完整显示 4 字标签 */
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);
@@ -234,7 +245,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);
/* 留点上方间距,与 sidebar-header 拉开 */ /* 留点上方间距,与上方 sort/新建笔记行拉开 */
margin-top: var(--space-2); margin-top: var(--space-2);
/* 避免一行字过长在窄侧栏里截断为半个字 */ /* 避免一行字过长在窄侧栏里截断为半个字 */
word-break: break-word; word-break: break-word;
@@ -315,9 +326,17 @@
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 3px; width: 3px;
height: 60%; /* 60% → 70%:原 60% 在 13px name + 12px meta 共 31px 行高下占 18px,
加上色条本身的圆角显得"瘦长"。提到 70% (22px) 后,与 file-name +
meta 二行总高的占比更平衡,色条"贴"住视觉单元,不漂在 item 中央。
仍留 30% 上下气口,避免色条顶到 file-name / meta 行边沿 */
height: 70%;
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
background: var(--accent); background: var(--accent);
/* 加微 box-shadow 让色条在 accent-soft 底色上"浮"起来一点 —
纯色 accent 在 #2a325c 类 accent-soft 背景下,RGB 距离很短,
加 4px 投影能把色条对比拉高一档 */
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 40%, transparent);
} }
.file-name { .file-name {
@@ -377,6 +396,9 @@
} }
.sidebar-footer { .sidebar-footer {
/* 现在侧栏底部只剩 AI 一个按钮(文件夹管理已搬到状态栏左段,
见 [[feedback-folder-actions-to-statusbar]]),padding 收紧到 10px 12px
让按钮离文件列表稍近、视觉关系更紧 */
padding: 10px 12px; padding: 10px 12px;
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
display: flex; display: flex;
@@ -384,65 +406,6 @@
gap: 2px; gap: 2px;
} }
/* 侧栏底部视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开。
用 1px 细线 + 上下 6px 留白,避免紧贴导致 AI 按钮和文件夹链接"挤成一团" */
.sidebar-footer-sep {
height: 1px;
background: var(--border-subtle);
margin: 6px 0;
}
/* AI 主操作按钮在侧栏底部已不再与 footer-sep 紧邻(sep 在 AI 上方),
不再需要 margin-bottom: 0 来解耦 gap。 */
.link-subtle {
display: flex;
align-items: center;
gap: var(--space-2);
/* 11px 字号在低 DPI 上偏糊,提到 12px(fs-xs-readable)保持视觉小但不模糊;
颜色已用 secondary(2026-08 提亮后约 8.5:1 on dark) */
font-size: var(--fs-xs-readable);
color: var(--fg-secondary);
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
font-weight: 500;
min-width: 0; /* 允许 flex item 在窄侧栏(180px)收缩到内容宽度以下 */
}
/* 文字单行 + 溢出省略号:防止「切换数据文件夹…」在窄侧栏里折成两行 */
.link-subtle > span {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* link-subtle / footer-action:侧栏底部"打开数据目录 / 切换目录"等
二级链接。hover 时给 bg-hover + accent 文字提示"可点"——
与全局 a 一致用 --accent-text,让"hover 才出颜色"的反馈也维持文字版。 */
.link-subtle:hover {
background: var(--bg-hover);
color: var(--accent-text);
}
/* <button> 重置:与 .link-subtle 一致的"链接外观",但语义上是动作按钮 */
.footer-action {
width: 100%;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
}
/* 键盘 focus 高亮 —— 鼠标点击不画,键盘 Tab 才出现(hover 不等于 focus) */
.link-subtle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================ /* ============================================================
Viewer Viewer
============================================================ */ ============================================================ */
@@ -507,7 +470,10 @@
color: var(--accent); color: var(--accent);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
position: relative; position: relative;
/* 让 halo 缓慢呼吸,强化「等待/邀请」感 */ /* 让 halo 缓慢呼吸,强化「等待/邀请」感。
1.04 → 1.02:旧值在 4s 周期下肉眼明显能看出"放大再缩回",
用户长时间停留空状态(常见:刚启动、刚切数据文件夹)会被这个 4% 缩放
持续吸引注意力;改 2% 后仍保留"呼吸"暗示,但不再"主动拉视线" */
animation: empty-halo 4s ease-in-out infinite; animation: empty-halo 4s ease-in-out infinite;
} }
.empty-state-icon::after { .empty-state-icon::after {
@@ -528,12 +494,14 @@
} }
@keyframes empty-halo { @keyframes empty-halo {
/* 振幅 1 → 1.02(+2%):保留"呼吸"质感的同时不抢占视线。
透明度仍 0.95 ↔ 1,让 halo 在淡入淡出之间柔和过渡 */
0%, 100% { 0%, 100% {
transform: scale(1); transform: scale(1);
opacity: 0.95; opacity: 0.95;
} }
50% { 50% {
transform: scale(1.04); transform: scale(1.02);
opacity: 1; opacity: 1;
} }
} }
@@ -574,13 +542,20 @@
.empty-state .hint.hint-secondary { .empty-state .hint.hint-secondary {
color: var(--fg-tertiary); color: var(--fg-tertiary);
font-size: var(--fs-xs-readable); font-size: var(--fs-xs-readable);
margin-top: 6px; /* margin-top 6 → 10px:原 6px 让主 hint 与次 hint 之间几乎贴住,
加上两行 lh-relaxed (1.7) 后视觉上更挤。提到 10px 让两组 hint
之间形成明确的"主 → 次"层次(主是引导、次是补充操作) */
margin-top: 10px;
max-width: 380px; max-width: 380px;
/* 1.55 让次级提示的换行节奏与主 hint(1.6)接近,避免出现"次 hint 文字挤一行"的突兀感 */
line-height: 1.55;
} }
.empty-state .hint kbd { .empty-state .hint kbd {
/* .md 这种代码 chip 用 kbd 标签更语义化 */ /* .md 这种代码 chip 用 kbd 标签更语义化 */
padding: 2px 7px; /* padding 2/7 → 1/6:在 12px 字号下原 2/7 让 kbd chip 显得"鼓鼓的",
跟相邻的中文小字节奏不齐。收 1px 上下让 chip 更贴字高基线 */
padding: 1px 6px;
background: var(--bg-hover); background: var(--bg-hover);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -590,6 +565,8 @@
color: var(--fg-secondary); color: var(--fg-secondary);
font-weight: 600; font-weight: 600;
box-shadow: 0 1px 0 var(--border-subtle); box-shadow: 0 1px 0 var(--border-subtle);
/* 上下微 margin 让 kbd 与相邻文字垂直居中对齐(默认 baseline 会让 kbd 显得"沉") */
margin: 0 1px;
} }
.empty-state code { .empty-state code {
@@ -649,10 +626,11 @@ body[data-editor-mode="split"] .editor-pane {
flex-direction: column; flex-direction: column;
} }
/* 让编辑器像 viewer 一样「居中 + 限定行宽」,与预览区域视觉重量一致。 /* 让编辑器与预览区域保持一致的宽度处理:共用 --w-reader-max。
max-width 与 .markdown-body 共用 --w-reader-max,保证 gutter + 内容总宽 默认 token 设为 none(content 完全填满区域,最大限度显示内容),
不超过 reader max;margin auto 在更宽的窗口中把编辑器推到中部。 若未来想恢复"最优阅读宽度"上限,把 01-tokens.css 里的 --w-reader-max
窄窗口下 max-width 不再生效,编辑器自然占满全宽(不破坏小屏体验)。 */ 改回 px 值(如 720px)即可,不用改这里。margin auto 仍保留,方便
切回 px 上限后让内容在更宽的窗口里居中。 */
.editor-pane .cm-editor { .editor-pane .cm-editor {
max-width: var(--w-reader-max); max-width: var(--w-reader-max);
margin: 0 auto; margin: 0 auto;

View File

@@ -136,6 +136,49 @@
padding: 5px 4px; padding: 5px 4px;
} }
/* 文件夹管理按钮(状态栏左段,[[feedback-folder-actions-to-statusbar]]):
icon-only 紧凑按钮(24×24px),不抢 .status-path 的视觉中心;
tertiary 文字 + 透明底 → 鼠标 hover 才出"可点"反馈,与右段 count chip 同档
"辅助信息"权重。点击由 app.js 的 linkOpenFolder / linkSwitchFolder /
btnResetFolder 处理器接管(与旧侧栏底部同 ID 复用,wiring 不用改)。 */
.status-folder-btn {
flex: 0 0 auto;
/* 22 → 24:与右侧 .status-chip 的 5+1 padding + 13px 字 ≈ 25px 视觉高度
对齐,不再比 chip 矮一档。点击命中区也同步 +2px,22px 在 38px statusbar
里偏窄,鼠标移动精度不够容易点偏 */
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--fg-tertiary);
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
/* hover 补一抹微弱 accent 辉光:folder 按钮当前只有背景+文字色变,
在 24×24 的紧凑尺寸下需要更明显的"可点"反馈;用 color-mix 把
--accent 透明度压在 12% 避免抢戏,仅作为可点击提示 */
.status-folder-btn:hover {
background: var(--bg-hover);
color: var(--fg-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 12%, transparent);
}
.status-folder-btn:active {
transform: scale(0.92);
}
.status-folder-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* 状态栏 chip:模式 / 字数 / 滚动进度 */ /* 状态栏 chip:模式 / 字数 / 滚动进度 */
.status-chip { .status-chip {
flex: 0 0 auto; flex: 0 0 auto;

View File

@@ -376,6 +376,20 @@
text-decoration: underline; text-decoration: underline;
} }
/* btn-link 之前没有 disabled 样式 ——「恢复默认」等按钮在「已经是默认」时
会陷入 disabled 状态却仍是 accent 色,视觉上像可点;降到 fg-tertiary +
取消 hover 反馈保持一致。 */
.btn-link:disabled,
.btn-link[disabled] {
color: var(--fg-tertiary);
cursor: not-allowed;
}
.btn-link:disabled:hover,
.btn-link[disabled]:hover {
color: var(--fg-tertiary);
text-decoration: none;
}
.form-path-row { .form-path-row {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
@@ -416,6 +430,17 @@
与上面的 form-row 修复思路一致:margins and gaps 二选一,避免叠加。 */ 与上面的 form-row 修复思路一致:margins and gaps 二选一,避免叠加。 */
} }
/* section header 内的右侧动作按钮(如「恢复默认」)—— margin-left:auto
让它贴到 header 右边;同时 align-self:center 让按钮纵向居中于 header
的图标 + 标题块,避免按钮顶部紧贴 header 顶端。覆盖 .btn-link 的固定
margin-left: var(--space-2)(在 text-flow 里有用,header 里靠 gap 即可)。 */
.settings-section-action {
margin-left: auto;
align-self: center;
/* 按钮的字体略小一点,与标题形成"标题 / 次要操作"层级 */
font-size: var(--fs-xs-readable);
}
.settings-section-icon { .settings-section-icon {
flex: 0 0 auto; flex: 0 0 auto;
/* 28 → 24:再缩小一档,配合 14px section padding,整张卡片视觉重量更轻; /* 28 → 24:再缩小一档,配合 14px section padding,整张卡片视觉重量更轻;
@@ -521,7 +546,9 @@
/* 11 → 12:用 --fs-xs-readable 解决"看不清",仍走 --fg-secondary 保对比度 */ /* 11 → 12:用 --fs-xs-readable 解决"看不清",仍走 --fg-secondary 保对比度 */
font-size: var(--fs-xs-readable); font-size: var(--fs-xs-readable);
color: var(--fg-secondary); color: var(--fg-secondary);
margin: 1px 0 0 0; /* 1 → 3:与上方 15px 标题之间留出更明显的呼吸;之前 1px 让副标题紧贴标题,
视觉上像同一段文字的两行而非"标题 + 描述"的层级关系。 */
margin: 3px 0 0 0;
line-height: 1.4; line-height: 1.4;
} }
@@ -677,7 +704,10 @@
.picker-group-value { .picker-group-value {
font-size: var(--fs-xs-readable); font-size: var(--fs-xs-readable);
color: var(--fg-tertiary); /* --fg-tertiary 在部分调色板(如 sunset)对比度接近 3.5,低于 WCAG AA;
改用 --fg-secondary 后即使在 sunset 上也稳定 ≥ 4.5。
"选中数值"是用户决策依据之一,不该比副标题还暗。 */
color: var(--fg-secondary);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 500; font-weight: 500;
@@ -742,12 +772,14 @@
} }
/* 卡片底部 label 容器共享样式 —— 不论 palette / fontsize / lineheight / ai-provider, /* 卡片底部 label 容器共享样式 —— 不论 palette / fontsize / lineheight / ai-provider,
视觉重量(字号 11px + tabular-nums + mono)在所有 picker 间一致, 视觉重量(字号 12px + tabular-nums + mono)在所有 picker 间一致,
让用户看到所有 picker 都是同一套"卡片 + 数值标签"语言。 */ 让用户看到所有 picker 都是同一套"卡片 + 数值标签"语言。
--fs-xs-readable (12px) 比 --fs-xs (11px) 在低 DPI 屏幕更可读,
picker 卡片是用户主要点击区域,label 字号不低于 hint。 */
.picker-card-label, .picker-card-label,
.fontsize-card-label, .fontsize-card-label,
.lineheight-card-label { .lineheight-card-label {
font-size: var(--fs-xs); font-size: var(--fs-xs-readable);
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--fg-tertiary); color: var(--fg-tertiary);
font-weight: 500; font-weight: 500;
@@ -980,7 +1012,9 @@
让"14/22px 字号"和"1.5/2.0 行距"的差异在多元素上同时可见。 让"14/22px 字号"和"1.5/2.0 行距"的差异在多元素上同时可见。
============================================================ */ ============================================================ */
.md-preview { .md-preview {
margin-top: 12px; /* 与上方 form-row 的间距由 .settings-section 的 flex gap (12px) 统一管理;
显式 margin-top 会与 gap 叠加成 24px,反而比 picker 卡片之间的间距还宽,
让"选完字号 → 看到预览"这一步视觉跳跃过大。 */
padding: 14px 18px; padding: 14px 18px;
background: var(--bg-viewer); background: var(--bg-viewer);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@@ -989,7 +1023,9 @@
} }
.md-preview-title { .md-preview-title {
font-size: 10px; /* 10px 不在 token 系统内,且在低 DPI 屏容易发糊;改用 --fs-xs (11px)
仍在 token 体系内,跟 callout 副标题等小字保持一致的视觉重量。 */
font-size: var(--fs-xs);
font-weight: 600; font-weight: 600;
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;

View File

@@ -1,5 +1,5 @@
// 工具栏组件 // 工具栏组件
// 绑定主题切换 / 置顶 / 设置 / 窗口控件按钮的事件。 // 绑定主题切换 / 置顶 / 设置 / 窗口控件 / 关闭当前文件 按钮的事件。
// //
// 与 Todo List 的 toolbar.js 区别:去掉了「添加 todo」+ 搜索框 + 视图切换, // 与 Todo List 的 toolbar.js 区别:去掉了「添加 todo」+ 搜索框 + 视图切换,
// 只留下窗口控制 + 应用级开关。 // 只留下窗口控制 + 应用级开关。
@@ -15,8 +15,10 @@ export class Toolbar {
* @param {() => void} options.onMinimize * @param {() => void} options.onMinimize
* @param {() => void} options.onToggleMaximize * @param {() => void} options.onToggleMaximize
* @param {() => void} options.onClose * @param {() => void} options.onClose
* @param {() => void} [options.onCloseFile] - 关闭当前文件按钮(无文件时按钮 disabled,
* 这里仍挂 listener;disabled 浏览器层面不触发 click,但与未来 re-enable 行为一致)
*/ */
constructor({ elements, onThemeClick, onPinClick, onSettingsClick, onResetLayoutClick, onMinimize, onToggleMaximize, onClose }) { constructor({ elements, onThemeClick, onPinClick, onSettingsClick, onResetLayoutClick, onMinimize, onToggleMaximize, onClose, onCloseFile }) {
this.elements = elements; this.elements = elements;
if (elements.btnTheme && onThemeClick) { if (elements.btnTheme && onThemeClick) {
@@ -40,5 +42,10 @@ export class Toolbar {
if (elements.btnClose && onClose) { if (elements.btnClose && onClose) {
elements.btnClose.addEventListener('click', onClose); elements.btnClose.addEventListener('click', onClose);
} }
// 关闭当前文件:独立 onCloseFile(与 btnClose 区分 —— btnClose 关的是窗口,
// btnCloseFile 关的是"当前打开的文件",语义不一样、回调也不一样)。
if (elements.btnCloseFile && onCloseFile) {
elements.btnCloseFile.addEventListener('click', onCloseFile);
}
} }
} }

View File

@@ -139,31 +139,6 @@ export function escapeAttr(s) {
return escapeHtml(s); return escapeHtml(s);
} }
/**
* 当前是否在 Windows 上 —— 用于大小写不敏感比较。
*
* 优先用 navigator.userAgentData(Chromium 90+ 官方接口,未来主流);
* 退回 navigator.platform(已废弃但 Electron 仍可用);
* 再退回 UA 字符串嗅探。
*
* 注意:本函数跑在 renderer 里,process 不存在 —— 不能写 process.platform。
*
* @returns {boolean}
*/
function isWindows() {
if (typeof navigator === 'undefined') return false;
// Chromium 90+: userAgentData.platform 形如 'Windows' / 'macOS' / 'Linux'
if (navigator.userAgentData && typeof navigator.userAgentData.platform === 'string') {
return navigator.userAgentData.platform.toLowerCase().includes('win');
}
// navigator.platform 已被 W3C 标记废弃,但 Chromium 内核仍支持;Electron 渲染端可用
if (typeof navigator.platform === 'string' && navigator.platform) {
if (/^win/i.test(navigator.platform)) return true;
}
// 兜底:UA 字符串嗅探
return /Windows NT/i.test(navigator.userAgent || '');
}
/** /**
* 检查 child 路径是否在 parent 目录之内(防御性边界检查)。 * 检查 child 路径是否在 parent 目录之内(防御性边界检查)。
* 不依赖文件系统(不解析 symlink),只做字符串前缀比对。 * 不依赖文件系统(不解析 symlink),只做字符串前缀比对。
@@ -185,7 +160,10 @@ export function isPathInside(child, parent) {
let a = norm(parent); let a = norm(parent);
let b = norm(child); let b = norm(child);
if (!b) return false; // 空 child 路径不视为合法 if (!b) return false; // 空 child 路径不视为合法
if (isWindows()) { // 与 pathsEqual 策略对齐 —— Windows + macOS(APFS 默认)都按大小写不敏感,
// Linux 严格相等。否则主进程 isWithinDataDir(main.js 的同名守卫)和 renderer
// 侧边界检查在 macOS 上会出现一过 allow 一过 reject 的不一致。
if (isCaseInsensitiveFs()) {
a = a.toLowerCase(); a = a.toLowerCase();
b = b.toLowerCase(); b = b.toLowerCase();
} }
@@ -201,14 +179,20 @@ export function isPathInside(child, parent) {
/** /**
* 平台相关的路径相等比较。 * 平台相关的路径相等比较。
* *
* Windows 路径大小写不敏感(NTFS 默认),但 macOS / Linux 大小写敏感。 * Windows(NTFS)/ macOS(APFS 默认 case-insensitive)路径大小写不敏感,
* 同样两条路径在 Windows 上要求 .toLowerCase() 相等;其他平台要求严格相等。 * 但 Linux(ext4 等默认大小写敏感)严格相等。
* 同样两条路径在前两类平台要求 .toLowerCase() 相等;Linux 上要求严格相等。
* *
* 用法:sidebar / file-list 把当前打开文件(state.currentFile.path,可能是 * 用法:sidebar / file-list 把当前打开文件(state.currentFile.path,可能是
* 用户输入的 case)与磁盘条目(fs 返回的真实 case)做匹配时, * 用户输入的 case)与磁盘条目(fs 返回的真实 case)做匹配时,
* `state.currentFile.path === entry.path` 在 Windows 上经常 false, * `state.currentFile.path === entry.path` 在 Windows / macOS 上经常 false,
* 导致高亮 / 选中态错位。统一走 pathsEqual 兜住。 * 导致高亮 / 选中态错位。统一走 pathsEqual 兜住。
* *
* 与主进程 main/fs-watcher.js 的 pathKey 策略保持一致 —— 双方都用
* 「Windows / darwin 折叠,Linux 严格」。否则 main 侧 Map key 命中而 renderer
* 侧路径比较 false,会出现「主进程 self-write filter 命中了,但 renderer
* 还是把同一文件当成两份」的幽灵 bug。
*
* @param {string|null|undefined} a * @param {string|null|undefined} a
* @param {string|null|undefined} b * @param {string|null|undefined} b
* @returns {boolean} * @returns {boolean}
@@ -216,7 +200,35 @@ export function isPathInside(child, parent) {
export function pathsEqual(a, b) { export function pathsEqual(a, b) {
if (a === b) return true; if (a === b) return true;
if (!a || !b) return false; if (!a || !b) return false;
return isWindows() ? a.toLowerCase() === b.toLowerCase() : a === b; return isCaseInsensitiveFs() ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* 当前文件系统是否大小写不敏感(Windows NTFS / macOS APFS 默认)。
*
* 与 isWindows 不同 —— Linux 上 ext4 / xfs 大小写敏感,但 macOS APFS(默认)
* 是大小写不敏感的。APFS 也支持 case-sensitive 卷,但作为桌面 Electron 应用
* 的最常见部署形态我们按默认 case-insensitive 处理,行为与 macOS Finder、
* Terminal、`open` 命令一致。
*
* 注意:本函数跑在 renderer 里,process 不存在 —— 用 navigator.userAgentData
* 替代 process.platform。
*
* @returns {boolean}
*/
function isCaseInsensitiveFs() {
if (typeof navigator === 'undefined') return false;
// Chromium 90+: userAgentData.platform 形如 'Windows' / 'macOS' / 'Linux'
if (navigator.userAgentData && typeof navigator.userAgentData.platform === 'string') {
const p = navigator.userAgentData.platform.toLowerCase();
if (p.includes('win') || p.includes('macos') || p.includes('mac')) return true;
}
if (typeof navigator.platform === 'string' && navigator.platform) {
if (/^(win|mac)/i.test(navigator.platform)) return true;
}
// 兜底:UA 字符串嗅探
const ua = navigator.userAgent || '';
return /Windows NT|Macintosh|Mac OS X/i.test(ua);
} }
/** /**

View File

@@ -542,6 +542,57 @@ describe('createAiProxy.runEdit —— API Key 脱敏 + URL 清洗', () => {
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/); expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
}); });
// M2 follow-up (Round 14):percent-encoded userinfo 也必须剥。
// 旧 regex `^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@` 不接受 `%` 之外的字符,但
// percent-encoded 形式 `user%40evil.com:pass@host` 字面上没有 `@` —— fetch
// 在解析 URL 时把 `%40` 还原成 `@`,再按 Basic Auth 发送,等于把
// "user%40evil.com:pass" 整段写到 Authorization 头,敏感凭据直发中转。
it('M2 percent-encoded:baseURL 含 %40 编码的 userinfo → 实际 fetch 的 URL 已剥 userinfo', async () => {
let captured = null;
const fetchImpl = async (url) => {
captured = url;
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://hunter2%40evil.com:secret@relay.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2pct' });
expect(r.ok).toBe(true);
// 剥 userinfo 之后只剩 host + path,不能再含 hunter2 / secret 字样
expect(captured).toBe('https://relay.example.com/v1/chat/completions');
expect(captured).not.toMatch(/hunter2/);
expect(captured).not.toMatch(/secret/);
});
// M2 follow-up (Round 14):sanitizeUrl 也走 new URL 解析路径,percent-encoded
// userinfo 在错误日志里也必须被遮罩(与 fetch URL 行为一致)。
it('M2 percent-encoded:sanitizeUrl 输出(HTTP 错误日志)已剥 percent-encoded userinfo', async () => {
const logs = [];
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://user%40evil.com:pass@api.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl: async () => new Response('oops', { status: 500 }),
log: (event, data) => logs.push({ event, data }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2pctlog' });
expect(r.ok).toBe(false);
const httpErrLog = logs.find((l) => l.event === 'ai:http_error');
expect(httpErrLog).toBeDefined();
expect(httpErrLog.data.url).not.toMatch(/user/);
expect(httpErrLog.data.url).not.toMatch(/pass/);
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
});
// M3 fix:HTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本 // M3 fix:HTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本
// (错误信息里把 header 序列化成字符串,AI proxy / 网关常见),旧 regex 遇到 `"` // (错误信息里把 header 序列化成字符串,AI proxy / 网关常见),旧 regex 遇到 `"`
// 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。 // 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。
@@ -733,10 +784,13 @@ describe('max_tokens 上限按模型路由', () => {
} }
// ---- Anthropic:5 系与 4.6+ 拿到 32k ---- // ---- Anthropic:5 系与 4.6+ 拿到 32k ----
// Round 13 fix (H4):新增 claude-haiku-5-*(旧正则漏命中落到 16k 档)。
it.each([ it.each([
'claude-opus-5', 'claude-opus-5',
'claude-sonnet-5', 'claude-sonnet-5',
'claude-fable-5', 'claude-fable-5',
'claude-haiku-5',
'claude-haiku-5-20251001',
'claude-opus-4-8', 'claude-opus-4-8',
'claude-opus-4-7', 'claude-opus-4-7',
'claude-sonnet-4-6', 'claude-sonnet-4-6',
@@ -757,13 +811,16 @@ describe('max_tokens 上限按模型路由', () => {
expect(body.max_tokens).toBe(16_384); expect(body.max_tokens).toBe(16_384);
}); });
// ---- Anthropic:claude-3 老家族与未知模型保守 8k ---- // ---- Anthropic:claude-3 老家族 4k(官方上限),未知模型 8k ----
// Round 13 fix (H2):claude-3 老家族官方 max output = 4096。旧值 8192
// 会让 opus-3 / sonnet-3 / haiku-3 被服务端 400 拒绝。
it.each([ it.each([
'claude-3-opus-20240229', 'claude-3-opus-20240229',
'claude-3-haiku-20240307', 'claude-3-haiku-20240307',
])('Anthropic %s(claude-3 老家族)→ max_tokens 8192', async (model) => { 'claude-3-sonnet-20240229',
])('Anthropic %s(claude-3 老家族)→ max_tokens 4096', async (model) => {
const body = await capturedBody('anthropic', model); const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(8_192); expect(body.max_tokens).toBe(4_096);
}); });
it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192,最大限度兼容', async () => { it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192,最大限度兼容', async () => {

View File

@@ -545,22 +545,22 @@ describe('resolveDataDirOrFallback(auto-fallback 2026-08)', () => {
}); });
describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => { describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
// 用 welcome.md 自己作为「已种子」标记,不再写 .notes-seeded 隐藏文件 // 用 README.md 自己作为「已种子」标记,不再写 .notes-seeded 隐藏文件
// —— 用户数据目录应该只放用户的内容。 // —— 用户数据目录应该只放用户的内容。
it('welcome.md 已存在 → 跳过(幂等)', async () => { it('README.md 已存在 → 跳过(幂等)', async () => {
configStore.init(); configStore.init();
const defaultDir = join(mockHomeDir, 'Notes'); const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true }); mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8'); writeFileSync(join(defaultDir, 'README.md'), '用户改过的内容', 'utf-8');
await configStore.ensureDefaultDataDir(); await configStore.ensureDefaultDataDir();
// welcome.md 存在 → 不重新复制 // README.md 存在 → 不重新复制
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容'); expect(readFileSync(join(defaultDir, 'README.md'), 'utf-8')).toBe('用户改过的内容');
}); });
it('默认目录已有其它 .md → 不复制 welcome.md,不写隐藏文件', async () => { it('默认目录已有其它 .md → 不复制任何 onboarding,不写隐藏文件', async () => {
configStore.init(); configStore.init();
const defaultDir = join(mockHomeDir, 'Notes'); const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true }); mkdirSync(defaultDir, { recursive: true });
@@ -568,23 +568,25 @@ describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
await configStore.ensureDefaultDataDir(); await configStore.ensureDefaultDataDir();
// 不复制 welcome.md,也不创建 .notes-seeded(用户数据目录应该干净) // 不复制 README.md,也不创建 .notes-seeded(用户数据目录应该干净)
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(false); expect(existsSync(join(defaultDir, 'README.md'))).toBe(false);
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false); expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
}); });
it('默认目录为空 → 复制 welcome.md(不创建隐藏文件)', async () => { it('默认目录为空 → 复制 README.md(不创建隐藏文件)', async () => {
configStore.init(); configStore.init();
const defaultDir = join(mockHomeDir, 'Notes'); const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true }); mkdirSync(defaultDir, { recursive: true });
await configStore.ensureDefaultDataDir(); await configStore.ensureDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true); // README.md 是 sentinel + 唯一 onboarding 文件
expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
// 不写隐藏文件
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false); expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
}); });
it('默认目录不存在 → 自动创建 + 复制', async () => { it('默认目录不存在 → 自动创建 + 复制 README.md', async () => {
configStore.init(); configStore.init();
// 不 mkdir,让 ensure 自己去建 // 不 mkdir,让 ensure 自己去建
@@ -592,7 +594,7 @@ describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
const defaultDir = join(mockHomeDir, 'Notes'); const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(true); expect(existsSync(defaultDir)).toBe(true);
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true); expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
}); });
it('用户已设 customDir → 仍种子默认目录(auto-fallback 需要)', async () => { it('用户已设 customDir → 仍种子默认目录(auto-fallback 需要)', async () => {
@@ -606,20 +608,20 @@ describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
const defaultDir = join(mockHomeDir, 'Notes'); const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(true); expect(existsSync(defaultDir)).toBe(true);
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true); expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
}); });
it('重复调用幂等(welcome.md 命中后第二次不覆盖用户内容)', async () => { it('重复调用幂等(README.md 命中后第二次不覆盖用户内容)', async () => {
configStore.init(); configStore.init();
const defaultDir = join(mockHomeDir, 'Notes'); const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true }); mkdirSync(defaultDir, { recursive: true });
await configStore.ensureDefaultDataDir(); await configStore.ensureDefaultDataDir();
// 用户改 welcome.md 后再调一次 ensure → 内容应保留 // 用户改 README.md 后再调一次 ensure → 内容应保留
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8'); writeFileSync(join(defaultDir, 'README.md'), '用户改过的内容', 'utf-8');
await configStore.ensureDefaultDataDir(); await configStore.ensureDefaultDataDir();
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容'); expect(readFileSync(join(defaultDir, 'README.md'), 'utf-8')).toBe('用户改过的内容');
}); });
}); });
@@ -664,7 +666,7 @@ describe('ensureDefaultDataDirSync(sync mkdir)', () => {
it('resolveDataDirOrFallback fallback 路径也自动 ensure 默认目录', async () => { it('resolveDataDirOrFallback fallback 路径也自动 ensure 默认目录', async () => {
// U 盘掉线 → custom 失效 → runtime fallback 到默认。如果默认也从未 // U 盘掉线 → custom 失效 → runtime fallback 到默认。如果默认也从未
// 建过/用过,要立刻能列出文件(welcome.md 由异步 ensure 种子)。 // 建过/用过,要立刻能列出文件(README.md 由异步 ensure 种子)。
configStore.init(); configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-ensure-')); const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-ensure-'));
await configStore.saveConfig({ dataDir: realDir }); await configStore.saveConfig({ dataDir: realDir });
@@ -679,7 +681,7 @@ describe('ensureDefaultDataDirSync(sync mkdir)', () => {
expect(existsSync(defaultDir)).toBe(true); expect(existsSync(defaultDir)).toBe(true);
// 异步种子:fire-and-forget,等 in-flight 完成 // 异步种子:fire-and-forget,等 in-flight 完成
await configStore.scheduleEnsureDefaultDataDir(); await configStore.scheduleEnsureDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true); expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
}); });
}); });
@@ -708,7 +710,7 @@ describe('scheduleEnsureDefaultDataDir(fire-and-forget 去重)', () => {
mkdirSync(defaultDir, { recursive: true }); mkdirSync(defaultDir, { recursive: true });
await configStore.seedDefaultDataDir(); await configStore.seedDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true); expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
}); });
}); });

View File

@@ -125,4 +125,28 @@ describe('editorThemeSpec background overrides (oneDark / base theme 互斥)', (
expect(v).toMatch(/var\(--fg-primary\)/); expect(v).toMatch(/var\(--fg-primary\)/);
expect(v).toMatch(/!important/); expect(v).toMatch(/!important/);
}); });
// base theme 给 .cm-line 设了 `padding: 0 2px 0 6px`,左侧 6px 内边距会
// 形成「行首死区」——鼠标点击这一段 6px 区域时光标无法可靠地放到该行最前面。
// 我们的覆盖必须是左内边距 0 + !important(与 base theme 同特异性 (0,1,0),
// 源序胜负不可靠,!important 兜底)。
it('.cm-line padding 必须覆盖 base theme 的左内边距 6px → 0(带 !important)', () => {
const v = __editorThemeSpecForTests.linePadding;
// 期望值形如 "0 2px 0 0 !important",左 0、右 2px、上 0、下 0
expect(v).toMatch(/^0\s+2px\s+0\s+0/);
expect(v).toMatch(/!important/);
});
// basicSetup 的 foldGutter() 渲染的 chevron 列默认宽度 ~16px,会从
// .cm-gutters 容器(CSS width 钉死 32px)溢出 ~10px,盖在 cm-line 的开头
// 字符上,导致点击「行首最前面」时 hit target 是 gutter SPAN 而非 cm-line
// —— CM6 监听挂在 view.contentDOM 上,gutter 不在 contentDOM 子树内,
// mousedown 不会冒泡过去,光标位置不变。
// 我们用 display:none + !important 隐藏 chevron 渲染部分。foldGutter()
// 的 keymap(Ctrl+Shift+[ / ])仍然有效,只是视觉上看不到 chevron。
it('.cm-foldGutter 必须 display:none(带 !important)— 隐藏溢出 chevron', () => {
const v = __editorThemeSpecForTests.foldGutterDisplay;
expect(v).toMatch(/^none/);
expect(v).toMatch(/!important/);
});
}); });

View File

@@ -80,14 +80,14 @@ describe('FileList 搜索 / 排序行为', () => {
feed([ feed([
{ name: 'alpha.md', path: '/d/alpha.md', mtimeMs: 300 }, { name: 'alpha.md', path: '/d/alpha.md', mtimeMs: 300 },
{ name: 'beta.md', path: '/d/beta.md', mtimeMs: 100 }, { name: 'beta.md', path: '/d/beta.md', mtimeMs: 100 },
{ name: 'welcome.md', path: '/d/welcome.md', mtimeMs: 50 }, { name: 'README.md', path: '/d/README.md', mtimeMs: 50 },
]); ]);
// 搜 'wel' 应该让 welcome.md 排前,不管它的 mtime 是最旧的 // 搜 'read' 应该让 README.md 排前,不管它的 mtime 是最旧的
searchInput.value = 'wel'; searchInput.value = 'read';
searchInput.dispatchEvent(new Event('input')); searchInput.dispatchEvent(new Event('input'));
// 等 debounce flush(100ms) // 等 debounce flush(100ms)
return new Promise((r) => setTimeout(r, 150)).then(() => { return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()[0]).toBe('welcome.md'); expect(renderedNames()[0]).toBe('README.md');
}); });
}); });

View File

@@ -1030,4 +1030,75 @@ describe('atomicWriteFile fsync 行为 (audit Round 4 P0-1)', () => {
const leftover = readdirSync(dir).filter((f) => f.includes('.tmp.')); const leftover = readdirSync(dir).filter((f) => f.includes('.tmp.'));
expect(leftover).toEqual([]); expect(leftover).toEqual([]);
}); });
});
// audit fix (Tmp-L2):ATOMIC_TMP_RE 之前太宽,把用户合法文件(`report.tmp.<ts>.<ts>.<rand>`)
// 也当成孤儿静默 unlink。这组测试钉死「仅匹配 EDITABLE_EXTS.tmp.<pid>.<ts>.<rand>」的边界。
describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () => {
it('匹配:<basename>.<editableExt>.tmp.<pid>.<ts>.<rand>', () => {
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.12345.1700000000000.abcdef')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('a.py.tmp.999.1700000000000.a1b2c3')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('sub_deep_name.json.tmp.7.1700000000000.zz9xka')).toBe(true);
});
it('拒绝:用户文件 report.tmp.<ts>.<ts>.<rand>(无 EDITABLE_EXT 前缀)', () => {
// 这正是误伤 bug 的源头 —— 没有 md / py / json 等扩展名前缀,
// 旧版 `/\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]+$/i` 会把这条匹配上。
expect(fileOps.ATOMIC_TMP_RE.test('report.tmp.2024.1234567.abcdef')).toBe(false);
expect(fileOps.ATOMIC_TMP_RE.test('backup.tmp.1700000000000.1700000000000.abcdef')).toBe(false);
});
it('拒绝:binary / 非白名单扩展名', () => {
expect(fileOps.ATOMIC_TMP_RE.test('photo.jpg.tmp.123.1700000000000.abcdef')).toBe(false);
expect(fileOps.ATOMIC_TMP_RE.test('archive.zip.tmp.123.1700000000000.abcdef')).toBe(false);
});
it('拒绝:config-store 的 config.json.tmp.* 也在范围外(由它自己管)', () => {
// config-store 的旧 tmp 不被 fileOps 清理 —— 这里只确认 ATOMIC_TMP_RE 不会
// 误伤。注意 config.json 不在 EDITABLE_EXTS(json 在)是,所以这条 *会* 匹配上;
// 故 cleanupStaleFileTmpFiles 内部排除逻辑在更上层处理。tmp 段格式补 digit.ts.rand 即可。
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000.abcdef')).toBe(true);
// 但 `config.json.tmp.123` 这种三段缺一不可被拒(rand 不在)。
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000')).toBe(false);
});
it('拒绝:长度字段结构错误(rand 大于 8 字符 / pid 非数字)', () => {
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.abc.1700000000000.abcdef')).toBe(false); // pid 非数字
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.123.abc.abcdef')).toBe(false); // ts 非数字
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.123.1700000000000.UPPERCASE')).toBe(false); // rand 范围
});
});
// audit fix (Tmp-L2):端到端验证 cleanupStaleFileTmpFiles 不会清理非自家产物
describe('cleanupStaleFileTmpFiles(端到端 audit Tmp-L2)', () => {
it('用户文件 report.tmp.<ts>.<ts>.<rand> 即使旧到 24h+ 也不被删', async () => {
// 写入一个旧 mtime 的文件(远大于 CROSS_PID_STALE_THRESHOLD_MS = 24h)。
const userFile = join(dir, 'report.tmp.1700000000000.1700000000000.aabbcc');
writeFileSync(userFile, 'user data', 'utf-8');
const past = Date.now() - (48 * 60 * 60 * 1000); // 48 小时前
const { utimesSync } = require('fs');
utimesSync(userFile, past / 1000, past / 1000);
await fileOps.cleanupStaleFileTmpFiles(dir);
// 必须仍在 —— 旧版会因为 tmp 段格式匹配而删掉它。
const { existsSync } = require('fs');
expect(existsSync(userFile)).toBe(true);
});
it('真正属于自家 atomicWriteFile 产物的孤儿 .md.tmp.<pid>.<ts>.<rand> 会按阈值清理', async () => {
// 模拟本进程 PID + 一个非常旧的时间戳(>24h)
const orphanName = `note.md.tmp.${process.pid}.1000000000000.abc123`;
const orphanFile = join(dir, orphanName);
writeFileSync(orphanFile, 'orphan', 'utf-8');
const past = Date.now() - (48 * 60 * 60 * 1000);
const { utimesSync } = require('fs');
utimesSync(orphanFile, past / 1000, past / 1000);
await fileOps.cleanupStaleFileTmpFiles(dir);
// 自家 PID 时 STALE_THRESHOLD_MS = 60s,48h 自然删除
const { existsSync } = require('fs');
expect(existsSync(orphanFile)).toBe(false);
});
}); });

View File

@@ -52,6 +52,16 @@ describe('friendlyFsError —— 已知 errno 映射', () => {
it('ENOTEMPTY → 目标文件夹不为空', () => { it('ENOTEMPTY → 目标文件夹不为空', () => {
expect(friendlyFsError('ENOTEMPTY')).toBe('目标文件夹不为空'); expect(friendlyFsError('ENOTEMPTY')).toBe('目标文件夹不为空');
}); });
it('ENOENT → 目标路径不存在', () => {
expect(friendlyFsError('ENOENT')).toMatch(/不存在/);
});
// audit fix (Round 17 H2):FS_TIMEOUT 是 main withTimeout() 自造 code,
// 不是 Node fs 内置 errno。SMB / OneDrive 挂死时透传,renderer 走这里。
it('FS_TIMEOUT → 磁盘暂时不可用文案', () => {
expect(friendlyFsError('FS_TIMEOUT')).toMatch(/超时|磁盘/);
});
}); });
describe('friendlyFsError —— fallback 行为', () => { describe('friendlyFsError —— fallback 行为', () => {

View File

@@ -0,0 +1,182 @@
// main/fs-watcher.js 单测(Case-M1 audit fix + Round 13 fix 回归)
//
// 不在仓库里的测试 —— fs-watcher 600+ 行(数据目录监听 + 事件防抖 + 自写过滤 +
// 错误闩锁 + 退避重连 + generation token)一直没单元测试。这次先补 case-insensitive
// self-write filter 这一条(核心 bug 修复的回归锚点),其他路径(轮询 / 重连 / 错误
// 抑制)需要 jsdom + 真实 fs.watch 集成,留给后续 Story。
//
// 覆盖:
// - markExpectedWrite / isSelfWrite:登记大小写 X 的路径,命中查询大小写 Y 也能识别
// - 与 case-sensitive 的 Linux 行为对照(platform-based 切换)
// - 过期条目被惰性 GC
// - updateSnapshot 轮询路径 push 回归(Round 13 fix fce2d83 修复的 bug):
// 旧实现 baseline 在 updateSnapshot 先写、emitFilesChanged 又读,看到同一
// snapshot 时 if 块不进、safeSend 跳过 —— 2s 轮询(fs.watch 不可靠环境的
// 唯一兜底)检测到的改动永远不通知 renderer。
import { describe, it, expect, vi } from 'vitest';
const { createFsWatcher } = require('../../main/fs-watcher.js');
/**
* 构造一个最小化的 fsWatcher 实例用于单元测试。
* 不实际订阅 fs.watch、轮询或 IPC —— 我们只关心 self-write filter 的 Map 行为。
*/
function makeWatcher() {
return createFsWatcher({
// 不用 —— 只用 mark/isSelfWrite
getMainWindow: () => null,
resolveDataDir: () => null,
scanDir: async () => ({ ok: true, entries: [] }),
});
}
/**
* 构造带 ipc spy 的 fsWatcher,用于验证 updateSnapshot regular path 是否
* 真的把列表变化 push 到 renderer。getMainWindow 返回一个虚拟 BrowserWindow,
* webContents.send 被替换为 vi.fn(),方便断言调用次数 + payload。
*/
function makeWatcherWithIpc(scanResults) {
const sends = [];
const fakeWin = {
isDestroyed: () => false,
webContents: { send: (channel, payload) => sends.push({ channel, payload }) },
};
let scanIdx = 0;
const w = createFsWatcher({
getMainWindow: () => fakeWin,
resolveDataDir: () => '/data',
scanDir: async () => scanResults[scanIdx++] || { ok: true, entries: [] },
});
return { watcher: w, sends, fakeWin };
}
describe('fs-watcher markExpectedWrite / isSelfWrite (Case-M1)', () => {
it('同 case 命中(sanity)', () => {
const w = makeWatcher();
w.markExpectedWrite('C:\\Notes\\foo.md', 1000);
expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(true);
});
it('Windows / darwin:跨 case 命中(Case-M1 修复)', () => {
// audit fix (Case-M1):fs.watch 在 Windows 上报的 filename 与 caller 写入的
// case 可能不一致(取决于 ReadDirectoryChangesW / NTFS 短名 8.3 / 同步盘
// 大小写敏感层)。strict Map.get 会漏命中 → renderer 把自写当成外部修改
// 误报,用户选「用磁盘版」会覆盖刚保存的内容。
const w = makeWatcher();
const mixed = process.platform;
// 在 Windows / darwin 上模拟「登记 Foo.md,watcher 上报 foo.md」
w.markExpectedWrite(`C:\\Notes\\Foo.md`, 1000);
const lookups = (process.platform === 'win32' || process.platform === 'darwin')
? [`C:\\Notes\\foo.md`, `C:\\Notes\\FOO.md`, `c:\\notes\\Foo.md`, `C:/Notes/Foo.md`]
: [`C:\\Notes\\Foo.md`];
for (const alt of lookups) {
expect(w.isSelfWrite(alt)).toBe(true);
}
expect(mixed).toBeTruthy(); // sanity guard
});
it('Linux(case-sensitive 平台):跨 case 不命中(旧行为保留)', () => {
// 仅当跑在 Linux 上才相关:默认模拟 darwin 风格的 expect 命中安全,
// 但 Linux 上 case-different 路径不应被误当作自写。
if (process.platform === 'linux') {
const w = makeWatcher();
w.markExpectedWrite('/tmp/Foo.md', 1000);
expect(w.isSelfWrite('/tmp/Foo.md')).toBe(true);
expect(w.isSelfWrite('/tmp/foo.md')).toBe(false);
} else {
// 在非 Linux 上跳过,但保持测试存在以便 CI 多平台矩阵触达。
expect(true).toBe(true);
}
});
it('未登记的路径:isSelfWrite 返回 false', () => {
const w = makeWatcher();
expect(w.isSelfWrite('C:\\Notes\\unknown.md')).toBe(false);
w.markExpectedWrite('C:\\Notes\\expected.md', 1000);
expect(w.isSelfWrite('C:\\Notes\\other.md')).toBe(false);
});
it('clearExpectedWrites 后所有命中失效', () => {
const w = makeWatcher();
w.markExpectedWrite('C:\\Notes\\foo.md', 1000);
expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(true);
// markExpectedWrite 的 expiresAt = Date.now() + 500ms —— 不 clear 也得过 500ms
// 才失效。clear 是立即清理入口(切目录 / stop 监听时调用)。
w.clearExpectedWrites();
expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(false);
});
});
describe('fs-watcher updateSnapshot —— push 决策回归 (Round 13 fix fce2d83)', () => {
// 关键回归锚点:2s 轮询(OneDrive / SMB fs.watch 不可靠环境唯一兜底)检测
// 到列表变化,必须把新列表推到 renderer 侧栏刷新。历史 bug 把 skipInitialEmit
// 透传给 emitFilesChanged + baseline 重复写,导致 safeSend 永远跳过。
//
// 测试通过 internal _test_updateSnapshot 触发该路径,避免引入 jsdom + 真 fs.watch。
it('startup 路径(skipInitialEmit=true):同步 baseline 但不 push', async () => {
// audit fix:startWatchingDir 内部 updateSnapshot(dir, true) 拿到的「真实状态」
// 只用来同步 baseline,不能误当成「新增变化」推给 renderer —— renderer 会通过
// 'file:list' 自己拉取启动文件列表,避免「刚加载就 emitFilesChanged 又被 renderer
// 当 fs:changed 事件 → 触发 scanDir 重复请求」的循环。
const { watcher: w, sends } = makeWatcherWithIpc([
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
]);
await w._test_updateSnapshot('/data', /* skipInitialEmit */ true);
// startup 只写 baseline 不 push
const filesChanged = sends.filter((s) => s.channel === 'files:changed');
expect(filesChanged.length).toBe(0);
});
it('常规路径(skipInitialEmit=false):检测到变化必须 push', async () => {
// 关键回归:第一次 startup(不 push)后,第二次常规 updateSnapshot 看到列表变化
// → 必须 safeSend 通知 renderer。Round 13 fix 之前的实现 silent 失败。
const { watcher: w, sends } = makeWatcherWithIpc([
// 1st call: startup snapshot, list = [a.md]
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
// 2nd call: polling tick, list grew to [a.md, b.md]
{ ok: true, entries: [
{ name: 'a.md', size: 10, mtimeMs: 1 },
{ name: 'b.md', size: 20, mtimeMs: 2 },
] },
]);
await w._test_updateSnapshot('/data', true); // startup
await w._test_updateSnapshot('/data', false); // polling tick detects new file
const pushes = sends
.filter((s) => s.channel === 'files:changed')
.map((s) => s.payload);
expect(pushes.length).toBeGreaterThanOrEqual(1);
const last = pushes[pushes.length - 1];
expect(last.entries.map((e) => e.name).sort()).toEqual(['a.md', 'b.md']);
// relOf 没有 toRelDir 注入时返回 null(fs-watcher 契约:可选字段,
// payload 缺 relDir 仍合法;renderer 走 fallback 不依赖此字段)
expect(last.relDir == null).toBe(true);
});
it('常规路径但列表未变:不 push(baseline 一致)', async () => {
// 同一列表连扫两次 → baseline 不变 → emitFilesChanged 内部 if 块不进 → 不 push。
// 避免 fs.watch + polling 双通道把同一次变化报两遍。
const { watcher: w, sends } = makeWatcherWithIpc([
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
]);
await w._test_updateSnapshot('/data', true);
await w._test_updateSnapshot('/data', false);
const pushes = sends.filter((s) => s.channel === 'files:changed');
expect(pushes.length).toBe(0);
});
it('扫描失败(result.ok=false)→ 通过错误通道 push,不走 entries 通道', async () => {
// 与 change 分支独立 —— 错误走 'files:changed' 但 payload 不带 entries,
// renderer 用 error 字段弹 toast 并保留旧列表(Round 7 FS-F12 行为)。
const { watcher: w, sends } = makeWatcherWithIpc([
{ ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT' },
]);
await w._test_updateSnapshot('/data', false);
const pushes = sends.filter((s) => s.channel === 'files:changed');
expect(pushes.length).toBe(1);
expect(pushes[0].payload.error).toBe('DATA_DIR_NOT_FOUND');
expect(pushes[0].payload.entries).toBeUndefined();
});
});

View File

@@ -488,12 +488,17 @@ describe('MarkdownViewer resolveRelativeImages 副作用', () => {
emptyElement: document.querySelector('.empty'), emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'), statusPath: document.createElement('div'),
statusMeta: document.createElement('div'), statusMeta: document.createElement('div'),
// audit fix (Sec-M2):新签名需要 getDataDir 才能继续 setAttribute
// loading/decoding;这里给一个「包含路径」的等价 dir,让 img.png 视为
// 「在数据目录内」跑通副作用断言。原本旧行为「不传 getDataDir 仍解析」
// 已删,这里补齐构造以让 loading/decoding 这条副作用断言继续有意义。
getDataDir: () => posixPath('/notes'),
}); });
}); });
it('图片自动加 loading="lazy" + decoding="async"', () => { it('图片自动加 loading="lazy" + decoding="async"', async () => {
body.innerHTML = '<img src="img.png" /><img src="img2.png" />'; body.innerHTML = '<img src="img.png" /><img src="img2.png" />';
viewer.resolveRelativeImages('/notes/test.md'); await viewer.resolveRelativeImages('/notes/test.md');
for (const img of body.querySelectorAll('img')) { for (const img of body.querySelectorAll('img')) {
expect(img.getAttribute('loading')).toBe('lazy'); expect(img.getAttribute('loading')).toBe('lazy');
@@ -501,18 +506,18 @@ describe('MarkdownViewer resolveRelativeImages 副作用', () => {
} }
}); });
it('已有 loading/decoding 不覆盖', () => { it('已有 loading/decoding 不覆盖', async () => {
body.innerHTML = '<img src="img.png" loading="eager" decoding="sync" />'; body.innerHTML = '<img src="img.png" loading="eager" decoding="sync" />';
viewer.resolveRelativeImages('/notes/test.md'); await viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img'); const img = body.querySelector('img');
expect(img.getAttribute('loading')).toBe('eager'); expect(img.getAttribute('loading')).toBe('eager');
expect(img.getAttribute('decoding')).toBe('sync'); expect(img.getAttribute('decoding')).toBe('sync');
}); });
it('data: URL 图片不动', () => { it('data: URL 图片不动', async () => {
body.innerHTML = '<img src="data:image/png;base64,iVBOR..." />'; body.innerHTML = '<img src="data:image/png;base64,iVBOR..." />';
viewer.resolveRelativeImages('/notes/test.md'); await viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img'); const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBOR...'); expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBOR...');
@@ -520,9 +525,9 @@ describe('MarkdownViewer resolveRelativeImages 副作用', () => {
expect(img.getAttribute('loading')).toBeNull(); expect(img.getAttribute('loading')).toBeNull();
}); });
it('绝对 file:// / https:// URL 图片不动', () => { it('绝对 file:// / https:// URL 图片不动', async () => {
body.innerHTML = '<img src="https://cdn.example.com/img.png" />'; body.innerHTML = '<img src="https://cdn.example.com/img.png" />';
viewer.resolveRelativeImages('/notes/test.md'); await viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img'); const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('https://cdn.example.com/img.png'); expect(img.getAttribute('src')).toBe('https://cdn.example.com/img.png');
@@ -583,25 +588,28 @@ describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)',
expect(img.getAttribute('src')).toMatch(/img\.png$/); expect(img.getAttribute('src')).toMatch(/img\.png$/);
}); });
it('getDataDir 返回 null(旧版未注入) → 不做边界检查,仅防 ../.. 越界', async () => { it('getDataDir 返回 null(不可用) → fail-closed:src 被剥,data-blocked="external"', async () => {
// audit fix (Sec-M2):旧版「getDataDir 返回 null → 跳过边界检查」是文件
// 系统枚举 oracle —— 恶意 md 可以用 `<img src="../victim.png">` 探测数据
// 目录外任意可读图片是否存在。新版 fail-closed:dataDir 不可用时一律拒写 src。
viewer = makeViewer(() => null); viewer = makeViewer(() => null);
// 不传 / 传 null 时 resolveRelativeImages 跳过 isPathInside 校验。 body.innerHTML = '<img src="img.png" alt="x">';
// resolvePath 内部仍会拒绝 '../..' 越界(保留旧行为)。
body.innerHTML = '<img src="../../outside.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md')); await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
const img = body.querySelector('img'); const img = body.querySelector('img');
// resolvePath 返回 null → src 不变 expect(img.hasAttribute('src')).toBe(false);
expect(img.hasAttribute('src')).toBe(true); expect(img.getAttribute('data-blocked')).toBe('external');
expect(img.getAttribute('src')).toBe('../../outside.png');
}); });
it('getDataDir 抛错 → 不阻断(catch 降级,旧行为继续)', async () => { it('getDataDir 抛错 → fail-closed:src 被剥,不阻断后续调用', async () => {
// audit fix (Sec-M2):旧版 catch 里静默吞掉错误并继续,相当于「IPC 故障时
// 整个数据目录边界检查被绕过」。新版与 null 同走 fail-closed 路径,保证
// 「dataDir 不可用 = 一律拦截」语义统一。catch 仍不阻断后续 DOM 处理,
// 但图片一律不下发 src(与 null 一致),这是有意的更安全默认。
viewer = makeViewer(() => { throw new Error('IPC down'); }); viewer = makeViewer(() => { throw new Error('IPC down'); });
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
body.innerHTML = '<img src="img.png">'; body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md')); await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
// 不抛错;图片被正常解析 expect(body.querySelector('img').hasAttribute('src')).toBe(false);
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
consoleWarn.mockRestore(); consoleWarn.mockRestore();
}); });
@@ -614,7 +622,8 @@ describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)',
expect(img.getAttribute('data-blocked')).toBe('external'); expect(img.getAttribute('data-blocked')).toBe('external');
}); });
it('未传 getDataDir 选项 → 不做边界检查(旧行为兼容)', async () => { it('未传 getDataDir 选项 → fail-closed:src 被剥', async () => {
// audit fix (Sec-M2):与 null / 抛错同走 fail-closed —— 边界守护不可降级。
viewer = new MarkdownViewer({ viewer = new MarkdownViewer({
bodyElement: body, bodyElement: body,
emptyElement: document.querySelector('.empty'), emptyElement: document.querySelector('.empty'),
@@ -624,7 +633,8 @@ describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)',
}); });
body.innerHTML = '<img src="img.png">'; body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md')); await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/); expect(body.querySelector('img').hasAttribute('src')).toBe(false);
expect(body.querySelector('img').getAttribute('data-blocked')).toBe('external');
}); });
}); });

183
tests/unit/rescue.test.js Normal file
View File

@@ -0,0 +1,183 @@
// src/rescue.js 测试
//
// 覆盖:
// - buildRescueName 的命名规则(去 .md 后缀、毫秒精度、随机后缀、默认值 fallback)
// - rescueDirtyContent 的三种结局:成功 / IPC result.error / IPC 异常
// - friendlyFsError 错误码 vs message 的语义(audit Round 16 bug)
/* @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { buildRescueName, rescueDirtyContent } from '../../src/rescue.js';
import * as feedback from '../../src/feedback.js';
describe('buildRescueName —— 命名规则', () => {
it('原名带 .md → 去掉后缀再加 -rescued-...', () => {
const name = buildRescueName('test.md');
expect(name).toMatch(/^test-rescued-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-[a-z0-9]{4}\.md$/);
});
it('原名大小写混合的 .MD 也去后缀(不区分大小写)', () => {
const name = buildRescueName('NOTES.MD');
expect(name.startsWith('NOTES-rescued-')).toBe(true);
});
it('原名不带 .md → 直接拼 -rescued-...', () => {
const name = buildRescueName('plain-text');
expect(name.startsWith('plain-text-rescued-')).toBe(true);
expect(name.endsWith('.md')).toBe(true);
});
it('原名为空 / null / undefined → 用 untitled.md 作 base', () => {
expect(buildRescueName('').startsWith('untitled-rescued-')).toBe(true);
expect(buildRescueName(null).startsWith('untitled-rescued-')).toBe(true);
expect(buildRescueName(undefined).startsWith('untitled-rescued-')).toBe(true);
});
it('时间戳含毫秒(避免同秒内重名,详见 audit renderer-M9)', () => {
const name = buildRescueName('foo.md');
// 形如 2026-09-21T10-30-45-123(slice(0,23) 保留 .mmm)
expect(name).toMatch(/-\d{3}-[a-z0-9]{4}\.md$/);
});
it('随机后缀每次都不同(碰撞概率 ~1/1.6M)', () => {
const a = buildRescueName('x.md');
const b = buildRescueName('x.md');
expect(a).not.toBe(b);
});
});
describe('rescueDirtyContent —— 三种结局', () => {
let showToastSpy;
beforeEach(() => {
showToastSpy = vi.spyOn(feedback, 'showToast').mockImplementation(() => {});
});
afterEach(() => {
showToastSpy.mockRestore();
delete window.api;
});
it('createFile ok=true → 弹 warning 成功 toast(含 rescue 名)', async () => {
const api = { createFile: vi.fn().mockResolvedValue({ ok: true }) };
const name = rescueDirtyContent({
api,
originalName: 'note.md',
content: 'unsaved body',
event: '文件已不在',
});
expect(name).toMatch(/^note-rescued-.+\.md$/);
// await 让 promise 链执行
await Promise.resolve();
await Promise.resolve();
expect(showToastSpy).toHaveBeenCalledTimes(1);
expect(showToastSpy.mock.calls[0][0]).toMatch(/^文件已不在,未保存内容已救回到「note-rescued-.+\.md」$/);
expect(showToastSpy.mock.calls[0][1]).toBe('warning');
expect(showToastSpy.mock.calls[0][2]).toBe(4500);
});
it('createFile ok=false → 弹 error toast,friendlyFsError 传 message 而非 error 码', async () => {
// audit fix (Round 16):旧版直接传 r?.error(业务码字符串如 'INVALID_NAME'),
// 用户在 toast 里看到原始业务码而非中文提示。
//
// 第二阶段代码审查 (Phase 2 review #2):friendlyFsError 现在走注入的 api
// 参数,不再 fallback 到 window.api。测试也对应调整 —— 把 friendlyFsError
// 挂在 api param 上,模拟生产里 ctx.api(contextBridge 暴露的对象)。
const api = {
createFile: vi.fn().mockResolvedValue({
ok: false,
code: 'EACCES',
error: 'INVALID_NAME', // 业务码,不该给用户看
message: '文件名包含 Windows 保留字符', // 中文明文,应该展示
}),
friendlyFsError: (code, fallback) => fallback || code || '未知错误',
};
rescueDirtyContent({
api,
originalName: 'bad.md',
content: 'x',
event: '原文件已被删除',
});
await Promise.resolve();
await Promise.resolve();
expect(showToastSpy).toHaveBeenCalledTimes(1);
expect(showToastSpy.mock.calls[0][0]).toContain('原文件已被删除;未保存内容丢失(救回失败:文件名包含 Windows 保留字符)');
// 关键断言:业务码「INVALID_NAME」**不能**出现在 toast 里
expect(showToastSpy.mock.calls[0][0]).not.toContain('INVALID_NAME');
});
it('createFile 抛 IPC 异常 → 弹 error toast,不抛 unhandled rejection', async () => {
const api = {
createFile: vi.fn().mockRejectedValue(new Error('IPC channel disconnected')),
};
rescueDirtyContent({
api,
originalName: 'foo.md',
content: 'x',
event: '文件已不在',
});
await Promise.resolve();
await Promise.resolve();
expect(showToastSpy).toHaveBeenCalledTimes(1);
expect(showToastSpy.mock.calls[0][0]).toContain('文件已不在;未保存内容丢失');
});
it('createFile 返回 ok=false 且无 message(极端) → fallback 到 error 码', async () => {
const api = {
createFile: vi.fn().mockResolvedValue({ ok: false, code: 'EACCES', error: 'UNKNOWN' }),
friendlyFsError: (code, fallback) => fallback || code || '未知错误',
};
rescueDirtyContent({
api,
originalName: 'foo.md',
content: 'x',
event: '文件已不在',
});
await Promise.resolve();
await Promise.resolve();
expect(showToastSpy.mock.calls[0][0]).toContain('救回失败:UNKNOWN');
});
it('api.friendlyFsError 不可用(极端) → 直接走 r?.message || r?.error 字符串', async () => {
// 早期启动 / preload 不可用时,注入的 api 上没有 friendlyFsError,
// fallback 仍给出可读提示(与第二阶段审查 review #2 一致:不再回退到
// window.api,契约是「谁注入谁负责」)。
const api = {
createFile: vi.fn().mockResolvedValue({ ok: false, message: '磁盘已满' }),
};
rescueDirtyContent({
api,
originalName: 'foo.md',
content: 'x',
event: '文件已不在',
});
await Promise.resolve();
await Promise.resolve();
expect(showToastSpy.mock.calls[0][0]).toContain('救回失败:磁盘已满');
});
it('createFile 接受新签名 payload { initialContent }(第二阶段 review #1 回归)', async () => {
// 之前 rescue.js 用 string overload `api.createFile(name, content)`,依赖 preload
// 的向后兼容分支。如果 preload 移除 string overload,所有 rescue 调用会静默
// 失败(fire-and-forget 调用方收不到错误,只看到「未保存内容丢失」toast)。
// 现在 rescue.js 显式用 `{ initialContent }` 对象形式 —— 断言 createFile 收到的
// 第二参是对象、content 落在 initialContent 字段,与 main/file-ops.js
// resolveFileName 的新签名对齐。
const api = {
createFile: vi.fn().mockResolvedValue({ ok: true }),
};
rescueDirtyContent({
api,
originalName: 'note.md',
content: 'unsaved body',
event: '文件已不在',
});
await Promise.resolve();
expect(api.createFile).toHaveBeenCalledTimes(1);
const [name, payload] = api.createFile.mock.calls[0];
expect(name).toMatch(/^note-rescued-.+\.md$/);
expect(payload).toEqual({ initialContent: 'unsaved body' });
// 关键:不再是 string 第二参
expect(typeof payload).toBe('object');
expect(typeof payload).not.toBe('string');
});
});

View File

@@ -73,23 +73,29 @@ describe('decideSaveToast —— STALE 反馈(核心 bug #1 修复 + Phase 2 P
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500); expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
}); });
it('silent=false + reason=STALE + ok=true → 同样弹「文件已切换」', () => { it('silent=false + reason=STALE + ok=true → 弹「已保存」(success,data 已落盘)', () => {
// Phase 2 audit 修复 (P1 #3):writeFile 在 STALE 检查前已 resolve, // Round 13 fix (M20):writeFile 在 STALE 检查前已 resolve → data 已落
// ok=true 表示 data 已落盘,但 editor 已不在 targetPath 上。用户视角 // targetPath,ok=true 表示写盘成功。此时用户已切走,editor 与 targetPath
// 仍然需要「文件已切换」反馈(编辑器和预期的不一致)。 // 解耦,但磁盘上数据是对的 —— 弹「已保存」(success) 比「文件已切换」更
// 准确反映「数据有没有存」。失败分支(ok=false)才弹「文件已切换」。
const showToast = makeShowToast(); const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast); decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500); expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
}); });
it('STALE 优先于 ok=true:reason=STALE 时一律走 warning', () => { it('STALE + ok=true 走 success;ok=false 走 warning(防呆双向断言)', () => {
// 防呆测试:即使 ok=true,reason=STALE 必须弹 warning 而不是 success。 // 防呆:必须严格按 ok 分流 —— ok=true 弹 success,ok=false 弹 warning。
// 否则 Phase 2 修复会把「data 已落盘」的 success 文案误导成「保存好了」。 // 不能「STALE 一律走 warning」(会把「data 已落盘」误导成「没存」),
const showToast = makeShowToast(); // 也不能「STALE 一律走 success」(写盘失败却被告知「已保存」)。
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast); const okToast = makeShowToast();
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500); decideSaveToast(false, { ok: true, reason: 'STALE' }, okToast);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500); expect(okToast).toHaveBeenCalledWith('已保存', 'success', 2500);
expect(okToast).not.toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
const failToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, failToast);
expect(failToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
expect(failToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
}); });
it('silent=false + 其它失败 reason(CONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => { it('silent=false + 其它失败 reason(CONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => {
@@ -132,12 +138,13 @@ describe('decideSaveToast —— shared path 场景(核心 bug #2 修复)',
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500); expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
}); });
it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「文件已切换」', () => { it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「已保存」', () => {
// Phase 2 P1 #3:writeFile 成功但用户已切走 → 关窗路径用 ok=true 退出, // Round 13 fix (M20):writeFile 成功但用户已切走 → 关窗路径用 ok=true
// 但 silent=false manual caller 仍应弹 STALE 反馈。 // 退出,silent=false manual caller 看到「数据已落盘」弹 success 而非
// 「文件已切换」—— 后者会误导(明明已存却说没存)。
const showToast = makeShowToast(); const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast); decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500); expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
}); });
it('同 IIFE 结果:auto-save silent=true 不弹 + manual silent=false 弹 → 不互相干扰', () => { it('同 IIFE 结果:auto-save silent=true 不弹 + manual silent=false 弹 → 不互相干扰', () => {

View File

@@ -0,0 +1,50 @@
// settings-dialog.js 的纯函数测试。
//
// settings-dialog 整个类没有单测(mount 路径太重、依赖 modal-stack / settingsStore
// / window.api 等太多胶水),但其中有几个无副作用的小函数值得保护。
// 本文件只覆盖那些 —— 别引整个 dialog 进来,那会拖一堆 stub。
import { describe, it, expect } from 'vitest';
import { clearAiKeyDefaultValue } from '../../src/settings-dialog.js';
describe('clearAiKeyDefaultValue(Sec-M3 修复:防 dataset 残留真 Key)', () => {
// 背景:reveal flow 会先写 input.dataset.defaultValue = 真 Key,再把
// .value 同步暴露给用户;关 dialog 时必须把 dataset.defaultValue 删掉,
// 否则 XSS / DevTools / 复制粘贴能直接读出真 Key,绕过 arm/reveal 5 秒窗口。
// 这条函数被 3 处调用:mount 后 / modal.setOnClose 早期 handler / 主 close handler。
it('正常 input 元素:删掉 defaultValue,保留其他 dataset 属性', () => {
const input = {
dataset: { defaultValue: 'sk-real-key', other: 'untouched' },
};
clearAiKeyDefaultValue(input);
expect(input.dataset.defaultValue).toBeUndefined();
expect(input.dataset.other).toBe('untouched');
});
it('dataset 上没有 defaultValue 时不抛错(幂等)', () => {
const input = { dataset: { other: 'x' } };
expect(() => clearAiKeyDefaultValue(input)).not.toThrow();
expect(input.dataset.other).toBe('x');
});
it('null / undefined 输入静默 no-op,不抛', () => {
expect(() => clearAiKeyDefaultValue(null)).not.toThrow();
expect(() => clearAiKeyDefaultValue(undefined)).not.toThrow();
});
it('没有 dataset 属性的对象静默 no-op', () => {
// 防御:如果调用方传错对象(比如 element ref 但还没 mount、或者 wrapper),
// 不能让 reveal 关 dialog 流程崩。真实 DOM 元素总有 dataset,所以这是兜底。
const fake = {};
expect(() => clearAiKeyDefaultValue(fake)).not.toThrow();
});
it('多次调用幂等(连续关 dialog / 双保险路径不会破坏)', () => {
const input = { dataset: { defaultValue: 'k' } };
clearAiKeyDefaultValue(input);
clearAiKeyDefaultValue(input);
clearAiKeyDefaultValue(input);
expect(input.dataset.defaultValue).toBeUndefined();
});
});

View File

@@ -444,4 +444,50 @@ describe('nullable-number typeof 守卫对称(Round 4 P2-3)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null }); const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null });
expect(r.sidebarWidth).toBe(null); expect(r.sidebarWidth).toBe(null);
}); });
});
describe('SETTINGS_UI_OPTIONS.aiProvider[*].defaults (audit T-4)', () => {
// T-4 把原本散在 src/settings-dialog.js 顶部的 AI_DEFAULTS(baseURL + model
// 占位符)迁到这里与 label / hint 同源。settings-dialog 通过
// getAiProviderDefaults(value) 查表,schema 是唯一事实源。
//
// 这里保护:
// 1. 两个 provider 都必须有 defaults 字段(不能让 placeholder 退化成空串)
// 2. defaults 必须含 baseUrl + model 两个 string 字段
// 3. 默认 baseUrl 必须与 provider 的协议约定一致 —— OpenAI 含 /v1,
// Anthropic 不含;否则用户在空白字段看到错误提示就会按 placeholder 填错。
const { SETTINGS_UI_OPTIONS } = schema;
const providers = SETTINGS_UI_OPTIONS.aiProvider;
it('OpenAI 兼容项的 defaults 含 /v1 + model', () => {
const p = providers.find((x) => x.value === 'openai');
expect(p?.defaults, 'openai 项必须有 defaults 字段').toBeDefined();
expect(typeof p.defaults.baseUrl).toBe('string');
expect(typeof p.defaults.model).toBe('string');
expect(p.defaults.baseUrl.endsWith('/v1'), 'OpenAI 兼容默认 baseURL 必须以 /v1 结尾').toBe(true);
});
it('Anthropic 兼容项的 defaults 不含 /v1', () => {
const p = providers.find((x) => x.value === 'anthropic');
expect(p?.defaults).toBeDefined();
expect(p.defaults.baseUrl.endsWith('/v1'), 'Anthropic 默认 baseURL 不能含 /v1 后缀').toBe(false);
expect(p.defaults.baseUrl.length).toBeGreaterThan(0);
expect(p.defaults.model.length).toBeGreaterThan(0);
});
it('所有 aiProvider 项都覆盖 defaults(新增 provider 时强制填齐)', () => {
for (const p of providers) {
expect(p.defaults, `${p.value} 缺 defaults`).toBeDefined();
expect(typeof p.defaults.baseUrl, `${p.value}.defaults.baseUrl 不是 string`).toBe('string');
expect(typeof p.defaults.model, `${p.value}.defaults.model 不是 string`).toBe('string');
}
});
it('defaults 字段被 Object.freeze 保护(防运行时突变)', () => {
const p = providers.find((x) => x.value === 'openai');
// Object.freeze 在严格模式写入会抛错;非严格模式静默失败(settings-dialog 是
// ESM 在 module top-level 加载时是 strict,但 try/catch 包住更稳)。
expect(() => { p.defaults.baseUrl = 'http://evil'; }).toThrow();
expect(p.defaults.baseUrl).toBe('https://api.openai.com/v1');
});
}); });

View File

@@ -49,6 +49,7 @@ describe('shortcuts 基本派发', () => {
onToggleFocusMode: vi.fn(), onToggleFocusMode: vi.fn(),
onToggleAi: vi.fn(), onToggleAi: vi.fn(),
onReload: vi.fn(), onReload: vi.fn(),
onCloseFile: vi.fn(),
}; };
detach = mountKeyboardShortcuts(handlers); detach = mountKeyboardShortcuts(handlers);
}); });
@@ -111,6 +112,26 @@ describe('shortcuts 基本派发', () => {
expect(handlers.onReload).toHaveBeenCalledTimes(2); expect(handlers.onReload).toHaveBeenCalledTimes(2);
}); });
// 关闭当前文件:Ctrl+W。无文件时调 onCloseFile 也安全 —— app.js 的
// closeCurrentFile() 内部对 !state.currentFile 直接 return,handler 失活无副作用。
it('Ctrl+W → onCloseFile', () => {
fireKey({ key: 'w', ctrlKey: true });
expect(handlers.onCloseFile).toHaveBeenCalledTimes(1);
fireKey({ key: 'W', ctrlKey: true });
expect(handlers.onCloseFile).toHaveBeenCalledTimes(2);
});
it('Ctrl+W 必须 preventDefault(防止 Electron 默认关闭 BrowserWindow)', () => {
const ev = fireKey({ key: 'w', ctrlKey: true });
expect(ev.defaultPrevented).toBe(true);
});
it('Ctrl+Shift+W / Ctrl+Alt+W 不应触发 onCloseFile(避免误触)', () => {
fireKey({ key: 'w', ctrlKey: true, shiftKey: true });
fireKey({ key: 'w', ctrlKey: true, altKey: true });
expect(handlers.onCloseFile).not.toHaveBeenCalled();
});
it('非修饰键 → 不触发任何 handler', () => { it('非修饰键 → 不触发任何 handler', () => {
fireKey({ key: 'a', ctrlKey: false, metaKey: false }); fireKey({ key: 'a', ctrlKey: false, metaKey: false });
for (const [name, fn] of Object.entries(handlers)) { for (const [name, fn] of Object.entries(handlers)) {
@@ -271,6 +292,7 @@ describe('shortcuts handler 缺失时静默', () => {
fireKey({ key: 'n', ctrlKey: true }); fireKey({ key: 'n', ctrlKey: true });
fireKey({ key: 'O', ctrlKey: true, shiftKey: true }); fireKey({ key: 'O', ctrlKey: true, shiftKey: true });
fireKey({ key: 'A', ctrlKey: true, shiftKey: true }); fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
fireKey({ key: 'w', ctrlKey: true });
}).not.toThrow(); }).not.toThrow();
}); });
}); });
@@ -313,6 +335,19 @@ describe('shortcuts IME 合成期间(C3 audit)', () => {
fireKey({ key: 'e', ctrlKey: true, isComposing: false }); fireKey({ key: 'e', ctrlKey: true, isComposing: false });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1); expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
}); });
// Ctrl+W 在 IME 合成期也应被吞:用户在 CJK 拼音中途按 Ctrl+W 不应该
// 立即关文件,IME buffer 还在编辑器里。
it('isComposing=true 时 Ctrl+W 不触发 onCloseFile', () => {
const handlers2 = { onCloseFile: vi.fn() };
const detach2 = mountKeyboardShortcuts(handlers2);
try {
fireKey({ key: 'w', ctrlKey: true, isComposing: true });
expect(handlers2.onCloseFile).not.toHaveBeenCalled();
} finally {
detach2();
}
});
}); });
describe('shortcuts 模态打开时 Ctrl+W 抑制(M1 audit)', () => { describe('shortcuts 模态打开时 Ctrl+W 抑制(M1 audit)', () => {
@@ -338,12 +373,18 @@ describe('shortcuts 模态打开时 Ctrl+W 抑制(M1 audit)', () => {
expect(ev.defaultPrevented).toBe(true); expect(ev.defaultPrevented).toBe(true);
}); });
it('模态未开时 Ctrl+W 不在抑制列表里(normal 行为:不被 preventDefault)', () => { it('模态未开时 Ctrl+W 仍 preventDefault(挡 Electron 关窗口),但不在 SUPPRESS 列表里', () => {
if (modalApi) modalApi.close('test-no-modal'); if (modalApi) modalApi.close('test-no-modal');
modalApi = null; modalApi = null;
const closeHandler = vi.fn();
const detachNoModal = mountKeyboardShortcuts({ onCloseFile: closeHandler });
const ev = fireKey({ key: 'w', ctrlKey: true }); const ev = fireKey({ key: 'w', ctrlKey: true });
// 不被任何 handler 拦截 → defaultPrevented 应该是 false(由浏览器处理关窗口) // Ctrl+W 必须 preventDefault:否则 Electron 默认行为是直接关 BrowserWindow,
expect(ev.defaultPrevented).toBe(false); // 整个应用被干掉,与"关闭当前文件"语义不符。这是 2026-09 设计优化引入
// 的新行为(之前测试期望 defaultPrevented=false 是旧语义)。
expect(ev.defaultPrevented).toBe(true);
expect(closeHandler).toHaveBeenCalled();
if (detachNoModal && typeof detachNoModal.dispose === 'function') detachNoModal.dispose();
}); });
}); });

View File

@@ -0,0 +1,106 @@
// src/toolbar.js 的 onCloseFile 接线测试
//
// 覆盖:
// - btnCloseFile click → onCloseFile 被调用
// - 缺失 onCloseFile 时不挂 listener(防御)
// - 缺失 btnCloseFile 时不挂 listener
//
// 真实运行链路:app.js 在 syncSaveButton() 末尾调 syncCloseFileButton(),
// 把按钮从 disabled → enabled;用户点击 → Toolbar 构造时挂的 listener 触发
// onCloseFile() → closeCurrentFile()。本测试只覆盖 Toolbar 自身的接线
// 段(构造时是否挂对 listener),不覆盖 closeCurrentFile 内部逻辑
// (那个由 app.js 模块顶层,单元测试触不到)。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Toolbar } from '../../src/toolbar.js';
describe('toolbar.onCloseFile 接线', () => {
let btnCloseFile;
let elements;
beforeEach(() => {
document.body.innerHTML = `
<button id="btn-close-file" type="button" disabled>关闭文件</button>
<button id="btn-theme">主题</button>
<button id="btn-pin">置顶</button>
<button id="btn-settings">设置</button>
<button id="btn-reset-layout">重置布局</button>
<button id="btn-min">最小化</button>
<button id="btn-max">最大化</button>
<button id="btn-close">关闭窗口</button>
`;
btnCloseFile = document.getElementById('btn-close-file');
elements = {
btnTheme: document.getElementById('btn-theme'),
btnPin: document.getElementById('btn-pin'),
btnSettings: document.getElementById('btn-settings'),
btnResetLayout: document.getElementById('btn-reset-layout'),
btnMin: document.getElementById('btn-min'),
btnMax: document.getElementById('btn-max'),
btnClose: document.getElementById('btn-close'),
btnCloseFile,
};
});
/** 用 dispatchEvent 触发 click —— jsdom 的 .click() 在 form 容器里行为不稳。 */
function clickEl(el) {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
it('btnCloseFile click → onCloseFile 被调用一次', () => {
const onCloseFile = vi.fn();
new Toolbar({ elements, onCloseFile });
clickEl(btnCloseFile);
expect(onCloseFile).toHaveBeenCalledTimes(1);
});
it('多次点击 → onCloseFile 调用相应次数(无去重)', () => {
const onCloseFile = vi.fn();
new Toolbar({ elements, onCloseFile });
clickEl(btnCloseFile);
clickEl(btnCloseFile);
clickEl(btnCloseFile);
expect(onCloseFile).toHaveBeenCalledTimes(3);
});
it('btnCloseFile 缺失 → onCloseFile 不被调用也不抛错', () => {
const onCloseFile = vi.fn();
const elementsNoBtn = { ...elements };
delete elementsNoBtn.btnCloseFile;
expect(() => new Toolbar({ elements: elementsNoBtn, onCloseFile })).not.toThrow();
expect(onCloseFile).not.toHaveBeenCalled();
});
it('btnCloseFile 存在但 onCloseFile 缺失 → 不挂 listener', () => {
// 验证方式:btnCloseFile.click() 时不应触发任何引用错误。
// 由于 listener 没挂,调用就是一个空操作;测试"不抛错"即可。
expect(() => new Toolbar({ elements })).not.toThrow();
// 没有 onCloseFile 时 listener 没挂,逻辑无副作用。
// (更深层的"没挂"需要 spy addEventListener,超出本测试范围。)
});
it('按钮 disabled 状态不影响 listener 挂载', () => {
// 浏览器层面 disabled 按钮 click 不派发,但 listener 应在构造时已挂。
// jsdom 不会模拟浏览器的 disabled-click 抑制(disabled 后 click 仍派发),
// 所以这个测试主要防回归:listener 必须挂上、调用 click 不抛错。
btnCloseFile.disabled = true;
const onCloseFile = vi.fn();
new Toolbar({ elements, onCloseFile });
expect(() => clickEl(btnCloseFile)).not.toThrow();
expect(onCloseFile).toHaveBeenCalled();
});
it('btnClose 与 btnCloseFile 互不影响', () => {
const onClose = vi.fn();
const onCloseFile = vi.fn();
new Toolbar({ elements, onClose, onCloseFile });
clickEl(document.getElementById('btn-close'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onCloseFile).not.toHaveBeenCalled();
clickEl(btnCloseFile);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onCloseFile).toHaveBeenCalledTimes(1);
});
});