update
This commit is contained in:
35
.gitignore
vendored
35
.gitignore
vendored
@@ -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
197
README.md
@@ -1,149 +1,160 @@
|
|||||||
# Notes
|
# Notes
|
||||||
|
|
||||||
桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。
|
本地优先的 Markdown 阅读与编辑工具。界面看到的和磁盘上的 `.md` 文件一模一样 —— 复制走就是备份。
|
||||||
|
|
||||||
   
|
   
|
||||||
|
|
||||||
## 简介
|
## 它是什么
|
||||||
|
|
||||||
- **文件即真相**:界面显示什么,磁盘上就是什么。
|
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 内打开;`#标题` 锚点文内跳转
|
||||||
- 相对路径图片(``)直接渲染
|
- 相对路径图片(``)直接渲染
|
||||||
- 状态栏实时高亮当前阅读章节
|
|
||||||
- 聚焦模式隐藏工具栏 / 侧栏 / 状态栏(`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
18
data/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
一个简洁的 **Markdown 阅读器 / 编辑器**。
|
||||||
|
|
||||||
|
把 `.md` 文件放进数据文件夹,左侧栏就会自动出现 —— 文件怎么写,界面就怎么显示。
|
||||||
|
|
||||||
|
## 常用快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| `Ctrl + F` | 聚焦文件搜索 |
|
||||||
|
| `Ctrl + N` | 新建笔记 |
|
||||||
|
| `Ctrl + S` | 保存当前编辑 |
|
||||||
|
| `Ctrl + E` | 切换视图模式(预览 / 编辑 / 双栏)|
|
||||||
|
| `Ctrl + ,` | 打开设置 |
|
||||||
|
| `Ctrl + Shift + T` | 切换主题 |
|
||||||
|
|
||||||
|
开始写笔记吧。
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 欢迎使用 Notes
|
|
||||||
|
|
||||||
这是一个简洁的 **Markdown 阅读器**。
|
|
||||||
|
|
||||||
把 `.md` 文件放进数据文件夹,左侧栏就会自动出现 —— 文件怎么写,界面就怎么显示。
|
|
||||||
|
|
||||||
## 快捷键
|
|
||||||
|
|
||||||
- `Ctrl + F` 搜索文件
|
|
||||||
- `Ctrl + S` 保存
|
|
||||||
- `Ctrl + ,` 打开设置
|
|
||||||
- `Ctrl + Shift + T` 切换主题
|
|
||||||
|
|
||||||
开始写笔记吧。
|
|
||||||
190
index.html
190
index.html
@@ -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
332
main.js
@@ -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' },
|
||||||
{
|
{
|
||||||
|
|||||||
179
main/ai.js
179
main/ai.js
@@ -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() : {}) || {};
|
||||||
|
|||||||
@@ -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 + 启动自身
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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; },
|
||||||
};
|
};
|
||||||
|
|||||||
26
preload.js
26
preload.js
@@ -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,
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 || '未知错误';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 —— 阻止攻击者把整窗当画布。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 修改失败';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
408
src/app.js
408
src/app.js
@@ -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;
|
||||||
|
|||||||
@@ -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; },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 的
|
||||||
// 引用,触发到旧组件实例的回调链。
|
// 引用,触发到旧组件实例的回调链。
|
||||||
|
|||||||
@@ -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
88
src/rescue.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 把 < > & 转成 < > &,再次打开设置时用户看到的是字面
|
// escapeAttr 把 < > & 转成 < > &,再次打开设置时用户看到的是字面
|
||||||
// &lt;b&gt; 这种"双重转义"内容。现在 textarea 模板里只渲染空 body,
|
// &lt;b&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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 动态覆盖,切主题不会重置 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/utils.js
72
src/utils.js
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 行为', () => {
|
||||||
|
|||||||
182
tests/unit/fs-watcher.test.js
Normal file
182
tests/unit/fs-watcher.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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
183
tests/unit/rescue.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 弹 → 不互相干扰', () => {
|
||||||
|
|||||||
50
tests/unit/settings-dialog.test.js
Normal file
50
tests/unit/settings-dialog.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
106
tests/unit/toolbar-close-file.test.js
Normal file
106
tests/unit/toolbar-close-file.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user