commit 9c06d3f4bec0a3c4b2879143ba884fa5d5db2b6a Author: guanjihuan Date: Sat Sep 12 14:15:26 2026 +0800 update diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..c130222 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.18.0 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..35ac901 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Notes + +桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。 + +![tests](https://img.shields.io/badge/tests-643%2F29%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) + +## 简介 + +- **文件即真相**:界面显示什么,磁盘上就是什么。 +- **保存由你决定**:默认 `Ctrl + S` 手动保存;工具栏可一键开启自动保存。 +- **完全本地**:所有文件就在你电脑上的指定文件夹里,复制走就是备份。 +- **AI 可选**:内置修改助手,支持 OpenAI / Anthropic 兼容协议,不启用则永不联网。 + +## 快速开始 + +```bash +npm install +npm start +``` + +要求 Node ≥ 20.18(见 `.nvmrc`)。首次启动会在用户主目录创建 `Notes` 文件夹,把 `.md` 文件放进去即可在左侧栏看到。 + +> **Windows + 中文路径**:Electron 在含中文路径下启动会报错。两种解决: +> 1. 把项目移到纯 ASCII 路径,如 `D:\Projects\notes` +> 2. 用管理员 cmd 建符号链接:`mklink /D C:\dev\notes "C:\path\to\中文路径\Notes"`,在 `C:\dev\notes` 跑 `npm install && npm start` + +## 命令 + +```bash +npm start # 启动(UTF-8 包装,推荐;中文 Windows 别用 start:raw) +npm test # 全部单测(643 用例 / 29 文件) +npm run test:watch # watch 模式 +npm run check # 语法 + IPC 通道配对 +npm run lint # ESLint +npm run build:win # Windows → dist/(NSIS 安装包) +npm run build:mac # macOS → dist/ +npm run build:linux # Linux → dist/(AppImage) +``` + +## 功能 + +### 阅读与编辑 + +- 侧栏文件列表,按文件名实时过滤(`Ctrl + F`) +- 三种视图:预览 / 编辑 / 双栏(`Ctrl + E` 循环切换) +- 暗 / 亮主题 × 5 套配色(默认 / 海洋 / 森林 / 薰衣草 / 夕阳),正交可选 +- 工具栏一键窗口置顶 +- 链接智能处理:外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转 +- 相对路径图片(`![](img/a.png)`)直接渲染 +- 状态栏实时高亮当前阅读章节 +- 聚焦模式隐藏工具栏 / 侧栏 / 状态栏(`Ctrl + Shift + F`) +- 系统托盘:关闭窗口保留托盘图标,单击恢复 +- 右键菜单:阅读视图复制 / 全选;编辑器复制 / 剪切 / 粘贴 / 全选 + +### 数据安全 + +- 外部修改自动检测:其他编辑器改了文件会提示重新加载(IME 合成期间不打断) +- 退出保护:有未保存改动时关闭窗口 / 退出托盘都会先问「保存 / 丢弃 / 取消」 +- 原子写盘(`tmp + rename`),断电不留半截文件 +- 数据目录自动回退:当前目录不可用(U 盘拔了 / 网盘断连)时自动切回默认目录 +- 监听对云同步盘(OneDrive / 坚果云等)也可靠 + +### AI 修改 + +- 工具栏唤起底部对话面板 +- 双协议:OpenAI 兼容(`/chat/completions`)与 Anthropic 兼容(`/v1/messages`) +- 行级 + 词级 diff 高亮,可逐 region 应用或一键应用全部 +- 提交后可随时取消,状态干净复位 + +## 设置 + +`Ctrl + ,` 打开设置对话框。 + +**数据** — 数据文件夹:留空用默认 `~/Notes`;切换时旧目录失效会自动回退 + +**外观** — 配色 5 套 × 2 主题;阅读字号 14/15/17/19/22;阅读行距 1.5/1.7/1.85/2.0 + +**行为** — 文件列表排序(名称 / 修改时间);自动保存(编辑停下 500 ms 后落盘) + +**AI** — 服务提供方 / Base URL / API Key(明文存本机 `settings.json`,UI 默认脱敏)/ 模型 / 系统提示(留空用内置中文 Markdown 助手 prompt) + +主题 / 置顶 / 视图模式在工具栏有独立按钮,**同一功能只保留一个入口**。 + +## 快捷键 + +| 快捷键 | 作用 | +|---|---| +| `Ctrl/Cmd + N` | 新建笔记 | +| `Ctrl/Cmd + F` | 聚焦文件搜索(编辑器内改为 CM6 查找) | +| `Ctrl/Cmd + S` | 保存(IME 合成中拒绝物理写盘) | +| `Ctrl/Cmd + E` | 切换视图模式(预览 → 编辑 → 双栏) | +| `Ctrl/Cmd + ,` | 打开设置 | +| `Ctrl/Cmd + R` | 重新加载界面(脏状态会先确认) | +| `Ctrl/Cmd + Shift + T` | 切换主题 | +| `Ctrl/Cmd + Shift + F` | 切换聚焦模式 | +| `Ctrl/Cmd + Shift + A` | 切换 AI 面板 | +| `Esc`(搜索框内) | 清空搜索 | +| `F12` | 打开 DevTools | + +窗口为无边框(自绘标题栏),Windows / Linux 上看不到菜单栏,但上表快捷键全部直接可用。 + +## 数据文件夹 + +| 平台 | 默认路径 | +|---|---| +| Windows | `C:\Users\<你>\Notes` | +| macOS | `~/Notes` | +| Linux | `$HOME/Notes` | + +可在设置里改成任意目录。**所有文件就在这里** —— 复制走就是备份。 + +## 项目结构 + +``` +Notes/ +├── main.js # Electron 主进程 +├── main/ # 主进程模块:ai / file-ops / fs-watcher / config-store +├── preload.js # IPC bridge +├── index.html # UI 框架 + CSP + importmap +├── src/ # 渲染端 +│ ├── ai/ # AI 面板(chat / diff / status / controller) +│ └── styles/ # CSS 分片(tokens / palettes / base / … 12 个) +├── shared/ # 主进程 / preload / 渲染端共享 +│ ├── ai-errors.js +│ ├── extension-lists.js +│ ├── friendly-fs-error.js +│ ├── markdown-diff.js +│ ├── render-sanitize.js +│ ├── settings-schema.js +│ └── slug.js +├── data/welcome.md # 首次启动种子文件 +├── patches/ # patch-package 补丁(app-builder-lib 26.15.3) +├── scripts/ +│ ├── launch.js # UTF-8 包装启动 +│ ├── check-syntax.js # 语法静态检查 +│ └── check-ipc.js # IPC 通道配对检查 +└── tests/ + └── unit/ # 29 个测试文件,643 用例(vitest) +``` + +## 故障排除 + +**文件列表不更新** +- 检查设置里的数据目录 +- 某些云同步盘的 `fs.watch` 不可靠,已加轮询 + watchdog 自愈 +- `Ctrl + R` 手动重新加载(脏状态会先确认) + +**控制台中文乱码** — 用 `npm start`,别用 `start:raw` + +**AI 提示"无法连接到服务"** +- 检查 Base URL:OpenAI 兼容需含 `/v1`,Anthropic 不含 +- 核对 API Key(点"显示"露出明文) +- `F12` → console 看完整错误码(参考 `shared/ai-errors.js`) + +**启动报错** +- 路径含中文(仅 Windows)→ 见上文"快速开始" +- Node 版本低于 20.18:`nvm use`(仓库根目录有 `.nvmrc`) +- `F12` → console 看渲染进程堆栈 + +## License + +MIT diff --git a/data/welcome.md b/data/welcome.md new file mode 100644 index 0000000..5e2c946 --- /dev/null +++ b/data/welcome.md @@ -0,0 +1,14 @@ +# 欢迎使用 Notes + +这是一个简洁的 **Markdown 阅读器**。 + +把 `.md` 文件放进数据文件夹,左侧栏就会自动出现 —— 文件怎么写,界面就怎么显示。 + +## 快捷键 + +- `Ctrl + F` 搜索文件 +- `Ctrl + S` 保存 +- `Ctrl + ,` 打开设置 +- `Ctrl + Shift + T` 切换主题 + +开始写笔记吧。 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9dee8a6 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,184 @@ +// ESLint flat config +// 两个 block: +// 1. main / preload / scripts/ —— Node CJS 环境(require / module / process / __dirname) +// 2. src/ —— 浏览器 ESM 环境(无 Node globals;importmap 暴露的 codemirror / @codemirror/* 是 ESM 模块, +// 不是 globals,不写进 globals 表) +// +// 规则严格度按"项目当前状态可一键通过"为基线:recommended + 关键 hygiene,不强制风格化(prettier 不上)。 + +const js = require('@eslint/js'); + +module.exports = [ + // eslint.config.js 自身:Node CJS + { + files: ['eslint.config.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'writable', + exports: 'writable', + }, + }, + }, + // 全局忽略 + { + ignores: [ + 'node_modules/**', + 'dist/**', + '.playwright-mcp/**', + 'scripts/_dbg-*.png', + 'tmp-*.mjs', // 临时验证脚本 + 'scripts/**/*.mjs', // 一次性 ad-hoc debug 脚本(playwright / repro 等),不是生产代码 + 'src/styles/**', // Stage 4 之后才有 + 'tests/**', // 测试文件用 vitest globals(describe/it/expect/vi), + // 由 vitest.config.js 负责;不需要 ESLint 强校验。 + ], + }, + + // Block 1: Node CJS —— main / preload / scripts / shared + { + files: [ + 'main.js', + 'main/**/*.js', + 'preload.js', + 'scripts/**/*.js', + 'shared/**/*.js', + ], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + // Node + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + module: 'readonly', + require: 'readonly', + exports: 'writable', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + // Electron(main/preload 直接 require 进来的) + app: 'readonly', + BrowserWindow: 'readonly', + ipcMain: 'readonly', + ipcRenderer: 'readonly', + contextBridge: 'readonly', + Menu: 'readonly', + Tray: 'readonly', + dialog: 'readonly', + shell: 'readonly', + screen: 'readonly', + nativeImage: 'readonly', + globalShortcut: 'readonly', + // preload 在 Electron sandbox 下能引用 window(与 renderer 的隔离 window 不同) + window: 'readonly', + document: 'readonly', + // Node 18+ 内置 Web API(main/ai.js 用 fetch + AbortController) + fetch: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + }, + }, + rules: { + ...js.configs.recommended.rules, + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-async-promise-executor': 'off', // preload 有 await in new Promise 的合法用法 + }, + }, + + // Block 2: renderer ESM —— src/** + { + files: ['src/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + // Browser + window: 'readonly', + document: 'readonly', + navigator: 'readonly', + location: 'readonly', + history: 'readonly', + localStorage: 'readonly', + sessionStorage: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + IntersectionObserver: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + getComputedStyle: 'readonly', + queueMicrotask: 'readonly', + MutationObserver: 'readonly', + crypto: 'readonly', + Element: 'readonly', + HTMLElement: 'readonly', + HTMLInputElement: 'readonly', + HTMLTextAreaElement: 'readonly', + Event: 'readonly', + CustomEvent: 'readonly', + KeyboardEvent: 'readonly', + MouseEvent: 'readonly', + DOMParser: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + Blob: 'readonly', + File: 'readonly', + FileReader: 'readonly', + Promise: 'readonly', + Map: 'readonly', + Set: 'readonly', + WeakMap: 'readonly', + WeakSet: 'readonly', + Date: 'readonly', + Math: 'readonly', + JSON: 'readonly', + Number: 'readonly', + String: 'readonly', + Array: 'readonly', + Object: 'readonly', + Error: 'readonly', + TypeError: 'readonly', + RangeError: 'readonly', + Symbol: 'readonly', + Proxy: 'readonly', + Reflect: 'readonly', + parseInt: 'readonly', + parseFloat: 'readonly', + isNaN: 'readonly', + isFinite: 'readonly', + encodeURIComponent: 'readonly', + decodeURIComponent: 'readonly', + // CodeMirror 是 ESM 模块,通过 importmap 解析 —— 不属于 globals + }, + }, + rules: { + ...js.configs.recommended.rules, + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, + + // 全局规则调整(适用于两个 block) + { + rules: { + 'no-undef': 'error', // 强制:renderer 不能用 process.* + 'no-prototype-builtins': 'off', // 旧代码里有 obj.hasOwnProperty(...) 调用 + }, + }, +]; diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..4bae4d4 Binary files /dev/null and b/icon.ico differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..38d1d83 --- /dev/null +++ b/index.html @@ -0,0 +1,524 @@ + + + + + + + + + + + Notes + + + + + + + + + + + + + + + + + + + +
+
+ + + + Notes +
+
+ +
+
+ + +
+
+ + + + +
+
+ + + +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ + + +
+ + + + +
+
+ + + +
+
+
+
+ + + +
+ + + + + +
+ +
+ + + + + + + + + + + + +
+ +
+
+ +
+

选择一个 Markdown 文件开始阅读

+

+ 把 .md 文件放入数据文件夹后,
+ 会自动出现在左侧列表。 +

+

左下角可切换数据文件夹,或在系统文件管理器中打开。

+
+
+
+ + + + + + + + +
+ + + + + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..98c4975 --- /dev/null +++ b/main.js @@ -0,0 +1,2461 @@ +// Electron 主进程 +// 负责窗口管理、文件 IO、IPC 桥接 + +// 关键:如果系统环境变量 ELECTRON_RUN_AS_NODE=1 被设置, +// electron.exe 会作为普通 Node 运行而非 Electron。 +// 这会让 `require('electron')` 返回路径字符串而非 API。 +// 这里主动清除它,确保应用始终作为 Electron 运行。 +if (process.env.ELECTRON_RUN_AS_NODE) { + console.log('[main] 检测到 ELECTRON_RUN_AS_NODE=1,已清除(避免被当作 Node)'); + delete process.env.ELECTRON_RUN_AS_NODE; +} + +// Windows 控制台编码修复:默认是 GBK(cp936),导致中文日志乱码。 +// 强制切换到 UTF-8,使 console.log 的中文能正确显示。 +if (process.platform === 'win32') { + try { + process.stdout.setDefaultEncoding('utf8'); + process.stderr.setDefaultEncoding('utf8'); + } catch { + // 某些环境下 setDefaultEncoding 不可用,忽略 + } +} + +// GUI 启动场景下(双击图标 / 启动器退出后),主进程的 stdout/stderr +// 没有真实终端可写,console.log 在 flush 时会触发 EPIPE: broken pipe, +// 进而被 Electron 弹成「A JavaScript error occurred in the main process」。 +// 给底层流挂 error 监听,把 EPIPE / ENOTCONN / EBADF 静默吞掉, +// console.log 仍按原样输出,只是不再让异常冒到顶层炸窗。 +try { + for (const stream of [process.stdout, process.stderr]) { + if (stream && typeof stream.on === 'function') { + stream.on('error', (err) => { + if (err && (err.code === 'EPIPE' || err.code === 'ENOTCONN' || err.code === 'EBADF')) { + return; // 静默忽略:没有终端可写是正常的 + } + // 其他错误写到 tmp 文件,避免被 Electron 弹成主进程错误框 + try { + require('fs').appendFileSync( + require('path').join(require('os').tmpdir(), 'notes-main-stderr.log'), + `[${new Date().toISOString()}] ${err.stack || err}\n` + ); + } catch { /* 兜底逻辑本身不应再抛 */ } + }); + } + } +} catch { + // 兜底逻辑本身不应再抛 +} + +// 把主进程的 console.log / console.warn / console.error 同步写到日志文件, +// 这样即便 stdout 断了(GUI 启动 / 启动器关闭),日志仍可追溯。 +// 不影响原有行为:仍会尝试写到 stdout(如果可用)。 +// +// audit fix (M5):之前写 %TEMP%\notes-main.log,多用户系统上 tmp 互相可读, +// 包含路径 / senderId 等的日志会被同机其他用户读到。改用 app.getPath('logs') +// (用户私有)作为首选;app 还没 ready 时退回 userData;都拿不到再退回 +// %LOCALAPPDATA%(Windows)或 ~/.local/share(POSIX)。 +try { + const _origLog = console.log; + const _origWarn = console.warn; + const _origError = console.error; + const _path = require('path'); + const _fsSync = require('fs'); + const _resolveLogDir = () => { + try { + const { app: _app } = require('electron'); + if (_app && typeof _app.getPath === 'function') { + // logs 路径可能在 app ready 之前未注册;用 userData 作为兜底 + try { + const logs = _app.getPath('logs'); + if (logs) return logs; + } catch {} + try { + const ud = _app.getPath('userData'); + if (ud) return ud; + } catch {} + } + } catch {} + if (process.platform === 'win32' && process.env.LOCALAPPDATA) { + return process.env.LOCALAPPDATA; + } + const home = require('os').homedir(); + if (process.platform === 'darwin') return _path.join(home, 'Library', 'Application Support'); + return _path.join(home, '.local', 'share'); + }; + const _logDir = _resolveLogDir(); + const _logFile = _path.join(_logDir, 'notes-main.log'); + const _writeLog = (level, args) => { + try { + // 兜底:目录可能不存在(首次启动 / 权限)→ 同步 mkdirSync recursive + _fsSync.mkdirSync(_logDir, { recursive: true }); + _fsSync.appendFileSync( + _logFile, + `[${new Date().toISOString()}] [${level}] ${args.map((a) => (typeof a === 'string' ? a : require('util').inspect(a))).join(' ')}\n` + ); + } catch { /* 写日志失败不应影响主流程 */ } + }; + console.log = (...args) => { _origLog(...args); _writeLog('log', args); }; + console.warn = (...args) => { _origWarn(...args); _writeLog('warn', args); }; + console.error = (...args) => { _origError(...args); _writeLog('error', args); }; +} catch { + // 重写 console 失败不应阻塞启动 +} + +const electronModule = require('electron'); + +// 检查 require('electron') 是否返回正确的 API(而非路径字符串) +// 在 Windows 上,如果项目路径包含非 ASCII 字符(如中文),可能会返回路径字符串 +if (typeof electronModule === 'string' || !electronModule.app) { + const errMsg = ` +================================================================================ +[FATAL] require('electron') 返回了无效值! + +问题原因: 项目路径包含非 ASCII 字符(如中文) + 当前路径: ${__dirname} + 返回值: ${typeof electronModule === 'string' ? '字符串 (路径)' : '无效对象'} + +解决方法: 将项目移动到 ASCII 路径,例如: + D:\\Projects\\notes + C:\\dev\\notes + +或者创建一个符号链接(mklink /D,管理员命令提示符): + mklink /D C:\\dev\\notes "${__dirname}" + +然后在符号链接路径下运行 npm start +================================================================================ +`; + console.error(errMsg); + try { + const { app: appApi } = require('electron'); + if (appApi && typeof appApi.whenReady === 'function') { + appApi.whenReady().then(() => { + const { BrowserWindow } = require('electron'); + const win = new BrowserWindow({ + width: 600, + height: 380, + resizable: false, + title: 'Notes - 启动错误' + }); + win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(` + +

⚠ 启动失败

+

问题:项目路径包含非 ASCII 字符(如中文),导致 Electron 无法正确加载 API。

+

当前路径:
${__dirname}

+

解决方法:将项目移动到 ASCII 路径(如 D:\\Projects\\notes)。

+

详细说明请查看 README。

+ + `)); + }); + return; + } + } catch { + // fallthrough + } + process.exit(1); +} + +const { app, BrowserWindow, ipcMain, dialog, Menu, shell, Tray } = electronModule; +const path = require('path'); +const fs = require('fs').promises; +const fsSync = require('fs'); +// 设置 schema(单一事实源)—— 主进程、preload、renderer 三处共享。 +// 新增/修改设置项只需改 shared/settings-schema.js 一处。 +const { validateAndSanitize } = require('./shared/settings-schema.js'); +// 配置持久化层(Stage 4b.1 抽离) +const configStore = require('./main/config-store'); +// 目录监听层(Stage 4b.2 抽离) +// 依赖通过参数注入 —— 不直接引用 mainWindow / configStore / scanFiles +// scanFiles 是函数声明,会被 hoist;这里注册时还未执行定义,但调用时已可用。 +const { createFsWatcher } = require('./main/fs-watcher'); +// 文件操作 helper(Stage 7 抽出):可测试的纯函数,IPC 注册仍在 main.js +// 包含 isWithinDataDir / resolveFileName / scanFiles / scanDir / assertNotSymlink / classifyEntry +const fileOps = require('./main/file-ops'); +const { + MAX_FILE_SIZE, + isWithinDataDir, + resolveFileName, + resolveRenameName, + scanDir, + assertNotSymlink, + assertNoSymlinkAncestor, + resolveDirRelative, + toRelativeDir, +} = fileOps; +// AI 代理(用 Node 18+ 内置 fetch 调 OpenAI 兼容 API) +const { createAiProxy } = require('./main/ai'); +// 启动标记:方便用户在 DevTools / 主进程控制台确认加载了最新代码 +// (修改 main.js / main/*.js 后必须完全退出重启,主进程代码只在启动时 require 一次) +console.log('[main] AI proxy loaded — token field routing per model: gpt-5/o-series → max_completion_tokens, others → max_tokens'); + +// 窗口图标和系统托盘共用同一图标 +const APP_ICON_PATH = path.join(__dirname, 'icon.ico'); + +// 日志路径脱敏 helper:console.error 写 tmp 文件时会带上 filePath(含用户名), +// 多用户系统上 tmp 可被其他用户读,且 ENOENT 也打路径 = 无成本枚举文件名。 +// 只保留 basename,定位「哪个文件」已经足够;完整路径不在 tmp 落地。 +function _redactPath(p) { + if (typeof p !== 'string' || !p) return p; + return path.basename(p); +} + +/** + * 把 file:create 阶段的 errno 翻译成中文,name 用于「创建 X 失败」前缀; + * 路径只放在 console(已 _redactPath),不让完整路径回到 renderer。 + * 与 file:write 的 renderer friendlyWriteError 一致地走「中文 + errno code」。 + */ +function _friendlyCreateError(e, name) { + const n = name || '文件'; + switch (e && e.code) { + case 'EACCES': + case 'EPERM': + return `没有写入权限,无法创建 ${n}`; + case 'ENOSPC': + return '磁盘空间不足,无法创建文件'; + case 'EROFS': + return '只读文件系统,无法创建文件'; + case 'EISDIR': + return '该路径是文件夹,无法写入'; + case 'ENOENT': + return '所在目录不存在,无法创建文件'; + default: + return `创建 ${n} 失败`; + } +} + +/** + * audit fix (K1-H1):file:rename 的 errno 翻译 —— 与 _friendlyCreateError + * 对称:原路径已过 isWithinDataDir / symlink 守卫,到这层基本只剩 EACCES / + * EPERM / ENOSPC / EROFS / EEXIST(同名的目录挡住文件 rename 在不同平台错 + * 误码不同)。新名字走 _friendlyCreateError 同款中文提示,不让英文 errno + * 直接回到 renderer(之前的 e.message 含「EPERM: operation not permitted」 + * + 完整旧路径,调试日志和 UI 都泄露)。 + */ +function _friendlyRenameError(e, newName) { + const n = newName || '文件'; + switch (e && e.code) { + case 'EACCES': + case 'EPERM': + return `没有重命名权限,无法将文件改名为 ${n}`; + case 'ENOSPC': + return '磁盘空间不足,无法重命名'; + case 'EROFS': + return '只读文件系统,无法重命名'; + case 'EEXIST': + case 'ENOTEMPTY': + return `已存在同名项,无法重命名为 ${n}`; + case 'ENOENT': + return '原文件已被删除,无法重命名'; + case 'EBUSY': + return '文件被占用,无法重命名'; + default: + return `重命名 ${n} 失败`; + } +} + +/** + * audit fix (K1-L2):file:delete 的 errno 翻译 —— 与上面 _friendly*Error + * 对称。shell.trashItem 失败的常见 errno:EACCES/EPERM(没权限)、 + * EBUSY(文件被占)、ENOENT(已不存在)、EACCES+Windows 共享冲突。 + */ +function _friendlyDeleteError(e) { + switch (e && e.code) { + case 'EACCES': + case 'EPERM': + return '没有删除权限,请检查文件权限'; + case 'EBUSY': + return '文件被其他程序占用,无法移到回收站'; + case 'ENOENT': + return '文件已不存在'; + case 'ENOTDIR': + case 'EISDIR': + return '目标路径不是文件'; + default: + return '无法移到回收站,请手动删除文件'; + } +} + +/** + * audit fix (K1-M2):file:read 的 errno 翻译。读路径常见 errno: + * EACCES/EPERM(权限)、EIO(磁盘 I/O)、EISDIR(路径是目录)、 + * ENAMETOOLONG(路径过长)。 + */ +function _friendlyReadError(e) { + switch (e && e.code) { + case 'EACCES': + case 'EPERM': + return '没有读取权限'; + case 'EIO': + return '磁盘 I/O 错误'; + case 'EISDIR': + return '该路径是文件夹,无法读取'; + case 'ENAMETOOLONG': + return '路径过长'; + default: + return '读取文件失败'; + } +} + +/** + * audit fix (K1-R4):shell/app:* handler 的 errno 翻译 —— 之前直接返回 + * 英文 e.message(带完整路径)到 renderer,3 个 handler(app:open-path / + * shell:show-item-in-folder / shell:open-dir)都漏。统一走 _friendlyShellError + * 翻译成中文,与 _friendlyReadError / _friendlyCreateError 对齐。 + * + * 这些 handler 多半是 stat 或 shell 操作,常见 errno: + * EACCES/EPERM(权限)、ENOENT(路径不在)、EIO(磁盘 I/O)、EBUSY(被占)。 + */ +function _friendlyShellError(e, fallback) { + switch (e && e.code) { + case 'EACCES': + case 'EPERM': + return '没有访问权限'; + case 'ENOENT': + return '路径不存在'; + case 'EIO': + return '磁盘 I/O 错误'; + case 'EBUSY': + return '文件被占用'; + case 'ENOTDIR': + return '目标路径不是目录'; + case 'EISDIR': + return '目标路径是文件夹'; + case 'ENAMETOOLONG': + return '路径过长'; + default: + return fallback || '操作失败'; + } +} + +// 抑制 Windows 上常见的 "Unable to move the cache" / "Gpu Cache Creation failed" 警告 +app.commandLine.appendSwitch('disable-gpu-cache'); +app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion'); + +let mainWindow = null; +let tray = null; +let isQuitting = false; +let currentDataDir = null; +let forceShowTimer = null; +let rendererDirty = false; // 渲染端是否有未保存改动 +// 关闭按钮触发的脏检查防重入:用户连点 × 时二次点击若没有 this 守卫会跑 +// 两次 confirmDiscardIfDirty(都 await promptRendererSave,让出事件循环), +// 弹出两个原生 dialog,关闭确认也跟着弹两次。镜像 before-quit 的 +// quitConfirmInFlight 模式。 +let closeConfirmInFlight = false; + +// 渲染进程崩溃计数器(防止 reload 死循环) +let renderGoneCount = 0; +const MAX_RENDER_GONE_RELOADS = 3; + +// 目录监听实例(Stage 4b.2 抽离到 main/fs-watcher.js) +// 注入依赖:getMainWindow 是闭包(每次访问时读 mainWindow 当前值), +// resolveDataDir 同理。scanDir / toRelDir 都是模块内函数,hoist 后可用。 +// Stage 8:注入 scanDir + toRelDir,监听目标是「当前显示目录」而非固定根目录。 +const fsWatcher = createFsWatcher({ + getMainWindow: () => mainWindow, + // 走 currentDataRoot 而不是 resolveDataDir:fsWatcher 的 updateSnapshot 里要 + // 用 dataRoot 重新做相对路径转换,customDir 失效场景下默认目录才是真实 root。 + resolveDataDir: () => currentDataRoot(), + scanDir, + toRelDir: (absDir) => toRelativeDir(absDir, currentDataRoot()), +}); + +// AI 代理实例 —— 每次 runEdit 都现读 configStore.getConfig(), +// 用户改完 BaseURL / API Key / Model 不需要重启。 +// P3-2 fix (audit):注入 log hook 让 ai:request / ai:http_error 在主进程 +// 控制台可见(线上排查 AI 失败时只能靠 DevTools network 面板)。 +// payload 白名单字段(url/status/detail/model/filename/promptLen/contentLen) +// 已经不含 apiKey / Authorization 头。 +const aiProxy = createAiProxy({ + getConfig: () => configStore.getConfig(), + log: (key, payload) => console.log(`[main][${key}]`, payload), +}); + +// ============================================ +// 启动日志 +// ============================================ + +console.log('[main] Electron 启动中...'); +console.log('[main] Platform:', process.platform); +console.log('[main] Electron version:', process.versions.electron); +console.log('[main] Project path:', __dirname); + +// audit fix (Round 9):全局兜底异常日志。之前的代码里 IPC handler / fsWatcher / +// AI 路径的异常基本都 try/catch + console.error,但仍有「外层 promise 链 +// 没人 await」「fsWatcher 内部 setTimeout 回调抛」之类的边界场景 —— 没有 +// 这里兜底会被 Node 默认行为吞掉(unhandledRejection 在新 Node 上是会 +// terminate 进程的,uncaughtException 默认打印 + 退出),连日志都不留。 +// 只 log 不 exit(与既有 try/catch 风格一致 —— 主进程挂了 IPC 全断)。 +process.on('uncaughtException', (err, origin) => { + console.error('[main] uncaughtException:', err && (err.stack || err.message || err), 'origin:', origin); +}); +process.on('unhandledRejection', (reason, promise) => { + console.error('[main] unhandledRejection at:', promise, 'reason:', reason && (reason.stack || reason.message || reason)); +}); + +// ============================================ +// 窗口创建 +// ============================================ + +function createWindow() { + if (forceShowTimer) { + clearTimeout(forceShowTimer); + forceShowTimer = null; + } + + mainWindow = new BrowserWindow({ + width: 1200, + height: 760, + minWidth: 600, + minHeight: 420, + backgroundColor: '#0a0a0c', + icon: APP_ICON_PATH, + show: false, + autoHideMenuBar: true, + // 自定义窗口:去掉系统标题栏,改由 renderer 顶部工具栏的 -webkit-app-region: drag + // 接管拖拽,并在工具栏右侧画出自定义最小化/还原/关闭按钮。 + frame: false, + titleBarStyle: 'hidden', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + } + }); + + mainWindow.loadFile('index.html'); + + // 启动时应用持久化的「始终置顶」状态 + if (configStore.getConfig().alwaysOnTop === true) { + mainWindow.setAlwaysOnTop(true); + } + + mainWindow.once('ready-to-show', () => { + console.log('[main] 窗口 ready-to-show,显示窗口'); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + } + broadcastMaximizeState(); + }); + + // 兜底:2 秒后如果还没显示,强制显示 + forceShowTimer = setTimeout(() => { + forceShowTimer = null; + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + console.warn('[main] ready-to-show 未触发,强制显示窗口'); + mainWindow.show(); + } + }, 2000); + // audit fix (Round 9):timer 句柄 .unref(),防止它单独阻塞进程退出。 + // 没有 .unref 的话,正常退出路径上 forceShowTimer 还没到 2s 触发的窗口里, + // electron 会等定时器到期才退(虽然窗口已 closed 但 event loop 还被它吊着)。 + // audit fix (Round 9):窗口重建时也会创建新的 forceShowTimer(createWindow + // 顶部清掉旧的)。unref 保证不阻塞退出。 + if (forceShowTimer && typeof forceShowTimer.unref === 'function') { + forceShowTimer.unref(); + } + + // 错误监听 + mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { + console.error('[main] 页面加载失败:', errorCode, errorDescription, validatedURL, 'isMainFrame:', isMainFrame); + // audit fix (Round 9):只对主框架错误弹致命 dialog。子框架(iframe)/ 主动 + // abort(errorCode=-3 ERR_ABORTED —— 用户按 Esc / navigate 取消)属正常 + // 路径,弹 dialog 反而扰民。errorCode=-3 是 Chromium 的 ERR_ABORTED。 + if (!isMainFrame || errorCode === -3) return; + if (!mainWindow || mainWindow.isDestroyed()) return; + // 主框架加载失败 → renderer 完全没起来,UI 也没法弹 toast。在主进程弹 + // 原生 dialog,让用户知道发生了什么并提供手动退出的按钮。 + try { + dialog.showMessageBoxSync(mainWindow, { + type: 'error', + title: '页面加载失败', + message: '应用主页面加载失败', + detail: `${errorDescription} (${validatedURL})\n\n请尝试重启应用或从任务管理器结束进程后重试。`, + buttons: ['退出', '继续(已加载的页面可能不可用)'], + defaultId: 0, + cancelId: 0, + }) === 1 && console.warn('[main] 用户选择继续,主框架加载失败但继续运行'); + } catch (e) { + console.error('[main] did-fail-load dialog 弹窗失败:', e); + } + }); + + mainWindow.webContents.on('render-process-gone', (event, details) => { + console.error('[main] 渲染进程崩溃:', details); + // 崩溃循环保护:3 次连续崩溃后不再 reload,避免无限循环占满 CPU + renderGoneCount += 1; + if (renderGoneCount > MAX_RENDER_GONE_RELOADS) { + console.error('[main] 渲染进程连续崩溃超过上限,停止自动 reload,请手动重启'); + // audit fix (Round 9):连续崩溃超上限弹致命 dialog 让用户决定。 + // renderer 已经不可用,没法自己弹 toast,必须在主进程走 dialog。 + if (mainWindow && !mainWindow.isDestroyed()) { + try { + dialog.showMessageBoxSync(mainWindow, { + type: 'error', + title: '渲染进程连续崩溃', + message: '应用渲染进程连续崩溃,已停止自动恢复', + detail: `请尝试重启应用。如果问题持续,请到 GitHub 仓库反馈(控制台有更多细节)。\n\n原因:${details.reason || 'unknown'}`, + buttons: ['退出', '关闭此提示'], + defaultId: 0, + cancelId: 0, + }); + } catch (e) { + console.error('[main] render-process-gone dialog 弹窗失败:', e); + } + } + return; + } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.reload(); + } + }); + + // 崩溃计数器只在「连续崩溃」时累加;reload 成功后再清零, + // 否则跨多次会话累积 3 次后就再也触发不了自动恢复了。 + mainWindow.webContents.on('did-finish-load', () => { + renderGoneCount = 0; + // audit fix (Round 9):renderer 重新加载(崩溃恢复 / Ctrl+R / 显示引导 + // 重启 renderer)后必须把 rendererDirty 重置为 false —— 否则上次崩溃 + // 之前的状态被「缓存」在主进程,before-quit 会拿这个 stale true 弹「未 + // 保存确认」但用户其实没东西可丢。renderer 重启后 state 是干净的,主 + // 进程需要跟上。 + rendererDirty = false; + // auto-fallback 2026-08:renderer 就绪后广播一次 data-dir:resolved。 + // 必须在 did-finish-load 里发,不能在 createWindow 末尾 —— + // 后者调用时 renderer 的 ipcRenderer.on 监听还没装好,事件被丢。 + // fellBack=false 时也发(让 renderer 拿到 defaultDir 用作 UI 文案); + // 监听器只在 fellBack=true 时弹引导弹窗,false 时只更新状态。 + broadcastDataDirResolved(); + }); + + // 拦截新窗口请求:markdown 里的 `` 默认会被 Electron 真的弹一个新窗口。 + // 我们要的是外部链接走 shell.openExternal(preload 已经处理),所以这里一律拒绝。 + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + console.warn('[main] 拒绝新窗口请求:', url); + return { action: 'deny' }; + }); + + // audit fix (Round 13 / Sec-L):拒绝所有权限请求。当前 threat model 下没有 + // 任何 feature 需要 media / geolocation / notifications / midi 等权限 —— 一旦 + // 未来 XSS 落地,攻击者若能让 renderer 调 navigator.geolocation / webcam 等, + // 没有这层兜底会直接拿到原始权限。Electron 默认对每个权限请求都会询问用户, + // 但「弹窗让用户点」不是真安全 —— 默认 deny + 显式 allow 才能把攻击面关到 0。 + // 设在 webContents 级:每窗口独立;若以后加 BrowserWindow 也要记得复制。 + mainWindow.webContents.session.setPermissionRequestHandler((_wc, _permission, callback) => { + callback(false); + }); + mainWindow.webContents.session.setPermissionCheckHandler(() => false); + + // audit fix (Round 9):F12 在所有平台都能打开 DevTools。 + // 菜单 accelerator `F12` 在 macOS 可见菜单栏下生效,但 Windows / Linux + // 上 autoHideMenuBar=true 让菜单隐藏 → accelerator 不再触发,README + // 承诺「Windows / Linux 也可」变成谎言。webContents.on('before-input-event') + // 是 Chromium 级的输入事件,不依赖菜单可见性,跨平台一致。 + // 只对 `F12`(不含修饰)生效,编辑器内也让位(CM6 不占 F12 但未来可能)。 + mainWindow.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') return; + if (input.key === 'F12' && !input.alt && !input.control && !input.meta && !input.shift) { + event.preventDefault(); + mainWindow.webContents.toggleDevTools(); + } + }); + + // 拦截任何页面内导航:只允许同源 file:// 加载 index.html。 + // 防止恶意 markdown 中的 把我们带到外部页面。 + mainWindow.webContents.on('will-navigate', (event, url) => { + const allowed = mainWindow.webContents.getURL(); + if (url === allowed) return; // 同 URL 重载 + // 任何外部 URL → 阻止并打开外部浏览器 + event.preventDefault(); + console.warn('[main] 拦截外部导航:', url); + if (/^https?:\/\//i.test(url)) { + shell.openExternal(url).catch((e) => console.error('[main] openExternal 失败:', e)); + } + }); + + // console-message 事件签名:Electron 35+ 派发 5-arg 兼容路径(runtime 不论 listener.length + // 是 1/2/5 都走同一内部接口),推荐用单参 `(event)` 直接读 WebContentsConsoleMessageEventParams: + // event.message - 消息文本 + // event.level - 'verbose' | 'info' | 'warning' | 'error' | 'debug'(字符串, + // 不是 Electron 28 时代的 0-3 数字;要相等比较 === 'error') + // event.lineNumber - 源代码行号 + // event.sourceId - 源代码 URL + // event.frame - WebFrameMain + // 走单参路径运行时不会再打印 deprecated 警告(仅多参 `on(..., (a,b) => ...)` 才会触发), + // 移除 console-message 监听会让 renderer 错误不可见,故保留。 + mainWindow.webContents.on('console-message', (event) => { + if (event.level === 'error') { + console.error(`[renderer ${event.sourceId}:${event.lineNumber}]`, event.message); + } + }); + + // 开发模式下自动打开 DevTools + if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) { + mainWindow.webContents.openDevTools({ mode: 'detach' }); + } + + // 关闭事件:有托盘时拦截关闭按钮改为隐藏到托盘(不丢数据,无需确认); + // 没有托盘时关闭就是退出,有未保存的改动必须先弹确认框。 + mainWindow.on('close', async (e) => { + // isQuitting 为 true 说明 before-quit 已经做过脏检查了,直接放行 + if (isQuitting) return; + e.preventDefault(); + + // 有托盘时关闭只是隐藏窗口,未保存的内容还在内存里,不该拿弹窗打扰用户; + // 真正的退出(托盘菜单、Cmd+Q 等)由 before-quit 统一做脏检查 + if (tray) { + hideWindow(); + return; + } + + // audit fix (Round 9):防 close 重入。用户双击 × 时第一次 confirmDiscardIfDirty + // 还在 await promptRendererSave(让出事件循环),第二次 close 事件进入 + // → 又走 confirmDiscardIfDirty → 两个原生 dialog 同时挂起。镜像 + // before-quit 的 quitConfirmInFlight 模式:第一次进入设标记,二次 click + // 直接 e.preventDefault 走人。 + if (closeConfirmInFlight) return; + closeConfirmInFlight = true; + try { + // 没有托盘:关闭窗口就是退出,丢改动之前必须确认 + const proceed = await confirmDiscardIfDirty(); + if (!proceed) return; + isQuitting = true; + app.quit(); + } finally { + closeConfirmInFlight = false; + } + }); + + mainWindow.on('maximize', () => broadcastMaximizeState()); + mainWindow.on('unmaximize', () => broadcastMaximizeState()); + + // 窗口最小化 / 收进托盘时暂停 readdir 轮询,重新可见时恢复并补扫一次 + mainWindow.on('minimize', () => fsWatcher.pauseDirPolling()); + mainWindow.on('hide', () => fsWatcher.pauseDirPolling()); + mainWindow.on('restore', () => fsWatcher.resumeDirPolling()); + mainWindow.on('show', () => fsWatcher.resumeDirPolling()); + + mainWindow.on('closed', () => { + console.log('[main] 窗口已关闭'); + // audit fix (Round 9):清理 forceShowTimer。createWindow 顶部也清,但 + // 异常路径下(窗口被强制销毁没走 createWindow)会泄漏 timer 句柄。 + // 这里 closed 兜底再清一次。forceShowTimer.unref() 之后不会阻塞退出, + // 但留着没意义(窗口已 null,timer 内回调的 isDestroyed 守卫也会拦下)。 + if (forceShowTimer) { + clearTimeout(forceShowTimer); + forceShowTimer = null; + } + fsWatcher.stopWatchingDir(); + mainWindow = null; + }); + + console.log('[main] 主窗口创建完成'); + + // audit fix (Round 7 FS-F4):createWindow 是重建路径(macOS activate / + // showWindow 的 destroyed 分支 / 渲染进程崩溃恢复)的统一入口。窗口重建 + // 后必须重新挂载目录监听,否则整个会话的文件监听彻底死掉,外部改动再也 + // 不会出现在侧栏。startWatchingDir 内部 stopWatchingDir 幂等,无副作用。 + try { + fsWatcher.startWatchingDir(currentDataRoot()); + } catch (e) { + console.warn('[main] 重建目录监听失败(仅依赖轮询):', e && e.message); + } +} + +// ============================================ +// 系统托盘 +// ============================================ + +function showWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(); + return; + } + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +function hideWindow() { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (forceShowTimer) { + clearTimeout(forceShowTimer); + forceShowTimer = null; + } + mainWindow.hide(); +} + +function toggleWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(); + return; + } + if (mainWindow.isVisible() && !mainWindow.isMinimized()) { + hideWindow(); + } else { + showWindow(); + } +} + +function buildTrayMenu() { + return Menu.buildFromTemplate([ + { label: '显示窗口', click: () => showWindow() }, + { label: '隐藏窗口', click: () => hideWindow() }, + { type: 'separator' }, + { + label: '始终置顶', + type: 'checkbox', + checked: configStore.getConfig().alwaysOnTop === true, + click: (item) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setAlwaysOnTop(item.checked); + } + // audit fix (C2):saveConfig 现在返回 Promise(异步写盘 + 串行 queue), + // fire-and-forget 路径加 .catch 避免写盘失败时 unhandled rejection。 + configStore.saveConfig({ alwaysOnTop: item.checked }).catch((e) => { + console.error('[main] tray alwaysOnTop 保存失败:', e?.error || e?.message || e); + }); + mainWindow?.webContents.send('always-on-top:changed', item.checked); + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + // 不在这里置 isQuitting:交给 before-quit 做「未保存改动」确认, + // 否则托盘退出会跳过脏检查直接丢数据。 + app.quit(); + } + } + ]); +} + +function createTray() { + if (tray) return; + try { + tray = new Tray(APP_ICON_PATH); + tray.setToolTip('Notes'); + tray.setContextMenu(buildTrayMenu()); + tray.on('click', () => toggleWindow()); + console.log('[main] 系统托盘已创建'); + } catch (e) { + console.error('[main] 系统托盘创建失败:', e.message); + tray = null; + } +} + +function destroyTray() { + if (!tray) return; + try { + tray.destroy(); + } catch (e) { + console.warn('[main] 销毁托盘失败:', e.message); + } + tray = null; +} + +// ============================================ +// 单实例锁 +// ============================================ +const gotSingleInstanceLock = app.requestSingleInstanceLock(); + +if (!gotSingleInstanceLock) { + console.log('[main] 已有实例在运行,激活已有窗口后退出本实例'); + isQuitting = true; + app.quit(); +} else { + app.on('second-instance', () => { + console.log('[main] 检测到第二个实例启动,激活已有窗口'); + showWindow(); + }); +} + +// ============================================ +// 应用生命周期 +// ============================================ + +app.whenReady().then(async () => { + if (!gotSingleInstanceLock) return; + + configStore.init(); + // auto-fallback 2026-08 + auto-create 默认目录:currentDataRoot() 内部走 + // resolveDataDirOrFallback,customDir 失效时回退到默认;返回 DEFAULT_DATA_DIR + // 时还会 sync mkdir(确保 fsWatcher 启动时不撞 ENOENT)+ 异步种子 welcome.md + // (fire-and-forget,下次 fsWatcher 第一次扫描大概率就绪)。 + // 之前用 resolveDataDir() 不做 stat、不 mkdir —— 用户首次启动默认目录还没建、 + // 或 U 盘掉线 fallback 路径下默认目录也从未被用过,都会让 fsWatcher.startWatchingDir + // 立刻 ENOENT 失败、侧栏空白、用户毫无线索。 + currentDataDir = currentDataRoot(); + // 注意:不要打印完整 config —— aiApiKey 等敏感字段不能进日志。 + // 只打印 "已加载" 这条确认信息;详情在出错时单独打。 + console.log('[main] 已加载配置'); + console.log('[main] 数据目录:', currentDataDir); + + // 等欢迎文档就绪再启动 fsWatcher —— 默认目录刚创建的情况下,先把 welcome.md + // 写好再让 fsWatcher 开始监听,避免「fsWatcher 第一次扫描拿到空目录、几秒 + // 后 welcome.md 突然冒出侧栏」的 UX 撕裂。resolveDataDirOrFallback 内部已 + // fire-and-forget 异步种子一次,这里 await 的是同一份 in-flight promise。 + await configStore.scheduleEnsureDefaultDataDir(); + + console.log('[main] app ready'); + buildMenu(); + createTray(); + createWindow(); + + // 启动目录监听器(默认目录已 sync mkdir + welcome.md 已 async 种子完毕) + fsWatcher.startWatchingDir(currentDataDir); + + app.on('activate', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + showWindow(); + } else if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}).catch(err => { + console.error('[main] app ready 失败:', err); +}); + +// 退出确认是否正在进行,防止用户连点托盘「退出」时叠出多个原生框 +let quitConfirmInFlight = false; + +// 所有退出路径(托盘退出、Cmd+Q、系统关机请求)都会经过 before-quit。 +// isQuitting 只有在这里确认过、或单实例锁失败时才会被置为 true。 +app.on('before-quit', async (e) => { + if (isQuitting) return; + + if (!rendererDirty) { + isQuitting = true; + return; + } + + // 已经弹着一个确认框了,忽略这次退出请求 + if (quitConfirmInFlight) { + e.preventDefault(); + return; + } + + e.preventDefault(); + quitConfirmInFlight = true; + try { + const proceed = await confirmDiscardIfDirty(); + if (!proceed) return; + isQuitting = true; + app.quit(); + } finally { + quitConfirmInFlight = false; + } +}); + +app.on('will-quit', () => { + // 退出时取消所有在飞的 AI 请求,避免响应被丢弃(付费用 API 也会浪费额度)。 + // audit fix (main-M11):把同步清理拆出来,把 revealArmedBySender 兜底清理 + // 也挪到这里(armed TTL 兜底 setTimeout 已经 .unref,正常退出流程不会等它, + // 显式清理避免 reveal Map 在长跑测试 / 频繁 reload 场景下累积 entry)。 + try { aiProxy.cancelAll(); } catch (e) { console.warn('[main] cancelAll 失败:', e && e.message); } + // audit fix (main-M12a):revealArmedBySender 在 will-quit 显式清空。 + // 既有 TTL 兜底(armed 5s 后 setTimeout 自动删),但用户开窗口→立刻退的 + // 短路径上 entry 仍占着 Map。shutdown 时主动清掉,调试时也能在 heap + // snapshot 看到模块级 Map 是干净的。 + try { revealArmedBySender.clear(); } catch (e) { console.warn('[main] revealArmed 清理失败:', e && e.message); } + // audit fix (Round 7 FS-F15):will-quit 显式停 fsWatcher。fsWatcher 持有 + // 1 个 FSWatcher 句柄 + 1 个 setInterval + 最多 2 个 setTimeout,长期跑 + // 测试 / 频繁 reload 场景下堆积。closed 路径已经 stopWatchingDir,但 + // macOS `before-quit` 可能跳过 closed 直接 will-quit —— 这里兜底。 + try { fsWatcher.stopWatchingDir(); } catch (e) { console.warn('[main] fsWatcher 清理失败:', e && e.message); } + destroyTray(); +}); + +app.on('window-all-closed', () => { + console.log('[main] 所有窗口已关闭'); + if (tray) return; + if (process.platform !== 'darwin') app.quit(); +}); + +// ============================================ +// IPC: 渲染端脏状态同步 + 触发保存 +// ============================================ + +ipcMain.handle('renderer:set-dirty', (_event, isDirty) => { + // 仅同步标志位,供 confirmDiscardIfDirty 在退出路径上判断「是否弹未保存确认框」。 + // 不在此处刷新托盘菜单 —— dirty 状态与托盘菜单项(始终置顶、显示主窗口)无关。 + rendererDirty = !!isDirty; + return true; +}); + +/** + * 通知渲染端立刻保存当前文件。 + * 通过 webContents.send + 一次性监听 IPC 返回值的方式拿到结果。 + * + * audit fix (M5 main):webContents.send 在 webContents 已 destroyed(但 + * mainWindow 还没被回收的极小窗口)会抛 TypeError —— 旧实现没 try/catch, + * 整个 Promise 永远 hang 在 unhandled 里;confirmDiscardIfDirty 调用方 + * `await promptRendererSave()` 永远等不到 resolve,「关闭窗口」操作死锁。 + * 收紧护栏:send 失败 → 立即解 timer + resolve(false);webContents 中途 + * destroyed → 同样提前 resolve(false) 不再等 10s。 + * + * @returns {Promise} true 表示保存成功(或没有需要保存的内容) + */ +function promptRendererSave() { + return new Promise((resolve) => { + if (!mainWindow || mainWindow.isDestroyed()) { + resolve(false); + return; + } + const reqId = `save-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const channel = `renderer:save-result:${reqId}`; + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + clearTimeout(hardTimer); + clearTimeout(graceTimer); + try { ipcMain.removeAllListeners(channel); } catch { /* ignore */ } + // Phase N M1 fix:早退路径(IPC 收到结果 / 窗口 destroyed / send 失败)清掉 + // graceTimer 与 once('destroyed'),否则 onDestroyed 会留到窗口死亡才被拆、 + // graceTimer 会留到 15s 才被 Node 自然 GC,延迟 process.exit 0.5s ~ 5s + // (取决于哪条路径先 settle)。granted settled 守卫让它们是 no-op,但 timer/ + // listener 仍占资源。 + try { wc && wc.off('destroyed', onDestroyed); } catch { /* ignore */ } + resolve(value); + }; + + // audit fix (Main P1 / promptRendererSave grace timer 死代码): + // 旧版 hardTimer(10s) + graceTimer(15s) 两个 timeout 都直接调 + // settle(false) —— 因为 hardTimer 先 fire,settled 守卫让 graceTimer + // 必是 no-op,「宽限期接受晚到的 ok=true 结果」根本没生效,等同于 + // 一段被注释误导的 dead code。 + // 修正:hardTimer fire 时只让出监听器(不再 settle),graceTimer fire + // 时才 settle(false)。这样 10-15s 之间晚到的 renderer:save-result:{reqId} + // 仍能走 settle(ok=true),避免大文件 / 慢盘下「renderer 实际写盘成功 + // 但晚到 10s+」被错判为保存失败 → 用户看到「取消关闭」,实际文件已 + // 保存的诡异状态。 + const HARD_DEADLINE_MS = 10000; + const GRACE_PERIOD_MS = 15000; + let hardTimer = setTimeout(() => { /* 进入宽限窗口:监听器保留 */ }, HARD_DEADLINE_MS); + let graceTimer = setTimeout(() => settle(false), GRACE_PERIOD_MS); + + ipcMain.once(channel, (_event, result) => { + // 宽限期内(10-15s 之间)到达的结果一律接受;hardTimer 之前照常 + // 接受;graceTimer 之后 / 已 settled 直接被 settled 守卫挡掉。 + settle(!!(result && result.ok)); + }); + + // webContents 中途被销毁(极少见但可能:before-quit 序列里被 GC) + const wc = mainWindow.webContents; + const onDestroyed = () => settle(false); + if (wc && !wc.isDestroyed()) { + wc.once('destroyed', onDestroyed); + } + + try { + wc.send('renderer:save-request', { reqId }); + } catch (e) { + console.error('[main] promptRendererSave send 失败:', e.message); + settle(false); + } + }); +} + +/** + * 有未保存改动时弹原生三选一确认框(保存 / 丢弃 / 取消)。 + * close(无托盘)和 before-quit 共用,保证任何退出路径都不会静默丢数据。 + * 隐藏到托盘不算退出,不走这里。 + * @returns {Promise} true = 可以继续退出;false = 用户取消 / 保存失败 + */ +/** + * confirmDiscardIfDirty 递归上限。防止「用户一直敲字 → 保存后又脏 → 反复弹原生框」 + * 死循环。Phase N-A1 修复用了递归弹框,第 MAX_CONFIRM_RECURSION 次直接返回 false + * 强制用户取消或丢弃。 + */ +const MAX_CONFIRM_RECURSION = 3; + +/** + * confirmDiscardIfDirty 的递归保护版本。depth=0 时不再弹原生框, + * 直接返回 false(强制用户取消或丢弃),防"用户一直敲字 → 反复提示"的死循环。 + */ +async function _confirmDiscardIfDirtyBounded(depth) { + if (depth <= 0) return false; + // audit fix (Round 8 M-2):必须把 depth 透传下去。旧实现调 confirmDiscardIfDirty() + // 不带参 → 内部又用常量 MAX_CONFIRM_RECURSION - 1 递归 → depth 恒为 2, + // 「第 3 次强制 return false」的安全网从未生效,理论上可无限递归到栈溢出。 + return await confirmDiscardIfDirty(depth); +} + +async function confirmDiscardIfDirty(depth = MAX_CONFIRM_RECURSION) { + if (!rendererDirty) return true; + if (!mainWindow || mainWindow.isDestroyed()) return true; + + // 窗口可能已经隐藏到托盘了,先显示出来,否则原生框会挂在看不见的窗口上 + if (!mainWindow.isVisible() || mainWindow.isMinimized()) { + showWindow(); + } + + const choice = dialog.showMessageBoxSync(mainWindow, { + type: 'warning', + buttons: ['保存后退出', '丢弃并退出', '取消'], + defaultId: 0, + cancelId: 2, + title: '未保存的改动', + message: '当前文件有未保存的改动。\n退出将丢失这些改动。', + detail: '「保存后退出」会写入磁盘再退出;「丢弃并退出」直接退出;「取消」回到窗口。', + noLink: true, + }); + + if (choice === 2) return false; + + if (choice === 0) { + const ok = await promptRendererSave(); + if (!ok) { + dialog.showMessageBoxSync(mainWindow, { + type: 'error', + buttons: ['好'], + title: '保存失败', + message: '保存当前文件失败,已取消退出。', + detail: '请回到窗口检查文件是否可写,或选择「丢弃并退出」。', + noLink: true, + }); + return false; + } + // audit fix (Phase N-A1):save 飞行期用户输入的数据丢失路径。 + // 旧实现:promptRendererSave 解析为 ok → confirmDiscardIfDirty 直接 return true + // → before-quit 看到 isQuitting=true 放行 → app.quit() → 编辑器销毁 → + // 飞行期用户敲的字(C2)永久丢失(盘上是 C1)。 + // + // 修复:在保存成功后再 re-check 一次 rendererDirty。rendererDirty 在 save 成功 + // 路径里会被 syncSaveButton() → setDirty(false) 写回 false,但飞行期用户又敲字 + // 会再次翻 true。如果保存后 dirty 重新为 true,说明用户在 save IPC 飞行期 + // 输入了新内容 —— 我们没有这些内容的盘上版本,强制退出等于丢字。 + // + // 处理:弹"保存后又产生新改动"原生框,用户重新决策(继续保存 / 丢弃 / 取消)。 + // 取消则用户继续编辑;继续保存走递归 → promptRendererSave 二次飞行再被异步 + // 重读 dirty 时可能再次脏——但主进程 IPC 在 await 期间不阻塞(renderer 的 + // editor/view dispatch 同步触发 setDirty IPC 写回 rendererDirty)。最坏 + // 情况用户看到"保存后又有新改动"反复弹——明确告知比静默丢字好得多。 + if (rendererDirty) { + const retry = dialog.showMessageBoxSync(mainWindow, { + type: 'warning', + buttons: ['再次保存', '丢弃并退出', '取消'], + defaultId: 0, + cancelId: 2, + title: '保存后又产生了新改动', + message: '文件已保存到磁盘,但保存过程中又输入了新内容。', + detail: '「再次保存」会写入最新的内容再退出;「丢弃并退出」直接退出,丢失保存后的新内容;「取消」回到窗口继续编辑。', + noLink: true, + }); + if (retry === 2) return false; // 取消:继续编辑 + if (retry === 1) return true; // 丢弃:直接退出 + // retry === 0:再次保存。递归调 confirmDiscardIfDirty 重新走完整流程。 + // 限制递归深度 ≤ MAX_CONFIRM_RECURSION 防极端时序死循环(用户一直敲字 → + // 永远保存后又脏)。depth 沿调用链递减,耗尽后 _confirmDiscardIfDirtyBounded + // 直接 return false(= 取消退出,回窗口继续编辑),不丢数据。 + return await _confirmDiscardIfDirtyBounded(depth - 1); + } + } + return true; +} + +// ============================================ +// IPC: 文件操作(只读) +// ============================================ + +/** + * 取当前数据目录路径(IPC handler 统一封装) + * + * auto-fallback 2026-08:改走 resolveDataDirOrFallback() —— custom 路径不存在时 + * runtime 回退到默认(saved 字段保留原值,U 盘插回下次启动还能用回去)。 + * 之前直接调 resolveDataDir() 不做 stat,缺失时整个文件 IO 全炸、用户侧栏空白。 + */ +function currentDataRoot() { + return configStore.resolveDataDirOrFallback().dir; +} + +/** + * 启动时广播一次 data-dir:resolved 给 renderer,让 UI 在 fallback 时弹一次 + * 「数据文件夹不可访问」的引导弹窗 / toast。fallBack=false 时不发(无意义噪音)。 + */ +function broadcastDataDirResolved() { + if (!mainWindow || mainWindow.isDestroyed()) return; + const info = configStore.resolveDataDirOrFallback(); + mainWindow.webContents.send('data-dir:resolved', { + dir: info.dir, + defaultDir: configStore.getDefaultDataDir(), + fellBack: info.fellBack, + saved: info.saved, + }); +} + +ipcMain.handle('file:list', async () => { + const dir = currentDataRoot(); + currentDataDir = dir; + // audit fix (Main P2 / file:list no validation):其他 handler(file:scan-dir + // / file:read / file:write / file:create / file:rename / file:delete)都先 + // 调 assertNoSymlinkAncestor 拒绝祖先链含 symlink 的目标;file:list 是早期 + // 入口,漏了这一步。 + // 威胁面:用户配置 dataDir 指到一个普通目录,但该目录里某个子项(含 + // file:list 直接扫描的根)被替换成 symlink 指向外部(恶意诱导 / 备份恢复 + // 时残留)—— 旧实现 fs.readdir + fs.stat 跟随 symlink,scanDir 把目标当 + // 成普通目录返回,渲染端按内部文件展示,触发 NTLM 偷凭 + 路径穿越。 + const ancestorCheck = await assertNoSymlinkAncestor(dir, dir); + if (!ancestorCheck.ok) { + return { + ok: false, + files: [], + entries: [], + error: ancestorCheck.error, + code: ancestorCheck.code, + message: ancestorCheck.message, + }; + } + // Folder Browser:根目录也走 scanDir,渲染端用 entryType 分流显示文件夹 / 文件 / binary。 + // 为了兼容老调用方,仍然把 entries 镜像成 files(仅 entryType==='editable'|'binary' 的项)。 + const result = await scanDir(dir); + if (!result.ok) { + return { + ok: false, + files: [], + entries: [], + error: result.error, + code: result.code, + message: result.message, + }; + } + // files = 非文件夹条目(侧栏历史逻辑兼容:state.files 仅含文件) + const files = result.entries + .filter((e) => !e.isFolder) + .map((e) => ({ name: e.name, path: e.path, size: e.size, mtimeMs: e.mtimeMs, entryType: e.entryType })); + return { + ok: true, + files, + entries: result.entries, + dir: result.dir, + relDir: '', + }; +}); + +/** + * file:scan-dir —— Folder Browser 的核心入口(Stage 8) + * + * 入参 relDir 是 dataRoot 下的 POSIX 风格相对路径('notes/2026'); + * 空串表示根目录。返回一层所有条目(文件夹 + 文件 + binary)。 + * + * 渲染端用这个在子目录之间导航;fs-watcher 也通过 {relDir} 字段告诉 + * 渲染端「变更发生在哪一层」,渲染端比对当前目录决定是否重扫。 + */ +ipcMain.handle('file:scan-dir', async (_event, relDir) => { + const dataRoot = currentDataRoot(); + const resolved = resolveDirRelative(typeof relDir === 'string' ? relDir : '', dataRoot); + if (!resolved.ok) { + return { ok: false, error: resolved.error, message: resolved.message }; + } + // audit fix (Phase O-H1):祖先链 symlink 检查。 + // 之前只 lstat(absDir) 自身,但 dataRoot/links 是 symlink 指向外部时, + // absDir = dataRoot/links/foo 是合法路径(symlink 在祖先链上), + // 自身不是 symlink → 通过检查。scanDir 跟随 symlink 读到外部目录文件列表, + // 渲染后等于无成本枚举外部目标内容。 + // assertNoSymlinkAncestor 从 absDir 一路 lstat 到 dataRoot , + // 中间任一层是 symlink 就拒绝 —— 与 file:read/write/rename/delete 对齐。 + const ancestorCheck = await assertNoSymlinkAncestor(resolved.absDir, dataRoot); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + const result = await scanDir(resolved.absDir); + if (!result.ok) { + return { + ok: false, + error: result.error, + code: result.code, + message: result.message, + relDir: resolved.relDir, + }; + } + return { ok: true, dir: result.dir, relDir: resolved.relDir, entries: result.entries }; +}); + +/** + * file:watch-dir —— 切换 fs-watcher 的监听目标到指定子目录(Folder Browser 用)。 + * + * 渲染端进入子目录时调用: + * await api.watchDir('notes/2026'); + * 之后 fs-watcher 推送的 files:changed 事件只与该子目录相关(payload.relDir 同步)。 + * + * 与 startWatchingDir / scanFiles 基线的区别:startWatchingDir 会清基线并按目录类型重新初始化, + * 这里直接复用工厂内部的 rewatch(),避免「切到同名目录时无谓重置基线」。 + * + * relDir 必须经过 resolveDirRelative 校验,防止传入绝对路径或 .. 段越权。 + */ +ipcMain.handle('file:watch-dir', async (_event, relDir) => { + const dataRoot = currentDataRoot(); + const resolved = resolveDirRelative(typeof relDir === 'string' ? relDir : '', dataRoot); + if (!resolved.ok) { + return { ok: false, error: resolved.error, code: resolved.code, message: resolved.message }; + } + // audit fix (Phase O-H2):祖先链 symlink 检查。 + // 之前只 lstat(absDir) 自身。fs.watch(absDir) 会跟随 symlink 解析到外部目标的 inode, + // watcher 在外部目标盘上持锁并把外部目录变更以 files:changed 推送回 renderer + // (payload.relDir = 内部相对路径,renderer 误以为是 dataRoot 子目录活动)。 + // assertNoSymlinkAncestor 挡住祖先链含 symlink 的目标。 + const ancestorCheck = await assertNoSymlinkAncestor(resolved.absDir, dataRoot); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + try { + fsWatcher.rewatch(resolved.absDir); + return { ok: true, dir: resolved.absDir, relDir: resolved.relDir }; + } catch (e) { + // audit fix (K1-R4):与其它 handler 对齐 code/message 形状,不再直回 e.message。 + return { ok: false, error: e.code || 'WATCH_FAILED', code: e.code, message: '无法切换监听目录' }; + } +}); + +ipcMain.handle('file:read', async (event, filePath) => { + if (typeof filePath !== 'string' || !filePath) { + // audit fix (Round 7 IPC-1):补齐 message 字段。renderer 走 + // `result.message || result.error || '未知错误'` 兜底,没 message 会退到 + // 英文原文 + 文案语言漂移。 + return { + ok: false, + error: 'INVALID_PATH', + code: 'INVALID_PATH', + message: '路径必须是字符串', + }; + } + // 路径必须在当前数据目录下(防御性:防止 renderer 越权访问) + if (!isWithinDataDir(filePath, currentDataRoot())) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; + } + // symlink 防护(audit Q3):与 file:write/rename/delete 对齐。 + // 之前 fs.stat(filePath) 会跟随 symlink → dataDir 内的 symlink 指向外部文件时, + // renderer 可以通过 file:read 越权读到外部文件内容。lstat 拒绝 symlink。 + const linkCheck = await assertNotSymlink(filePath); + if (!linkCheck.ok) { + return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; + } + if (linkCheck.isFile === false) { + return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件已被删除' }; + } + // audit fix (main-M2):assertNotSymlink 只看 filePath 自身,而 fs.stat / + // fs.readFile 会跟随 filePath 祖先链上的 symlink 目录。dataDir/sub 是 symlink + // 指向外部目录时,readFile 实际读到 dataDir 之外的内容。补一次祖先检查, + // 与 file:write/rename/delete 的 M1 修复保持对称。 + const ancestorCheck = await assertNoSymlinkAncestor(filePath, currentDataRoot()); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + try { + const st = await fs.stat(filePath); + if (!st.isFile()) { + return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '不是普通文件' }; + } + if (st.size > MAX_FILE_SIZE) { + return { + ok: false, + error: 'FILE_TOO_LARGE', + code: 'FILE_TOO_LARGE', + message: `文件过大(${(st.size / 1024 / 1024).toFixed(1)} MB),已超过 ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)} MB 上限` + }; + } + const content = await fs.readFile(filePath, 'utf-8'); + // audit fix (Phase L3-FS 2A):去掉 UTF-8 BOM(Notepad 等 Windows 工具会带 + // 字节序标记存 UTF-8)。BOM 在第一行 heading 之前渲染成零宽字符,会让 + // 文内锚点的 slug 偏移 / 复制粘贴时污染 / DOMPurify 把它当成不可见字符 + // 吞掉前的不一致。原子写会原样写回字节(BOM 是三字节 0xEFBBBF), + // 这里 strip 后 save 也是无 BOM,写盘时已经清掉。 + let stripped = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content; + // audit fix (Phase L3-FS 1A):detect binary content misnamed as .md。 + // fs.readFile(..., 'utf-8') 把无效字节替换成 U+FFFD(�)。JPEG/PNG/ZIP 等 + // 二进制文件被外部改名成 .md 时直接命中 EDITABLE_EXTS 走 readFile, + // 用户编辑保存 → atomicWriteFile 把污染后的 U+FFFD 字符串写回,原二进制 + // 内容永久丢失。简单阈值:前 4 KB 内 U+FFFD 占比 > 1% → 当作二进制拒绝。 + // (正常 UTF-8 文本几乎不会有 U+FFFD;CJK 偶发坏字符也不至于达到 1%。) + if (stripped.length > 0) { + const probeLen = Math.min(stripped.length, 4096); + let replacements = 0; + for (let i = 0; i < probeLen; i += 1) { + if (stripped.charCodeAt(i) === 0xFFFD) replacements += 1; + } + if (replacements / probeLen > 0.01) { + return { + ok: false, + error: 'NOT_TEXT', + code: 'NOT_TEXT', + message: '该文件不是有效的 UTF-8 文本(疑似二进制),无法编辑', + }; + } + } + return { ok: true, content: stripped, mtimeMs: st.mtimeMs, size: st.size }; + } catch (e) { + if (e.code === 'ENOENT') { + return { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '文件已被删除' }; + } + // audit fix (K1-M2):message 之前直回 e.message(英文 errno + 完整路径), + // 跟 file:write 的 renderer friendlyWriteError 不一致 —— renderer 一律走 + // 「未知错误」分支。按 errno 翻译。 + console.error('[main] 读取文件失败:', _redactPath(filePath), e.code || e.message); + return { + ok: false, + error: e.code || 'READ_FAILED', + code: e.code, + message: _friendlyReadError(e), + }; + } +}); + +/** + * file:write —— 新增 expectedMtimeMs 校验(audit #2) + * + * renderer 传入 expectedMtimeMs(state.lastSavedMtimeMs)时,主进程先 + * 读当前磁盘 mtime,不一致则拒绝写入并返回 FILE_CHANGED_EXTERNALLY。 + * 这样可以挡住「外部编辑器刚刚修改 → fs:changed 还没投到 renderer → 用户 Ctrl+S + * 静默覆盖外部修改」的竞态。 + * + * 不传 expectedMtimeMs(老调用 / 强写场景)则跳过校验。 + */ +ipcMain.handle('file:write', async (event, filePath, content, expectedMtimeMs = null) => { + if (typeof filePath !== 'string' || !filePath) { + // audit fix (Round 7 IPC-3):补齐 code + message,对齐其它 file:* handler 的 + // 字段集。renderer 一律 `result.code / result.message` 取值,没这两个字段 + // 会兜底走 result.error 英文原文 + 文案漂移。 + return { + ok: false, + error: 'INVALID_PATH', + code: 'INVALID_PATH', + message: '路径必须是字符串', + }; + } + if (typeof content !== 'string') { + return { + ok: false, + error: 'INVALID_CONTENT', + code: 'INVALID_CONTENT', + message: '内容必须是字符串', + }; + } + // 写入大小限制:与 file:read 对齐(MAX_FILE_SIZE),避免恶意 / bug 写入巨型文件导致下次读不出 + if (Buffer.byteLength(content, 'utf8') > MAX_FILE_SIZE) { + return { + ok: false, + error: 'FILE_TOO_LARGE', + code: 'FILE_TOO_LARGE', + message: `文件超过 ${MAX_FILE_SIZE} 字节上限`, + }; + } + // 路径必须在当前数据目录下(防御性:防止 renderer 越权写入) + if (!isWithinDataDir(filePath, currentDataRoot())) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; + } + // symlink 防护(audit #7):写入 symlink 会跟随到外部文件,绕过 isWithinDataDir + const linkCheck = await assertNotSymlink(filePath); + if (!linkCheck.ok) { + return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; + } + // audit fix (M1):symlink 祖先链防护 —— 父目录是 symlink 时,target 的 + // 真实路径会跳出 dataDir。光检查 target 文件不够。 + const ancestorCheck = await assertNoSymlinkAncestor(filePath, currentDataRoot()); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + if (linkCheck.isFile === false) { + // audit fix (M2):assertNotSymlink 不区分「目录」与「不存在」,与 file:rename 对齐: + // 命中目录 → NOT_A_FILE,让 renderer 给出「该路径是文件夹」的中文提示;命中不存在 → FILE_NOT_FOUND。 + // 否则 fs.writeFile 在目录上会抛 EISDIR 但被 catch 转成通用 WRITE_FAILED,用户只看到 errno。 + let st; + try { + st = await fs.stat(filePath); + } catch { + st = null; + } + if (st && st.isDirectory()) { + return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '该路径是文件夹,不能写入' }; + } + return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件已被删除' }; + } + try { + // mtime 校验(audit #2) + if (Number.isFinite(expectedMtimeMs)) { + const stNow = await fs.stat(filePath); + // Math.floor 兼容 sub-ms 浮点误差 + if (Math.floor(stNow.mtimeMs) !== Math.floor(expectedMtimeMs)) { + return { + ok: false, + error: 'FILE_CHANGED_EXTERNALLY', + code: 'FILE_CHANGED_EXTERNALLY', + mtimeMs: stNow.mtimeMs, + size: stNow.size, + message: '文件在外部被修改,请重新加载或选择强制覆盖', + }; + } + } + // audit fix (C1 file-IO):原子写(tmp + fsync + rename),防止进程在 + // writeFile 中途崩溃导致文件内容半截(用户丢失全部编辑)。 + await fileOps.atomicWriteFile(filePath, content); + const st = await fs.stat(filePath); + return { ok: true, mtimeMs: st.mtimeMs, size: st.size }; + } catch (e) { + if (e.code === 'ENOENT') { + return { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '文件已被删除' }; + } + // P1-7:权限 / 只读 / 磁盘满 / 文件被占用 → 把原始 code 透出,renderer 才能给中文提示 + // + // audit fix (Phase N Q-fix):message 字段从 e.message(含英文 errno + 完整 + // 路径,可能泄露)改为 fileOps.friendlyWriteError(e) 翻译成中文,与 _friendlyCreateError + // / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError 对齐。 + // helper 放在 main/file-ops.js(不是本文件)方便单测 —— main.js 加载 Electron + // app 实例副作用重,没法在 vitest 里 require。 + console.error('[main] 写入文件失败:', _redactPath(filePath), e.message); + return { + ok: false, + error: e.code || 'WRITE_FAILED', + code: e.code, + message: fileOps.friendlyWriteError(e), + }; + } +}); + +ipcMain.handle('file:create', async (_event, rawName, opts) => { + if (typeof rawName !== 'string') { + return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: '文件名必须是字符串' }; + } + const dataRoot = currentDataRoot(); + if (!dataRoot) return { ok: false, error: 'DATA_DIR_UNAVAILABLE', code: 'DATA_DIR_UNAVAILABLE', message: '数据目录不可用' }; + + // Stage 8:file:create 扩展签名以支持子目录。 + // 向后兼容旧调用:(name, initialContentString) —— 旧 IPC 把 initialContent 当字符串。 + // 新调用:(name, { dir?, initialContent? })。 + let targetDir = dataRoot; + let initialContent = ''; + if (typeof opts === 'string') { + initialContent = opts; + } else if (opts && typeof opts === 'object') { + if (typeof opts.initialContent === 'string') initialContent = opts.initialContent; + if (typeof opts.dir === 'string' && opts.dir) { + // dir 必须是 dataRoot 下的绝对路径或根 + if (!isWithinDataDir(opts.dir, dataRoot)) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '目标目录不在数据目录内' }; + } + targetDir = opts.dir; + } + } + + // audit fix:与 file:write 对齐写入大小上限(**在 mkdir 之前**)。 + // 之前 file:create 漏掉 MAX_FILE_SIZE 检查,导致 initialContent 可塞任意大字符串 + // (粘贴一篇 100MB 小说)→ 下次 file:read 直接返回 FILE_TOO_LARGE,应用被自己 + // 写入的文件锁死、删都删不掉(rename/delete 也可能受影响)。在这里预先拒绝。 + // 放在 mkdir 之前:超大 initialContent 没必要先在磁盘上建空目录再拒绝,浪费 IO。 + if (Buffer.byteLength(initialContent, 'utf8') > MAX_FILE_SIZE) { + return { + ok: false, + error: 'FILE_TOO_LARGE', + code: 'FILE_TOO_LARGE', + message: `初始内容超过 ${MAX_FILE_SIZE} 字节上限`, + }; + } + + // 子目录可能不存在(用户首次进入空子目录并新建文件)→ 自动创建。 + // 这是有意的:用户在 UI 上明确选择了"在该目录下新建",自动 mkdir 是合理语义。 + // + // audit fix (Phase O-H3):祖先链 symlink 检查必须在 mkdir 之前。 + // 之前只 lstat(targetDir) 自己:targetDir 不存在时 ENOENT 跳过;然后 + // mkdir(targetDir, {recursive:true}) 会跟随 symlink 在外部目标盘上真实创建 + // 子目录,副作用先发生;之后 line 1307 的 assertNoSymlinkAncestor(r.path) + // 才拒绝 atomicWriteFile —— 用户没创建文件但外部目标盘上多了空目录。 + // assertNoSymlinkAncestor 覆盖 targetDir 自身 + 全部祖先,等价且更严格。 + if (targetDir !== dataRoot) { + const ancestorCheck = await assertNoSymlinkAncestor(targetDir, dataRoot); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + } + try { + await fs.mkdir(targetDir, { recursive: true }); + } catch (e) { + // audit fix (K1-H2):mkdir 失败之前直回 e.message(英文 + 含完整路径), + // 与上面 atomicWrite 的 _friendlyCreateError 不对称;renderer 一律走 + // 「未知错误」分支。按 errno 翻译:EACCES/EPERM = 没权限, + // ENOSPC = 磁盘满,EROFS = 只读,ENOENT = 路径里有不存在组件。 + console.error('[main] file:create mkdir 失败:', _redactPath(targetDir), e.code || e.message); + return { + ok: false, + error: e.code || 'MKDIR_FAILED', + code: e.code, + message: _friendlyCreateError(e, '所在目录'), + }; + } + + 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) { + return { + ok: false, + 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)) { + 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 }; + } + + try { + // 初始内容默认为 `# 标题\n\n` —— 哪怕空字符串也保证文件可读 + // 2026-08-28 反馈:resolveFileName 不再强制补 .md,所以这里按最后一个 . + // 剥除任意扩展名(foo.md → foo;foo.txt → foo;无扩展名 → 原样)。 + const baseForTitle = r.name.replace(/\.[^./\\]+$/, ''); + const content = typeof initialContent === 'string' && initialContent.length > 0 + ? initialContent + : `# ${baseForTitle}\n\n`; + // audit fix (C2 file-IO):原子写,与 file:write 对齐。 + // 即便 create 失败也不该留半截文件——磁盘要么没这个文件,要么是完整的。 + await fileOps.atomicWriteFile(r.path, content); + const st = await fs.stat(r.path); + return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; + } catch (e) { + // audit fix (1.1):补齐 code / message 字段,与 file:read/write 对齐, + // renderer 的 friendlyWriteError 才能按 errno 给中文提示。 + // audit fix (B-2):这里之前直接返回 e.message(英文 + 含完整路径),与 file:write + // 不一致。按 errno 翻译成中文提示,路径走 _redactPath 防泄露到 tmp 日志。 + console.error('[main] file:create 失败:', _redactPath(r.path), e.code || e.message); + return { + ok: false, + error: e.code || 'CREATE_FAILED', + code: e.code, + message: _friendlyCreateError(e, r.name), + }; + } +}); + +/** + * file:rename —— 修复两点(audit #4 + audit #12 + audit #7): + * - 返回 mtimeMs / size 让 renderer 立刻同步侧边栏与状态栏 + * - Windows 上大小写不敏感比对(path.resolve 保留原大小写) + * - symlink 防护:拒绝重命名 symlink + */ +ipcMain.handle('file:rename', async (_event, oldPath, newName) => { + if (typeof oldPath !== 'string' || typeof newName !== 'string') { + return { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' }; + } + const dir = currentDataRoot(); + if (!isWithinDataDir(oldPath, dir)) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '原路径不在数据目录内' }; + } + // symlink 防护(audit #7) + const linkCheck = await assertNotSymlink(oldPath); + if (!linkCheck.ok) { + return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; + } + // audit fix (M4 main):拒绝目录 —— file:rename 契约是「重命名文件」。 + // assertNotSymlink 在 isFile=false 时不区分「目录」与「不存在」,调用方 + // 以前会把目录也当作文件 rename 进去(POSIX 上 fs.rename 真能把目录改名), + // 与 file:create / file:delete 的 file-only 契约不一致。把目录提前拒掉, + // 让 Folder Browser 用专门的 folder:rename 路径(见后)。 + if (linkCheck.isFile === false) { + let st; + try { + st = await fs.stat(oldPath); + } catch { + st = null; + } + if (st && st.isDirectory()) { + return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '该路径是文件夹,请使用文件夹重命名' }; + } + return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件已被删除' }; + } + // audit fix (M1):原路径父目录链上的 symlink 也要拒 —— 否则 rename 会 + // 跟随到 dataDir 外。 + const oldAncestorCheck = await assertNoSymlinkAncestor(oldPath, dir); + if (!oldAncestorCheck.ok) { + return { ok: false, error: oldAncestorCheck.error, code: oldAncestorCheck.code, message: oldAncestorCheck.message }; + } + + // 解析新文件名(重命名专用:不强制 .md 后缀,用户输入什么就用什么 —— 见 [[project-no-auto-md-on-rename-2026-08]]; + // 仍做路径分隔符 / Windows 保留字符 / 控制字符 / 重名避让校验)。 + const r = await resolveRenameName(newName, dir); + // audit fix (Round 7 IPC-3):补齐 message 字段,并保证 code 一定存在。 + // 旧版直接 `code: r.code`,但 resolveRenameName 历史上不返回 code 字段 + // (参见 main/file-ops.js#resolveRenameName:返回 `{ ok:false, error }`), + // 导致 code=undefined → renderer 兜底走 result.error 文案语言漂移。 + // 这里给所有失败分支兜底 code='INVALID_NAME'(rename 路径专属)。 + if (!r.ok) { + return { + ok: false, + error: 'INVALID_NAME', + code: 'INVALID_NAME', + message: r.error, + }; + } + + // audit 防御性修复:resolveFileName 内部已 access 失败才返回 r.path, + // 但 resolve 与 rename 之间存在 TOCTOU 窗口(本地攻击者 / 并发进程可能 + // 在 r.path 位置抢先放一个 symlink,fs.rename 在 POSIX 上会跟随 symlink + // 重命名到外部目标)。在 rename 前再 lstat 一次,命中 symlink 就拒。 + // 若中间有进程在该位置放了普通文件,fs.rename 会自己撞 EEXIST。 + const targetLinkCheck = await assertNotSymlink(r.path); + if (!targetLinkCheck.ok) { + return { ok: false, error: targetLinkCheck.error, code: targetLinkCheck.code, message: targetLinkCheck.message }; + } + // audit fix (M1):新路径的父目录链同样要无 symlink。 + const newAncestorCheck = await assertNoSymlinkAncestor(r.path, dir); + if (!newAncestorCheck.ok) { + return { ok: false, error: newAncestorCheck.error, code: newAncestorCheck.code, message: newAncestorCheck.message }; + } + + // 新路径不能等于原路径(Windows 上大小写不敏感比对,audit #12) + const oldResolved = path.resolve(oldPath); + const newResolved = path.resolve(r.path); + const isSamePath = process.platform === 'win32' + ? oldResolved.toLowerCase() === newResolved.toLowerCase() + : oldResolved === newResolved; + if (isSamePath) { + // 同名 short-circuit 时拿真实 mtime/size(状态栏需要)。 + // 若 stat 失败(权限 / 瞬时错误),不要用 0 值污染调用方 —— 状态栏会显示 + // 1970-01-01 让用户怀疑数据丢失。直接返回错误,让 UI 不被 0 值污染。 + let st; + try { + st = await fs.stat(oldPath); + } catch (e) { + return { + ok: false, + error: e.code || 'STAT_FAILED', + code: e.code, + message: '读取文件状态失败', + }; + } + return { + ok: true, + path: r.path, + name: r.name, + unchanged: true, + mtimeMs: st.mtimeMs, + size: st.size, + }; + } + try { + await fs.rename(oldPath, r.path); + // 重新 stat 拿到新 mtime/size(audit #4:之前漏掉,renderer 状态栏会短暂无日期) + const st = await fs.stat(r.path); + return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; + } catch (e) { + // audit fix (K1-H1):console 走 _redactPath(之前 e.message 直接打到日志, + // 含完整旧路径,tmp 日志 / heap snapshot 里泄漏);message 走 _friendlyRenameError + // 翻译成中文,不让英文 errno + 完整路径直接回到 renderer。 + console.error('[main] file:rename 失败:', _redactPath(oldPath), '→', _redactPath(r.path), e.code || e.message); + return { + ok: false, + error: e.code || 'RENAME_FAILED', + code: e.code, + message: _friendlyRenameError(e, r.name), + }; + } +}); + +/** + * file:delete —— 修复一点(audit #7): + * - symlink 防护:拒绝删除 symlink(避免 trashItem 跟随到外部真实文件) + * - Stage 8:支持删除文件夹(Folder Browser)。空判断逻辑: + * `assertNotSymlink` 返回 isFile:false 可能是「目录」或「不存在」; + * 用 stat 二次判定,存在但不是文件 → 当作目录走 trashItem。 + */ +ipcMain.handle('file:delete', async (_event, filePath) => { + if (typeof filePath !== 'string' || !filePath) { + return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串' }; + } + if (!isWithinDataDir(filePath, currentDataRoot())) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; + } + // symlink 防护(audit #7) + const linkCheck = await assertNotSymlink(filePath); + if (!linkCheck.ok) { + return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; + } + // audit fix (M1):祖先链 symlink 防护 —— 父目录是 symlink 时 trashItem + // 会跟随到外部真实文件,造成越权删除。 + const ancestorCheck = await assertNoSymlinkAncestor(filePath, currentDataRoot()); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + // isFile=false 时再 stat 一次区分「目录」与「不存在」; + // 目录走 trashItem(macOS/Windows 原生支持),ENOENT 报 FILE_NOT_FOUND。 + if (linkCheck.isFile === false) { + let st; + try { + st = await fs.stat(filePath); + } catch (e) { + if (e.code === 'ENOENT') { + return { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '路径已不存在' }; + } + // audit fix (K1-R4):与其它 handler 对齐 code/message 形状,不再直回 e.message。 + return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取状态失败') }; + } + if (!st.isDirectory()) { + return { ok: false, error: 'NOT_A_FILE_OR_DIR', code: 'NOT_A_FILE_OR_DIR', message: '该路径既不是文件也不是目录' }; + } + // 走系统回收站 —— 目录也行;失败兜底 fs.rmdir 删空目录 + try { + await shell.trashItem(filePath); + return { ok: true, path: filePath, recycled: true, isFolder: true }; + } catch (e) { + console.warn('[main] trashItem(目录) 失败,降级到 rmdir:', e.message); + try { + await fs.rmdir(filePath); + return { ok: true, path: filePath, recycled: false, isFolder: true }; + } catch (e2) { + // 不拼 e2.message(英文 errno + 路径)—— 其它 file:* handler 都用纯中文 message + return { + ok: false, + error: e2.code || 'DELETE_DIR_FAILED', + code: e2.code, + message: '删除目录失败(可能非空)', + }; + } + } + } + // 文件路径:走系统回收站,失败时**不再静默 unlink**。 +// 旧版「trashItem 失败 → fallback unlink」在以下场景会让用户永久失去文件: +// - 回收站被禁用 / 满(Linux + 部分 Windows 配置) +// - trashItem 因权限 / EBUSY 失败 +// - 文件在外部存储 / 网络盘等 trash 不可达的位置 +// 现在:trashItem 失败时直接报错,让 UI 提示用户(而不是悄悄 unlink 把文件抹掉)。 +try { + await shell.trashItem(filePath); + return { ok: true, path: filePath, recycled: true }; +} catch (e) { + // audit fix (K1-L2):之前 message 拼 e.message(英文 errno + 路径)回到 renderer + // —— 漏完整路径到 UI。其他 file:* handler 都用纯中文 message,这里对齐。 + // 按 errno 翻译:EACCES/EPERM = 没权限、EBUSY = 文件被占、ENOENT = 已不在。 + console.warn('[main] shell.trashItem 失败,不降级 unlink:', _redactPath(filePath), e.code || e.message); + return { + ok: false, + error: 'TRASH_FAILED', + code: e.code || 'TRASH_FAILED', + message: _friendlyDeleteError(e), + }; +} +}); + +// ============================================ +// IPC: 应用信息 +// ============================================ + +ipcMain.handle('app:get-data-dir', () => currentDataRoot()); +ipcMain.handle('app:get-default-data-dir', () => configStore.getDefaultDataDir()); +ipcMain.handle('app:get-version', () => app.getVersion()); + +ipcMain.handle('app:open-data-dir', async () => { + let dir; + try { + dir = path.resolve(currentDataRoot()); + const st = await fs.stat(dir); + if (!st.isDirectory()) { + return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '数据路径不是一个目录' }; + } + } catch (e) { + // audit fix (K1-M1):补齐 ok / code / message 字段,与其它 handler 形状一致; + // message 不再拼 e.message(英文 errno + 路径)。 + console.warn('[main] app:open-data-dir stat 失败:', _redactPath(dir), e.code || e.message); + return { + ok: false, + error: e.code || 'STAT_FAILED', + code: e.code, + message: '无法访问数据目录', + }; + } + try { + // audit fix (K1-M5):shell.openPath 返回 Promise,未 await 时空字符串 = 失败 + // 会被静默吞掉,调用方以为「打开成功」实际啥都没发生。await 后检查空串。 + const failure = await shell.openPath(dir); + if (failure) { + // shell.openPath 失败时返回错误消息字符串(不是抛错) + console.warn('[main] shell.openPath 返回失败:', failure); + return { + ok: false, + error: 'OPEN_FAILED', + code: 'OPEN_FAILED', + message: `无法打开数据目录:${failure}`, + }; + } + return { ok: true, path: dir }; + } catch (e) { + console.warn('[main] shell.openPath 抛错:', _redactPath(dir), e.message); + return { + ok: false, + error: e.code || 'OPEN_FAILED', + code: e.code, + message: '无法打开数据目录', + }; + } +}); + +// 仅打开路径,不修改持久化设置(用于设置对话框的"打开"预览按钮) +// +// 安全:renderer 不能传任意路径触发 shell.openPath(即使只是打开文件管理器, +// 也是无谓的权限暴露)。这里强制 target 必须是当前数据目录(settings-dialog.js +// 也只拿 _configDir 来调用)。路径比对走 path.resolve + 大小写规整化 +// (Windows 不区分大小写、POSIX 区分),避免通过 '..' / 大小写绕过。 +ipcMain.handle('app:open-path', async (_event, target) => { + if (typeof target !== 'string' || !target) { + // audit fix (Round 8 IPC-5):补齐 message 字段,与 file:read / file:write / + // shell:open-dir 等 handler 对齐。renderer 走 `result.message || result.error + // || '未知错误'` 兜底,没 message 会拿到英文业务码。 + return { + ok: false, + error: 'INVALID_PATH', + code: 'INVALID_PATH', + message: '路径必须是字符串', + }; + } + // path 已在文件顶部 require,这里不再重复(避免阅读时被遮蔽误以为是局部变量) + // 走 currentDataRoot 而不是 resolveDataDir:customDir 失效时回退到默认, + // 让预览按钮跟用户实际在用的目录一致(与 fallback 语义对齐)。 + const dataDir = currentDataRoot(); + const resolvedTarget = path.resolve(target); + const resolvedData = path.resolve(dataDir); + const samePath = process.platform === 'win32' + ? resolvedTarget.toLowerCase() === resolvedData.toLowerCase() + : resolvedTarget === resolvedData; + if (!samePath) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '仅允许打开当前数据目录' }; + } + try { + const st = await fs.stat(resolvedTarget); + if (!st.isDirectory()) { + // audit fix (K1-R4):error 改用 NOT_A_DIRECTORY 业务码(与 shell:open-dir 对齐), + // 之前用中文 '不是一个目录' 与全文件英文 error 风格不一致。 + return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '不是一个目录' }; + } + await shell.openPath(resolvedTarget); + return { ok: true, path: resolvedTarget }; + } catch (e) { + // audit fix (K1-R4):走 _friendlyShellError 翻译 errno,不再直回 e.message。 + return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: _friendlyShellError(e, '无法打开数据目录') }; + } +}); + +ipcMain.handle('app:choose-data-dir', async () => { + if (!mainWindow) return null; + const result = await dialog.showOpenDialog(mainWindow, { + title: '选择数据文件夹', + properties: ['openDirectory', 'createDirectory'], + buttonLabel: '选择此文件夹', + }); + if (result.canceled || !result.filePaths.length) return null; + return result.filePaths[0]; +}); + +/** + * app:reset-data-dir —— 「回到默认」按钮的 IPC(auto-fallback 2026-08): + * 1) saveConfig({ dataDir: '' }) 把 custom 持久化值清空,下次启动也走默认 + * 2) 重启 fsWatcher(监听目标从缺失的旧路径切到默认) + * 3) 刷新托盘菜单(托盘「打开数据文件夹」缓存了旧路径) + * 4) 返回 { dir, defaultDir } 让 renderer 弹 toast + * + * 与 runtime fallback 的区别:runtime fallback 只在内存里走默认,config.json 里 + * 的 dataDir 不动 —— U 盘插回下次启动还能用回去;这里走 saveConfig 是「永久放弃 + * custom 路径」的语义。auto-fallback 已尽量减少用户误触发,这里只对应用户主动点 + * 「回到默认」按钮的明确指令。 + */ +ipcMain.handle('app:reset-data-dir', async () => { + if (!mainWindow || mainWindow.isDestroyed()) { + return { ok: false, error: 'NO_WINDOW' }; + } + try { + const saveResult = await configStore.saveConfig({ dataDir: '' }); + if (!saveResult.ok) { + return { ok: false, error: saveResult.error || '配置保存失败' }; + } + // 走 currentDataRoot 而不是 resolveDataDir —— 内部会 sync mkdir(默认目录可能 + // 从未被用过)+ 异步种子 welcome.md,让 fsWatcher 启动时不撞 ENOENT、侧栏里 + // 立刻有欢迎文档可见。 + const newDir = currentDataRoot(); + currentDataDir = newDir; + // 等欢迎文档就绪再重启 fsWatcher,避免「fsWatcher 启动时拿到空目录、 + // 几秒后 welcome.md 突然冒出侧栏」的 UX 撕裂。 + await configStore.scheduleEnsureDefaultDataDir(); + // 重启目录监听:旧监听在缺失的 custom 路径上,新监听切到默认 + try { + fsWatcher.startWatchingDir(newDir); + } catch (e) { + console.warn('[main] 重启目录监听失败(仅依赖轮询):', e && e.message); + } + if (tray) { + tray.setContextMenu(buildTrayMenu()); + } + return { + ok: true, + dir: newDir, + defaultDir: configStore.getDefaultDataDir(), + }; + } catch (e) { + console.error('[main] app:reset-data-dir 失败:', e && (e.message || e)); + return { ok: false, error: e?.message || '未知错误' }; + } +}); + +// ============================================ +// IPC: shell.openExternal(链接点击跳转默认浏览器) +// ============================================ +ipcMain.handle('shell:open-external', async (_event, url) => { + // audit fix (Round 7 IPC-10):改成 `{ ok, code, message }` envelope,与全文件 + // 其它 IPC handler 对齐。renderer 端 `if (!ok)` 无法区分「URL 不合法」/「shell + // 调用失败」/「系统级错误」,统一走 envelope 让 toast 文案可针对原因。 + if (typeof url !== 'string' || !url) { + return { + ok: false, + error: 'INVALID_URL', + code: 'INVALID_URL', + message: '链接必须是字符串', + }; + } + // 防御性:只允许 http(s) 和 mailto,避免被利用打开 file:// / 其他协议 + if (!/^(https?:\/\/|mailto:)/i.test(url)) { + console.warn('[main] 拒绝打开非 http(s)/mailto URL:', url); + return { + ok: false, + error: 'INVALID_URL', + code: 'INVALID_URL', + message: '只允许 http(s) / mailto 链接', + }; + } + // 进一步校验:scheme 后必须有合法主机部分。 + // 之前只校验前缀,会放过畸形 URL(如 'http:///etc/passwd' / 'http://\x00/foo' / + // 'http:// host' 带空格)。new URL 在解析失败时抛错,刚好兜底。 + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && parsed.protocol !== 'mailto:') { + console.warn('[main] 拒绝打开:协议不匹配', parsed.protocol, url); + return { + ok: false, + error: 'INVALID_URL', + code: 'INVALID_URL', + message: '协议不被允许', + }; + } + // mailto 不要求 hostname;http/https 必须有非空 hostname + if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && !parsed.hostname) { + console.warn('[main] 拒绝打开:缺少 hostname', url); + return { + ok: false, + error: 'INVALID_URL', + code: 'INVALID_URL', + message: '链接缺少主机名', + }; + } + } catch (e) { + console.warn('[main] 拒绝打开:URL 解析失败', url, e.message); + return { + ok: false, + error: 'INVALID_URL', + code: 'INVALID_URL', + message: '链接格式不合法', + }; + } + try { + // 必须 await:openExternal 返回 Promise,不接住的话失败会变成 + // unhandledRejection,而渲染端还以为打开成功了 + await shell.openExternal(url); + return { ok: true }; + } catch (e) { + console.error('[main] openExternal 失败:', e.message); + return { + ok: false, + error: e.code || 'OPEN_FAILED', + code: e.code || 'OPEN_FAILED', + message: '无法打开链接', + }; + } +}); + +/** + * 在系统文件管理器中显示文件(macOS = Reveal,Win/Linux = 选中文件)。 + * + * 旧版走 `app:open-path` 但那个 IPC 要求 path 必须是目录, + * 传文件会被拒绝("不是一个目录")。这里单独走 shell.showItemInFolder, + * 是 Electron 提供的"显示文件"标准入口。 + */ +ipcMain.handle('shell:show-item-in-folder', async (_event, fullPath) => { + if (typeof fullPath !== 'string' || !fullPath) { + // audit fix (Round 8 IPC-6):补齐 message 字段,与 shell:open-dir / file:read + // 等 handler 对齐。renderer friendlyFsError 兜底走 result.message,没 message + // 会退到英文业务码。 + return { + ok: false, + error: 'INVALID_PATH', + code: 'INVALID_PATH', + message: '路径必须是字符串', + }; + } + // isWithinDataDir 需要数据目录作为第二参;不传则内部 root 为 undefined → 永远 false → 误拒 + if (!isWithinDataDir(fullPath, currentDataRoot())) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; + } + try { + // audit fix (Main P1 / shell:show-item-in-folder symlink check): + // 之前 isWithinDataDir 只检「路径字面值是否在数据目录下」—— 但路径若 + // 是数据目录里的符号链接(symlink)且指向外部,shell.showItemInFolder + // 实际会在文件管理器里打开 symlink 真实目标。攻击向量: + // - 攻击者诱导用户把外部恶意目录 / 网络共享(`\\evil\share`)做成 + // 数据目录里的 symlink; + // - 渲染端走 fs.stat(跟随 symlink)拿到合法 st.isFile()=true 通过; + // - shell.showItemInFolder 在 Windows 上解析为 SMB UNC,系统静默做 + // NTLM 认证(与 Sec-H2 同一族风险,CVE-2023-23397 的笔记版本)。 + // 修复:realpath 拿到「symlink 真实指向」,再次 isWithinDataDir 校验。 + // 真实路径不在数据目录 → 拒(错误码 SYMLINK_OUTSIDE,与 file:write + // 路径的 SYMLINK_NOT_ALLOWED 对齐但语义更准确)。 + const real = await fs.promises.realpath(fullPath); + if (!isWithinDataDir(real, currentDataRoot())) { + return { + ok: false, + error: 'SYMLINK_OUTSIDE', + code: 'SYMLINK_OUTSIDE', + message: '符号链接指向数据目录外', + }; + } + const st = await fs.stat(fullPath); + if (!st.isFile()) { + return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '不是一个文件' }; + } + shell.showItemInFolder(fullPath); + return { ok: true }; + } catch (e) { + if (e.code === 'ENOENT') return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件不存在' }; + // audit fix (K1-R4):走 _friendlyShellError 翻译 errno,不再直回 e.message。 + return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '无法在文件夹中显示') }; + } +}); + +/** + * 在系统文件管理器中打开数据目录下的任意目录(子目录 / 数据根)。 + * + * 与 `shell:show-item-in-folder` 的差别:showItemInFolder 只接受文件路径 + * ("在文件夹中显示并选中文件"语义),无法用来打开一个目录供用户浏览。 + * 状态栏左侧的路径 chip 在「没打开文件」时会显示当前浏览目录,此时点击应该 + * 真的打开那个目录(Win/Linux = 资源管理器进目录;macOS = Finder 进目录), + * 而不是静默无副作用。 + * + * 安全:与 `shell:show-item-in-folder` 对称 —— 强制 isWithinDataDir 边界检查, + * 不让 renderer 通过这条 IPC 打开任意系统目录。 + */ +ipcMain.handle('shell:open-dir', async (_event, dirPath) => { + if (typeof dirPath !== 'string' || !dirPath) { + // audit fix (Round 7 IPC-4):补齐 code + message 字段,与其它 shell:* handler + // 对齐。renderer 走 friendlyFsError(code, message) 没 message 兜底到英文。 + return { + ok: false, + error: 'INVALID_PATH', + code: 'INVALID_PATH', + message: '路径必须是字符串', + }; + } + const resolved = path.resolve(dirPath); + if (!isWithinDataDir(resolved, currentDataRoot())) { + return { ok: false, error: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; + } + try { + // audit fix:用 lstat 拒绝 symlink 目录(与 file:scan-dir 对齐)。 + // fs.stat 跟随 symlink —— resolved 若指向外部目录,stat 仍能拿到目标目录 + // 信息,导致下面的 shell.openPath 跑出数据目录边界。lstat + isSymbolicLink + // 检查与 file:scan-dir 一致。 + const lst = await fs.lstat(resolved); + if (lst.isSymbolicLink()) { + return { ok: false, error: 'SYMLINK_NOT_ALLOWED', code: 'SYMLINK_NOT_ALLOWED', message: '不允许在符号链接目录上执行此操作' }; + } + if (!lst.isDirectory()) { + // audit fix (Round 7 IPC-9):补齐 message 字段,与 line 1705/1717/1720 对齐。 + // renderer 走 `friendlyFsError(code, message || error || '未知错误')` 时 + // 缺 message 兜底到 result.error 业务码字符串 'NOT_A_DIRECTORY',用户看到 + // 的是代码不是中文。 + return { + ok: false, + error: 'NOT_A_DIRECTORY', + code: 'NOT_A_DIRECTORY', + message: '路径不是文件夹', + }; + } + const failure = await shell.openPath(resolved); + if (failure) { + // shell.openPath 失败时返回错误消息字符串(不是抛错),参考 app:open-path + return { ok: false, error: 'OPEN_FAILED', code: 'OPEN_FAILED', message: failure }; + } + return { ok: true }; + } catch (e) { + if (e.code === 'ENOENT') return { ok: false, error: 'DIR_NOT_FOUND', code: 'DIR_NOT_FOUND', message: '目录不存在' }; + // audit fix (K1-R4):走 _friendlyShellError 翻译 errno,不再直回 e.message。 + return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: _friendlyShellError(e, '无法打开目录') }; + } +}); + +// ============================================ +// IPC: 窗口控制 +// ============================================ + +ipcMain.handle('window:set-always-on-top', (_event, enabled) => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + if (typeof enabled !== 'boolean') { + console.warn('[main] setAlwaysOnTop 收到非布尔值:', enabled); + return false; + } + try { + mainWindow.setAlwaysOnTop(enabled); + return true; + } catch (e) { + console.error('[main] setAlwaysOnTop 失败:', e.message); + return false; + } +}); + +ipcMain.handle('window:minimize', () => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + // audit fix (K1-L4):try/catch 兜底 —— minimize() 在窗口被销毁过程中可能抛 + // "Object has been destroyed",与 setAlwaysOnTop 的 L4 修复对齐。 + try { + mainWindow.minimize(); + return true; + } catch (e) { + console.warn('[main] window:minimize 失败:', e.message); + return false; + } +}); + +ipcMain.handle('window:toggle-maximize', () => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + try { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + return mainWindow.isMaximized(); + } catch (e) { + console.warn('[main] window:toggle-maximize 失败:', e.message); + return false; + } +}); + +ipcMain.handle('window:close', () => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + try { + mainWindow.close(); + return true; + } catch (e) { + console.warn('[main] window:close 失败:', e.message); + return false; + } +}); + +function broadcastMaximizeState() { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send('window:maximize-state', mainWindow.isMaximized()); +} + +// ============================================ +// IPC: AI 修改 +// ============================================ + +/** + * 渲染端发来修改请求:调 OpenAI 兼容 API,返回 { id, content, responseFormat }。 + * 错误统一返回 { ok:false, error, message } 供 renderer 弹 toast。 + */ +ipcMain.handle('ai:edit', async (_event, payload) => { + if (!payload || typeof payload !== 'object') { + return { ok: false, error: 'INVALID_PAYLOAD', message: '请求体无效' }; + } + const { prompt, content, filename, requestId } = payload; + return aiProxy.runEdit({ prompt, content, filename, requestId }); +}); + +/** + * 渲染端发来取消请求:找到对应 requestId 的 AbortController 并触发 abort。 + * 找不到(请求已完成 / 超时)静默忽略。 + */ +ipcMain.on('ai:cancel', (_event, requestId) => { + if (typeof requestId === 'string' && requestId) { + aiProxy.cancel(requestId); + } +}); + +// ============================================ +// IPC: 应用设置(持久化) +// ============================================ + +ipcMain.handle('app:get-settings', () => { + const cfg = configStore.getConfig() || {}; + const { aiApiKey, ...rest } = cfg; + // P1-1 fix (audit):过滤 aiApiKey 真值,只露布尔标记。renderer 任何位置 + // 通过 window.api.getSettings() 都拿不到完整 Key —— 必须显式调 + // app:reveal-ai-key 才能拿真值(settings 对话框回显用)。 + return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey }; +}); + +/** + * Q7/Q9 fix (audit):「显示 API Key」必须先 arm 再 reveal: + * + * 1. settings-dialog 在用户主动点击「显示 Key」时先调 app:arm-reveal-ai-key + * —— 主进程记录一个 5 秒 armed 窗口(per-sender,按 webContents.id 区分)。 + * 2. 然后调 app:reveal-ai-key —— 主进程检查 armed 窗口仍在有效期内。 + * 3. reveal 后立即 disarm。 + * + * DevTools / 第三方 hook / 任何意外注入的 JS 不会先经过「用户点按钮」事件, + * 自然不会调 arm。一旦 arm 流程跑过,主进程日志记下 time + senderId,方便 + * 事后审计。这条链路**不能**完全防御已被攻陷的 renderer(renderer 拿到的 + * IPC 桥跟合法代码一样),但能在「合法流程之外」显著抬高成本(attacker 必须 + * 也调 arm 才能拿 key,会留下可追溯的日志条目)。 + * + * 不入 renderer 常驻缓存;调用方应在用完后立即从 DOM 清掉。 + */ +const REVEAL_ARMED_TTL_MS = 5_000; +/** @type {Map} webContents.id → 截止时间戳 */ +const revealArmedBySender = new Map(); + +ipcMain.handle('app:arm-reveal-ai-key', (event) => { + const senderId = event.sender && event.sender.id; + if (typeof senderId !== 'number') { + return { ok: false, error: 'INVALID_SENDER' }; + } + const until = Date.now() + REVEAL_ARMED_TTL_MS; + revealArmedBySender.set(senderId, until); + // L4 fix (audit):去掉 ISO 时间戳(精确到 ms 让 arm 时刻成为可关联的 fingerprint), + // 改用 ttlMs 表明窗口长度即可。 + console.log('[reveal-ai-key] armed by sender', senderId, 'ttlMs=', REVEAL_ARMED_TTL_MS); + // 兜底:armed 窗口过期自动清理(防止 reveal 永远不来,Map 一直占位) + setTimeout(() => { + const cur = revealArmedBySender.get(senderId); + if (cur === until) revealArmedBySender.delete(senderId); + }, REVEAL_ARMED_TTL_MS + 100).unref?.(); + return { ok: true, ttlMs: REVEAL_ARMED_TTL_MS }; +}); + +ipcMain.handle('app:reveal-ai-key', (event) => { + const senderId = event.sender && event.sender.id; + const until = (typeof senderId === 'number') ? revealArmedBySender.get(senderId) : undefined; + // 任意一条命中即拒绝:没 arm / arm 已过期 / sender 不匹配 + if (typeof until !== 'number' || until <= Date.now()) { + if (typeof senderId === 'number') revealArmedBySender.delete(senderId); + // 不打印 apiKey 内容;只记"拒绝了一次 reveal 尝试",方便事后追查 + console.warn('[reveal-ai-key] rejected: no valid arm window for sender', senderId); + return { ok: false, error: 'NOT_AUTHORIZED' }; + } + // 一次性:reveal 后立即 disarm,避免同一窗口被重放 + revealArmedBySender.delete(senderId); + const cfg = configStore.getConfig() || {}; + // 显式记一条"成功 reveal"日志(含 key 长度而非真值,便于事后核对"有没有人偷 key") + // L4 fix (audit):单行结构、sender + length,去掉任何 key 相关指纹; + // 配 arm 日志一起能看出 arm→reveal 时序关系。 + const keyStr = String(cfg.aiApiKey || ''); + console.log('[reveal-ai-key] revealed by sender', senderId, 'length=', keyStr.length); + return { ok: true, aiApiKey: keyStr }; +}); + +/** + * 返回 config.json 的真实路径 + 所在目录。 + * 设置对话框用:让用户看到 API Key / 设置到底存在了哪个文件, + * 并能一键打开所在目录自行核对/备份。 + * + * audit fix (M4):路径在 tmp 日志 / 屏幕共享 / 截图里出现会暴露 Windows 用户名 + * (`C:\Users\\AppData\...`)。设置面板只需要「哪个文件 / 哪个目录」足够 + * 用户理解位置,把 basename / dir basename 返回即可,完整路径仅在「打开所在 + * 目录」按钮内部使用(不再经过 IPC)。 + */ +ipcMain.handle('app:get-config-path', () => { + const filePath = configStore.getConfigPath(); + return { + name: path.basename(filePath), + dirName: path.basename(path.dirname(filePath)), + }; +}); + +/** + * 打开 config.json 所在目录(设置对话框 AI 段的 [打开文件夹] 按钮)。 + * + * audit fix (Round 9 CFG-1):这个按钮之前完全是死的,两处同时坏掉 —— + * 1) M4 把 `app:get-config-path` 的返回从 `{path, dir}` 改成 `{name, dirName}` + * 做隐私脱敏,但 settings-dialog 仍读 `cfg.path` / `cfg.dir` → 都是 + * undefined → `initial._configPath` 永不赋值 → hint 里的路径 + 按钮 + * 整段模板不渲染(文案还断在「存储于 config.json」和「。每次请求」之间)。 + * 2) 即便按钮渲染出来,它调的 `app:open-path` 强制 target === 当前数据目录, + * 传 config 目录会被 PATH_NOT_ALLOWED 拒掉(那个 handler 的注释说 + * 「settings-dialog 也只拿 _configDir 来调用」,但 _configDir 是 userData + * 目录、不是数据目录 —— 注释与事实不符)。 + * + * 这里用「不接参数」的专用 handler 同时满足两个既有约束:完整路径不过 IPC + * (隐私),renderer 也无法传任意路径(安全)。形状与 app:open-data-dir 对齐。 + */ +ipcMain.handle('app:open-config-dir', async () => { + let dir; + try { + dir = path.dirname(configStore.getConfigPath()); + const st = await fs.stat(dir); + if (!st.isDirectory()) { + return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '配置路径不是一个目录' }; + } + } catch (e) { + console.warn('[main] app:open-config-dir stat 失败:', _redactPath(dir), e.code || e.message); + return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: '无法访问配置目录' }; + } + try { + const failure = await shell.openPath(dir); + if (failure) { + console.warn('[main] shell.openPath 返回失败:', failure); + return { ok: false, error: 'OPEN_FAILED', code: 'OPEN_FAILED', message: `无法打开配置目录:${failure}` }; + } + // 不回传 dir —— 完整路径带 Windows 用户名,与 app:get-config-path 的脱敏一致。 + return { ok: true }; + } catch (e) { + console.warn('[main] shell.openPath 抛错:', _redactPath(dir), e.message); + return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: _friendlyShellError(e, '无法打开配置目录') }; + } +}); + +/** + * 保存设置。 + * 返回结构化结果 { ok:true, settings } / { ok:false, error },而不是抛错 —— + * ipcMain.handle 抛出的错误会被 Electron 包装成 + * "Error invoking remote method 'app:save-settings': Error: 目录不存在", + * 渲染端直接把这串东西塞进 toast 很难看。 + */ +ipcMain.handle('app:save-settings', async (event, partial) => { + if (!partial || typeof partial !== 'object') return { ok: true, settings: configStore.getConfig() }; + + // 目录存在性校验(schema 里用 opts.resolveDir 注入,因为要异步 fs.stat + access) + const resolveDir = async (p) => { + try { + const st = await fs.stat(p); + if (!st.isDirectory()) return { ok: false, error: '路径不是一个目录' }; + await fs.access(p, fsSync.constants.R_OK); + return { ok: true }; + } catch (e) { + if (e.code === 'ENOENT') return { ok: false, error: '目录不存在' }; + if (e.code === 'EACCES' || e.code === 'EPERM') return { ok: false, error: '没有访问权限' }; + return { ok: false, error: e.message || '无法访问该目录' }; + } + }; + + const result = await validateAndSanitize(partial, { resolveDir }); + if (!result.ok) return { ok: false, error: result.error }; + const sanitized = result.sanitized; + + // audit fix (main-M4):先 saveConfig 再 apply 窗口 / 托盘副作用。旧实现先 + // setAlwaysOnTop / 重建 tray menu,saveConfig 失败时 UI 已变更、磁盘还是 + // 旧值,重启后置顶状态被撤回 + 用户以为配置损坏。修复后语义:「磁盘先 + // 落定,UI 再跟上」—— saveConfig 失败直接返回 {ok:false},副作用一段都 + // 不跑。 + // audit fix (Round 8 M-1):必须在 saveConfig 之前快照旧值。saveConfig 成功时 + // 会把 appConfig 就地换成 merged(config-store.js:188),之后再读 + // configStore.getConfig().alwaysOnTop 拿到的已经是新值 → 与 sanitized 恒等 → + // `!==` 恒为 false → setAlwaysOnTop / 托盘菜单刷新一次都不跑。表现:用户勾选 + // 「始终置顶」后磁盘和 UI 都变了,但窗口实际没置顶、托盘勾选状态也不同步, + // 要重启(createWindow 重新 apply)才生效。 + const prevAlwaysOnTop = configStore.getConfig().alwaysOnTop; + const saveResult = await configStore.saveConfig(sanitized); + if (!saveResult.ok) { + return { ok: false, error: saveResult.error || '配置保存失败' }; + } + const next = saveResult.value; + + // alwaysOnTop 变更需要同步到窗口 + tray(schema 不应耦合 Electron API,所以副作用留在 main.js) + if ('alwaysOnTop' in sanitized && prevAlwaysOnTop !== sanitized.alwaysOnTop) { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setAlwaysOnTop(sanitized.alwaysOnTop); + } + if (tray) { + tray.setContextMenu(buildTrayMenu()); + } + } + + // dataDir 变更后重新启动目录监听 + 刷新托盘菜单(托盘「打开数据文件夹」缓存了旧路径) + if ('dataDir' in sanitized) { + // 走 currentDataRoot 而不是 resolveDataDir:customDir 失效时 fallback 到默认 + // 并 sync mkdir,确保 fsWatcher 启动时不撞 ENOENT。 + const newDirRaw = currentDataRoot(); + await configStore.scheduleEnsureDefaultDataDir(); + // audit fix:直接比较字符串会因为尾斜杠 / 大小写 / `..` 残留而误判。 + // 用 path.resolve 归一化后再比;Windows 上大小写不敏感。 + const newResolved = path.resolve(newDirRaw); + const curResolved = path.resolve(currentDataDir); + const same = process.platform === 'win32' + ? newResolved.toLowerCase() === curResolved.toLowerCase() + : newResolved === curResolved; + if (!same) { + currentDataDir = newDirRaw; + fsWatcher.startWatchingDir(newDirRaw); + if (tray) { + tray.setContextMenu(buildTrayMenu()); + } + } + } + + // Phase N 安全修复:save-settings 之前直接 echo `next`,里面含 aiApiKey 明文。 + // renderer settingsStore.update 会拿这个 settings 调 coerceLoadedSettings + // 覆盖 _settings —— 把真值灌进内存。后续 getSettings / getAll / IPC echo 都 + // 走同一份内存,AI Key 明文在内存里能待到下次 restart。 + // 与 get-settings 同款 redaction:剥掉 aiApiKey 真值、用 aiApiKey='' 占位、 + // 用 _hasAiKey 标记"是否配置过 Key"。renderer settings-store 已在 update 路径 + // 把 aiApiKey='' 落进 _settings 与 cachePartial,"清空 Key" 的写入也无副作用 + // 走空字符串 diff。 + const { aiApiKey: realKey, ...rest } = next; + return { ok: true, settings: { ...rest, aiApiKey: '', _hasAiKey: !!realKey } }; +}); + +// ============================================ +// 应用菜单 +// ============================================ + +function buildMenu() { + const isMac = process.platform === 'darwin'; + const template = [ + ...(isMac ? [{ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'quit' } + ] + }] : []), + { + label: '视图', + submenu: [ + { + label: '切换主题', + accelerator: 'CmdOrCtrl+Shift+T', + click: () => mainWindow?.webContents.send('menu:toggle-theme') + }, + { type: 'separator' }, + { + label: '设置...', + accelerator: 'CmdOrCtrl+,', + click: () => mainWindow?.webContents.send('menu:settings') + }, + { type: 'separator' }, + { + label: '开发者工具', + accelerator: 'F12', + click: () => mainWindow?.webContents.toggleDevTools() + }, + { role: 'reload', label: '重新加载' } + ] + } + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} \ No newline at end of file diff --git a/main/ai.js b/main/ai.js new file mode 100644 index 0000000..6851bac --- /dev/null +++ b/main/ai.js @@ -0,0 +1,938 @@ +// 主进程 AI 代理 +// ============================================================================ +// +// 通过 Node 18+ 内置 fetch 调用 LLM API。两条协议分支: +// - aiProvider === 'openai'(默认)→ POST {baseURL}/chat/completions +// Authorization: Bearer ... +// - aiProvider === 'anthropic' → POST {baseURL}/v1/messages +// x-api-key: ... + anthropic-version +// +// 不使用 openai / @anthropic-ai SDK(避免引入依赖;统一在主进程发起, +// 隐藏 apiKey;统一错误结构供 renderer 弹 toast)。 +// +// 设计要点: +// - 每个请求用一个 requestId 追踪;调用方通过 ai:cancel IPC 终止 +// - 配置(provider / baseURL / apiKey / model / systemPrompt)从 +// configStore.getConfig() 现读现用,用户改完设置不需要重启 +// - 错误统一返回 { ok:false, error, message },error 是稳定的错误码字符串, +// message 是用户能看的中文 +// - API Key 不写日志;请求/响应调试时只用长度 + 状态码 +// +// 协议差异(两个分支各自完整处理;共享的 cancel / 校验 / 响应后处理 抽到 helper): +// 请求体: +// OpenAI : { model, temperature, max_tokens, max_completion_tokens, +// messages: [{role, content}], stream:false } +// Anthropic : { model, max_tokens, system, messages: [{role, content}], ... } +// +// 响应体: +// OpenAI : choices[0].message.content (string) +// choices[0].finish_reason === 'length' 截断 +// Anthropic : content[].text (拼接所有 text 块) +// stop_reason === 'max_tokens' 截断 +// ============================================================================ + +'use strict'; + +const CURRENT_FILE_EDIT_SYSTEM_PROMPT = + '你是 Markdown 文本助手。输入 JSON 格式含 filename、currentMarkdown、userPrompt。请按 userPrompt 修改 currentMarkdown,然后直接输出 JSON 格式:{"content":"完整 Markdown"}。如果 userPrompt 与文档修改无关,或者是 {"content":"完整 Markdown"} 内容和 currentMarkdown 完全一致,直接简单回复即可,不需要回复 {"content":"完整 Markdown"}。'; + +const DEFAULT_TIMEOUT_MS = 300_000; +// 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。 +// 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。 +const MAX_CONTENT_BYTES = 1_500_000; +// 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。 +// 5 MB 对正常 AI 回复(几十 KB 到 1 MB)足够宽松。 +const MAX_RESP_BYTES = 5 * 1024 * 1024; +// tryParseJson 输入硬上限:getJsonCandidates 里的 fenced block 正则 + +// indexOf/lastIndexOf/slice 在 5 MB 字符串上仍有可观 CPU 占用, +// 且对对抗性输入(无闭合 ```)会让非贪婪量词扫到尾;提前砍掉尾巴, +// 让 JSON 解析失败直接走 extractRawReply 的回退路径。 +const MAX_PARSE_INPUT_BYTES = 2_000_000; + +// Anthropic API 当前稳定版本(2023-06-01 之后未再变) +const ANTHROPIC_API_VERSION = '2023-06-01'; + +// audit fix (CQ-MED-7):错误码字面值与 renderer 端共享。preload 经 contextBridge +// 把同一份 AI_ERROR 暴露到 window.api.aiErrors,main / renderer 永远引用同一对象, +// 不再靠注释提醒同步。 +const { AI_ERROR } = require('../shared/ai-errors.js'); +const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED; +const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT; +const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER; +const ERR_FORMAT = AI_ERROR.ERR_FORMAT; +const ERR_CANCELLED = AI_ERROR.ERR_CANCELLED; + +const TRUNCATED_MESSAGE = 'AI 修改结果不完整,请缩小文档或简化要求后重试。'; + +/** + * 把超长输入砍到上限内(按 UTF-8 字节)。MAX_PARSE_INPUT_BYTES 之外的尾部 + * 在多数 AI 模型回复里没有意义(远早于 JSON 边界)—— 直接截掉既防 ReDoS, + * 又让正则 / indexOf 不再 O(n²) 退化。 + * @param {string} message + * @returns {string} + */ +function capForParse(message) { + const s = String(message || ''); + if (Buffer.byteLength(s, 'utf8') <= MAX_PARSE_INPUT_BYTES) return s; + // 按字符截可能切到 UTF-8 序列中间;用 Buffer 切字节再转回字符串, + // 最后若尾部半个 multi-byte 用 toString('utf8') 会被替换成 U+FFFD, + // 但 JSON.parse 会立即抛 SyntaxError → 由 tryParseJson 的 catch 兜底。 + return Buffer.from(s, 'utf8').subarray(0, MAX_PARSE_INPUT_BYTES).toString('utf8'); +} + +/** + * 提取 AI 消息中的 JSON 候选:trimmed 原文 / 三反引号代码块 / 第一个 { 到最后一个 }。 + * @param {string} message + * @returns {string[]} + */ +function getJsonCandidates(message) { + const trimmed = capForParse(message).trim(); + const candidates = [trimmed]; + const fencedBlock = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fencedBlock && fencedBlock[1]) { + candidates.push(fencedBlock[1].trim()); + } + const jsonStart = trimmed.indexOf('{'); + const jsonEnd = trimmed.lastIndexOf('}'); + if (jsonStart !== -1 && jsonEnd > jsonStart) { + candidates.push(trimmed.slice(jsonStart, jsonEnd + 1)); + } + return [...new Set(candidates)]; +} + +function tryParseJson(message) { + for (const candidate of getJsonCandidates(message)) { + try { + return JSON.parse(candidate); + } catch { + // continue + } + } + return undefined; +} + +/** + * 在 message 中找第一个花括号配对的 JSON 对象,并把对象文本截出来。 + * 简单字符串扫描 —— O(n) 处理嵌套 `{` `}` 和字符串字面量(避免 JSON 内容里的 + * 引号 / 反斜杠把花括号配对误判)。找不到配对返回 null。 + * + * 与 getJsonCandidates 的 brace-pair 切片不同:这里还要在体内识别字符串里的 + * 转义序列(`\"` / `\\`),因此专写一个实现而非复用 indexOf/lastIndexOf。 + * + * @param {string} s + * @returns {string|null} + */ +function extractFirstJsonObject(s) { + const str = String(s || ''); + const len = str.length; + let start = -1; + let depth = 0; + let inStr = false; + let escape = false; + for (let i = 0; i < len; i++) { + const ch = str[i]; + if (inStr) { + if (escape) { escape = false; continue; } + if (ch === '\\') { escape = true; continue; } + if (ch === '"') { inStr = false; } + continue; + } + if (ch === '"') { inStr = true; continue; } + if (ch === '{') { + if (start < 0) start = i; + depth++; + continue; + } + if (ch === '}') { + if (depth === 0) continue; + depth--; + if (depth === 0 && start >= 0) return str.slice(start, i + 1); + } + } + return null; +} + +function looksLikeEditJson(message) { + // audit fix:之前的 /"content"\s*:/ 太宽松 —— 用户提示词里只要含 `"content":` + // 子串就会被当作「AI 改稿的 JSON 截断」,误报 TRUNCATED_MESSAGE。 + // 第二版 `/\{[^{}]*"content"\s*:[^{}]*\}/` 又过紧 —— 当 content 字段后跟随嵌套 + // 对象(例:`{"content":"x","patches":[{"op":"replace"}]}`)时 `[^{}]*` 立刻失配, + // AI 返回"看似想输出 JSON 但 token 不够截断"的场景下,TRUNCATED 提示被静默吃掉。 + // + // 现在走 extractFirstJsonObject 取出第一个配对的 JSON 对象文本,再在体内找 + // `"content"` 键(允许值跨多行、允许嵌套、字符串里的 " 不会干扰)。 + // —— 简单纯文本("please edit {content} now")也不会命中,因为: + // 1. 字符串里的花括号不算嵌套对象起点(inStr 分支已处理); + // 2. 真正配对的对象才走 key 搜索。 + // + // 截断的 JSON(花括号未闭合 / 数组未闭合):extractFirstJsonObject 找不到配对对象, + // 这里直接返回 false —— normalizeAssistantText 会走 raw 回退把残文本返回给用户。 + // 用户能看到 AI 输出了什么,比直接弹"不完整"更直观。截断信号应当由上游 + // finish_reason=length / stop_reason=max_tokens 在更早的路径触发,不依赖正文配对。 + const obj = extractFirstJsonObject(message); + if (!obj) return false; + // 体内 key 检测:用 `"content"` 加 `:` 兜住常见间距(`"content" :` / `"content":`); + // 不复用 contains('"content"') 是为了避免匹配键名包含 content 子串的字段 + // (如 `"mycontent":1` —— 但这种情况极少见,多一道正则更稳)。 + return /"content"\s*:/.test(obj); +} + +/** + * 从非 JSON 解析得到的对象里挑 reply/message/text/answer 字段当作纯文本。 + * @param {unknown} parsed + * @returns {string | null} + */ +function extractRawReply(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + for (const key of ['reply', 'message', 'text', 'answer']) { + const value = /** @type {Record} */ (parsed)[key]; + if (typeof value === 'string') return value; + } + return null; +} + +/** + * 拼接 baseURL + path,自动处理末尾斜杠。 + * @param {string} base + * @param {string} path + * @returns {string} + */ +function joinUrl(base, path) { + // P3-3 fix (audit):拆分 query / fragment 后再拼。 + // 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages` + // (query 后被拼了 path,URL 非法)。query / fragment 也可能在错误回显时 + // 包含 api_key 之类敏感 token,所以一并禁止放在 baseURL 里。 + // M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo(`https://user:pass@host`)。 + // fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务, + // 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。 + const s = String(base || ''); + const m = s.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); + if (!m) return s; + let b = m[1].replace(/\/+$/, ''); + b = b.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1'); + const p = String(path || '').replace(/^\/+/, ''); + return `${b}/${p}${m[2] || ''}${m[3] || ''}`; +} + +/** + * 过滤错误回显里的敏感 token(OpenAI/Anthropic/proxy 可能在错误信息里 + * echo URL 或 header)。API Key 不该出现在用户能看到的 toast 里。 + * P2-2 fix (audit);Q3 fix (audit):补 Google API key / JWT / proxy-authorization。 + * @param {string} detail + * @returns {string} + */ +function sanitizeDetail(detail) { + return String(detail || '') + // OpenAI / 通用 OpenAI 风格 key:sk-xxx / sk-proj-xxx / sk-ant-xxx + // M3 fix (audit):阈值 8 → 5,捕获被截断的 key(错误回显常见前缀模式 sk-12ab...)。 + // 5 是保守下限:正常文本里 5 位随机 base64url 不常见 + .replace(/sk-[A-Za-z0-9_-]{5,}/g, '[API_KEY]') + // Google API key:AIzaSy 开头 + 33 字符。33 是 Google 当前规范 + .replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]') + // JWT:header.payload.signature 三段 base64url;至少各 8 字符避免误伤短词 + .replace(/eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[JWT]') + // M3 fix (audit):先单独处理 JSON 风格 `"authorization": "Bearer sk-xxx"`—— + // 旧正则 `[^\s,;&}"']+` 遇到 `"` 就停,只盖到 `"Bearer`,剩余 ` sk-xxx"` + // 直接漏到下一步被文本截断逻辑保留。现在用单独 pattern 一次性吃掉整段带引号的值。 + // audit fix (Phase O-L19):扩到 `authentication` / `www-authenticate` / `cookie` / + // `set-cookie` —— 自部署中转 / Azure gateway 偶尔回 `Authentication: Bearer sk-xxx`, + // LLM 自定义代理常通过 cookie 传 key。原始 key 不会泄露(line 229 的 sk-/AIzaSy + // 兜底会替成 [API_KEY]),但 header 名这一行会在 detail 文本里残留。 + .replace(/("(?:proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"') + // 任意 header 里出现敏感 token —— 含 proxy-authorization(Q3 audit 新增) + // 不再排除 `"` 和 `'`:让正则跨过引号吃掉值(与 JSON pattern 互补,命中 form-style) + .replace(/(proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)\s*[:=]\s*[^\s,;&}]+/gi, '$1=[REDACTED]') + // URL query 或 form body 里的 key/token + .replace(/(api[_-]?key|token)\s*=\s*[^\s,;&}]+/gi, '$1=[REDACTED]'); +} + +/** + * 把 URL 中可能携带 secret 的部分脱敏再写到日志。 + * 1. 去掉 query 和 fragment(用户 baseURL 不该带 ?api_key=xxx / #fragment,但有人会带) + * 2. path 段里嵌入的 secret token(OpenAI sk-xxx / Google AIzaSy)也遮罩 + * 保留 scheme + host + path,便于调试"请求打到哪个域名",但不带任何 secret。 + * Q6 fix (audit)。 + * @param {string} raw + * @returns {string} + */ +function sanitizeUrl(raw) { + return String(raw || '') + // M2 fix (audit):先剥 userinfo(https://user:pass@host),否则 fetch Basic Auth + // 凭据会被完整写到错误日志(即使 query/fragment 已剥,userinfo 仍在 host 前) + .replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1') + .replace(/[?#].*$/, '') // 去 query / fragment + .replace(/\/(sk-[A-Za-z0-9_-]{8,})/g, '/[REDACTED]') // path 段里的 OpenAI key + .replace(/\/(AIzaSy[A-Za-z0-9_-]{20,})/g, '/[REDACTED]'); // path 段里的 Google key +} + +/** + * 读 response body 到字符串,超过 MAX_RESP_BYTES 立刻中断。 + * P2-3 fix (audit)。 + * @param {Response} res + * @returns {Promise<{ ok:true, text:string } | { ok:false, message:string }>} + */ +async function readBodyWithLimit(res) { + if (!res.body || typeof res.body.getReader !== 'function') { + // 旧版 fetch / mock:fallback 到 .text(),上限由 .text() 自带的内存限制兜底 + try { + const text = await res.text(); + if (Buffer.byteLength(text, 'utf8') > MAX_RESP_BYTES) { + return { ok: false, message: 'AI 响应体过大' }; + } + return { ok: true, text }; + } catch { + return { ok: false, message: '读取 AI 响应失败' }; + } + } + const reader = res.body.getReader(); + /** @type {Buffer[]} */ + const chunks = []; + let total = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESP_BYTES) { + try { reader.cancel(); } catch { /* ignore */ } + return { ok: false, message: 'AI 响应体过大(超过 5 MB)' }; + } + chunks.push(Buffer.from(value)); + } + return { ok: true, text: Buffer.concat(chunks).toString('utf8') }; +} + +/** + * 创建主进程 AI 代理。 + * @param {object} deps + * @param {() => object} deps.getConfig - 取最新设置(configStore.getConfig);每次 runEdit 都会调用 + * @param {typeof fetch} [deps.fetchImpl] - 注入 fetch(测试用) + * @param {(key: string, payload: object) => void} [deps.log] - 日志钩子(不记录 apiKey) + */ +function createAiProxy({ getConfig, fetchImpl, log } = {}) { + if (typeof getConfig !== 'function') { + throw new Error('[ai] getConfig 必须是函数'); + } + const fetchFn = fetchImpl || ((...args) => fetch(...args)); + const logFn = typeof log === 'function' ? log : () => {}; + + /** @type {Map} */ + const pending = new Map(); + /** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */ + const timers = new Map(); + /** audit fix (C4):请求生命周期登记集合(runEdit 入口 → finally),用于 + * 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的, + * runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */ + const inFlightRequestIds = new Set(); + + function cancel(requestId) { + const controller = pending.get(requestId); + const timer = timers.get(requestId); + if (controller) { + controller.abort(); + pending.delete(requestId); + } + if (timer) { + clearTimeout(timer); + timers.delete(requestId); + } + } + + function cancelAll() { + for (const controller of pending.values()) controller.abort(); + pending.clear(); + // C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转 + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + } + + /** + * 校验 baseURL 是否为合法 http(s) URL。 + * 防止 javascript: / file: / data: 等伪协议触发 fetch TypeError 后报成"网络错误"误导用户。 + * M1 (audit):旧版只走 fetch 抛错,错误信息不友好。 + * @param {string} base + * @returns {{ok:true, value:string} | {ok:false, reason:string}} + */ + function validateBaseUrl(base) { + const s = String(base || '').trim(); + if (!s) return { ok: false, reason: 'Base URL 不能为空' }; + if (!/^https?:\/\//i.test(s)) { + return { ok: false, reason: 'Base URL 必须以 http:// 或 https:// 开头' }; + } + // P2 fix:hostname 校验 —— 之前只校验前缀,`https:///etc/passwd`(缺少 host) + // / `https:// host`(带空格)会被放行,然后 fetch 抛「ENOTFOUND / Invalid URL」 + // 但错误信息毫无线索。new URL 直接拒绝这些畸形输入,给出可执行反馈。 + try { + const parsed = new URL(s); + if (!parsed.hostname) { + return { ok: false, reason: 'Base URL 缺少主机名' }; + } + // 主机名不能包含空白字符或 ASCII 控制字符(防御一些浏览器容忍的奇怪输入) + // eslint-disable-next-line no-control-regex -- 控制字符范围是刻意检查的非法字符 + if (/[\s\x00-\x1f]/.test(parsed.hostname)) { + return { ok: false, reason: 'Base URL 主机名包含非法字符' }; + } + } catch (e) { + return { ok: false, reason: 'Base URL 不是合法 URL:' + (e && e.message || '') }; + } + return { ok: true, value: s }; + } + + /** + * 按模型名路由 token 上限 + token 字段。 + * H1 (audit):65536 远超多数模型上限(gpt-3.5=4096, gpt-4=8192, gpt-4o=16384), + * 服务端可能直接 400 拒绝或抛 invalid_request_error。 + * H2 (audit):gpt-5 / o-series 只接受 max_completion_tokens,旧字段会触发 + * "Unsupported parameter" 错误;老模型反过来——只接受 max_tokens。 + * H5 (audit):gpt-5 / o-series 不接受自定义 temperature(o1 固定为 1), + * 发 0.2 会 400 invalid_request_error。 + * @param {string} model + * @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }} + */ + function pickOpenAITokenConfig(model) { + const m = String(model || '').toLowerCase(); + // gpt-5 / o-series → 必须用 max_completion_tokens;上限通常 ≥ 128k;不传 temperature + if (/^(gpt-5|o1|o3|o4)/.test(m)) { + return { capTokens: 32_000, tokenField: 'max_completion_tokens', includeTemperature: false }; + } + // gpt-4o / 4-turbo → max_tokens,上限 16k + if (/^gpt-4o/.test(m) || /^gpt-4-turbo/.test(m)) { + return { capTokens: 16_384, tokenField: 'max_tokens', includeTemperature: true }; + } + // 普通 gpt-4 → 8k + if (/^gpt-4/.test(m)) { + return { capTokens: 8_192, tokenField: 'max_tokens', includeTemperature: true }; + } + // gpt-3.5 → 4k + if (/^gpt-3\.5/.test(m)) { + return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true }; + } + // 未知模型(包括国产中转、自部署):保守值 + 老字段,最大限度兼容 + return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true }; + } + + /** + * Anthropic 模型 max_tokens 上限路由。 + * H1 (audit):Anthropic 不同模型上限差异很大(旧 haiku 4096 / sonnet-3-5 8192), + * 65536 会被这些老模型 400 拒绝。 + * + * audit fix (round-13):补齐「5 系」与 4.6+ 命名。 + * 之前的正则 `claude-(3-5|3\.5|3-7|sonnet-4|opus-4|4)` 要求 `claude-` 后面 + * 紧跟这些片段,于是 claude-opus-5 / claude-sonnet-5 / claude-fable-5 / + * claude-haiku-4-5 全部不命中 → 回退 8192,反而比 claude-opus-4-8(16384) + * 更低。结果是「越新、输出上限越高的模型,拿到的 max_tokens 越小」: + * 长笔记改写会在 8k 处被截断 → stop_reason: max_tokens → runEdit 走 + * TRUNCATED_MESSAGE 分支报「AI 修改结果不完整」。这正是 Phase O-L17 + * 想修掉的那类 bug,只是模型命名又演进了一代。 + * + * 现行分档(保守取值,远低于官方上限,避免中转/自部署网关拒绝): + * - 5 系 + 4.6/4.7/4.8(官方 max output 128k)→ 32k + * - 其余 4 系(含 haiku-4-5)+ 3-5/3-7 → 16k + * - claude-3 老家族 → 8k + * - 未知模型(国产中转 / 自部署)→ 8k,最大限度兼容 + * @param {string} model + * @returns {number} + */ + function pickAnthropicTokenConfig(model) { + const m = String(model || '').toLowerCase(); + // 5 系(opus-5 / sonnet-5 / fable-5 / mythos-5)与 4.6+ → 官方 128k 上限 + if (/claude-(opus|sonnet|fable|mythos)-5/.test(m)) return 32_000; + if (/claude-(opus|sonnet)-4-(6|7|8)/.test(m)) return 32_000; + // 其余 4 系(sonnet-4-5 / opus-4-5 / haiku-4-5 / claude-4-*)+ 3-5 / 3-7 + if (/claude-(3-5|3\.5|3-7|sonnet-4|opus-4|haiku-4|4)/.test(m)) return 16_384; + if (/claude-3/.test(m)) return 8_192; + return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff + } + + /** + * 抽象的 HTTP 调用 + 超时 + 取消 + 状态码错误处理。 + * 不解析业务响应(OpenAI / Anthropic 各自的 JSON 结构在调用方处理)。 + * + * @param {{ + * requestId: string, + * url: string, + * headers: Record, + * body: object, + * timeoutMs: number, + * }} args + * @returns {Promise<{ ok: true, json: any } | { ok: false, error: string, message: string }>} + */ + async function postJson({ requestId, url, headers, body, timeoutMs }) { + const controller = new AbortController(); + pending.set(requestId, controller); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + timers.set(requestId, timer); + + // audit fix:所有 exit 路径必须清理两个 Map,否则长时间使用会泄漏 + // 内存(之前修复 cancelAll 只清理了 Map 没在 postJson 内清理 entry, + // 现在统一走 helper 确保不遗漏)。 + // + // 关键:cleanup 只清理「自己的」controller / timer,不能按 requestId 无脑 + // delete。如果 renderer 在我们 await fetchFn 的间隙用同一个 requestId 发起 + // 了新请求(pending.has → cancel → 新 postJson),新请求会重新 + // pending.set(requestId, newController)。随后旧 cleanup 在 microtask 阶段 + // 触发 pending.delete(requestId),会把新请求的 controller 从 Map 里抹掉, + // 导致新请求无法单独 cancel。比对 identity 再 delete 即可解决。 + function cleanup() { + clearTimeout(timer); + if (pending.get(requestId) === controller) pending.delete(requestId); + if (timers.get(requestId) === timer) timers.delete(requestId); + } + + let res; + try { + // audit fix (Round 13 / Sec-H3):redirect: 'manual' 阻止 undici 跟随 3xx + // 重定向到不同 origin 时复传自定义头。fetch 规范只会在 CORS 非通配头 + // 集合里自动剥 `Authorization`,但 Anthropic 用的是 `x-api-key`(自定义头), + // 不在脱敏名单里 —— 用户配置的中转 / 第三方网关一旦答 302 到攻击者域, + // `x-api-key: sk-ant-...` 和当前笔记全文都会被转发出去。 + // + // 用 manual 后拿到的是 opaqueredirect 类型的 Response,status 0、body 不可读; + // 我们在下文按 status === 0 / type === 'opaqueredirect' 显式报错给用户。 + // 不支持 redirect 的真 endpoint 不会触发这条分支(2xx/4xx/5xx 都不是 3xx)。 + res = await fetchFn(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + redirect: 'manual', + }); + } catch (e) { + cleanup(); + if (controller.signal.aborted) { + if (timedOut) { + return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' }; + } + return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; + } + const msg = e instanceof Error ? e.message : String(e); + if (/abort/i.test(msg)) { + return { ok: false, error: timedOut ? ERR_TIMEOUT : ERR_CANCELLED, message: timedOut ? 'AI 请求超时,请缩小文档、简化要求或重试' : '已取消 AI 请求' }; + } + // M1 fix (audit):网络错误信息里也可能携带 api key(fetch 库 / DNS 错误里偶尔 + // 会回显 URL 或 header),统一走 sanitizeDetail 防止泄露到 toast / 日志 + return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` }; + } + cleanup(); + + // audit fix (Round 13 / Sec-H3):3xx 重定向在 manual 模式下表现为 status=0 + // 且 type='opaqueredirect'。明示用户配置错了 Base URL,不要走"读 body 取错误信息" + // 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。 + if (res.status === 0 || res.type === 'opaqueredirect') { + return { + ok: false, + error: ERR_PROVIDER, + message: 'Base URL 发生了重定向,请直接填写最终地址(出于 API Key 安全考虑,Notes 不会自动跟随重定向)', + }; + } + + if (!res.ok) { + const status = res.status; + let detail = ''; + let errBody = null; + // P2-3 fix (audit):用 readBodyWithLimit 限制响应体大小,防止 OOM + // 审计修复 (Round 11 deep-fix P1-2):在 readBodyWithLimit 抛 AbortError 时 + // (用户取消)不要静默走到下面 → bodyRead.ok === false 时仍正常报错 OK; + // 但 reader 自身抛错会冒到 catch。检查 controller.signal.aborted 走「已取消」。 + let bodyRead; + try { + bodyRead = await readBodyWithLimit(res); + } catch (e) { + if (controller && controller.signal && controller.signal.aborted) { + return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; + } + throw e; + } + if (!bodyRead.ok) { + // Q6 fix (audit):url 写入日志前 sanitizeUrl,去掉 query / fragment / path secret + logFn('ai:http_error', { url: sanitizeUrl(url), status, detail: bodyRead.message }); + return { ok: false, error: ERR_FORMAT, message: bodyRead.message }; + } + const text = bodyRead.text; + if (text) { + // 尝试解析为 JSON 取 error.message;不成功则把原文截断用作 detail + try { + const parsed = JSON.parse(text); + errBody = parsed; + if (parsed && parsed.error && typeof parsed.error.message === 'string') { + detail = parsed.error.message; + } + } catch { + // ignore + } + if (!detail) detail = text.length > 200 ? `${text.slice(0, 200)}…` : text; + } + // P2-2 fix (audit):过滤 detail 里的 api key / 敏感 token 后再 log + 回显 + detail = sanitizeDetail(detail); + // Q6 fix (audit):url 写入日志前 sanitizeUrl,去掉 query / fragment / path secret + logFn('ai:http_error', { url: sanitizeUrl(url), status, detail }); + if (status === 401 || status === 403) { + return { ok: false, error: ERR_PROVIDER, message: 'API Key 无效或没有权限' }; + } + if (status === 404) { + return { ok: false, error: ERR_PROVIDER, message: 'Base URL 或模型不存在' }; + } + if (status === 408 || status === 504) { + return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' }; + } + if (status === 429) { + return { ok: false, error: ERR_PROVIDER, message: '请求过于频繁,请稍后重试' }; + } + if (status === 400 && errBody && errBody.error && errBody.error.type === 'invalid_request_error') { + // audit fix (Round 8 A-1):这里过去用的是 `errBody.error.message` 原文, + // 绕过了上面 line 525 的 sanitizeDetail —— 而 detail 正是同一个字符串 + // 脱敏后的版本。自部署网关(Azure / LiteLLM / 各类中转)在 + // invalid_request_error.message 里 echo 请求头或请求体的情况很常见, + // 一旦回吐 `Bearer sk-...` 就会原样进 toast。改用已脱敏的 detail。 + return { ok: false, error: ERR_PROVIDER, message: `请求参数错误:${detail || '服务端未提供详情'}` }; + } + if (status >= 500) { + // audit fix (M3 regression):500 也带 sanitized detail, + // 让上游服务器把真实错误("invalid token: sk-xxx")回吐时, + // 用户能从 toast 看到「凭据有问题」而不是一句空泛的"服务不可用"。 + // detail 已经走过 sanitizeDetail,key 类 token 已经被 [API_KEY] 替换。 + return { + ok: false, + error: ERR_PROVIDER, + message: `AI 服务暂时不可用(HTTP ${status})${detail ? ':' + detail : ''}`, + }; + } + return { ok: false, error: ERR_PROVIDER, message: `AI 请求失败(HTTP ${status})${detail ? ':' + detail : ''}` }; + } + + /** @type {any} */ + let json; + try { + // P2-3 fix (audit):成功路径也走大小限制(恶意 / 错误配置的服务端可能 + // 对 200 也返回大 body)。用 text + JSON.parse 替代 res.json()。 + const bodyRead = await readBodyWithLimit(res); + if (!bodyRead.ok) return { ok: false, error: ERR_FORMAT, message: bodyRead.message }; + json = JSON.parse(bodyRead.text); + } catch (e) { + // 审计修复 (Round 11 deep-fix P1-2):用户取消时 readBodyWithLimit 内部 + // reader.cancel() 抛 AbortError,外层 catch 之前把它误判成 "不是合法 JSON" + // → UI 看到误导的格式错误。先检查 controller.signal.aborted 走「已取消」分支。 + if (controller && controller.signal && controller.signal.aborted) { + return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; + } + const msg = e instanceof Error ? e.message : String(e); + if (/abort/i.test(msg)) { + return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; + } + return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' }; + } + return { ok: true, json }; + } + + /** + * 把模型原始文本回复归一化成 { content, responseFormat }: + * - 解析成 { content: string } → responseFormat = 'json'(用于文档修改 diff) + * - 其它情况 → responseFormat = 'raw'(普通对话回复) + */ + function normalizeAssistantText(message, originalContent) { + const parsed = tryParseJson(message); + if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') { + const next = parsed.content; + if (next === originalContent) { + return { content: '当前文档无需修改。', responseFormat: 'raw' }; + } + return { content: next, responseFormat: 'json' }; + } + // audit fix (shared-M13):如果 JSON 解析成功但 content 不是字符串(典型 + // 是 [] 数组 / 对象 / null / 数字),不要按「截断」处理 —— 模型是按规矩 + // 返回 JSON 对象的,只是 content 的形状不是我们约定的字符串。把 message + // 整体当 raw 回退返回给用户,至少他们能看到模型实际输出了什么,而不是 + // 看到一个误导的「输出不完整,请重试」提示。 + if (parsed && typeof parsed === 'object' && 'content' in parsed) { + return { content: message, responseFormat: 'raw' }; + } + const rawReply = extractRawReply(parsed); + if (rawReply) return { content: rawReply, responseFormat: 'raw' }; + if (looksLikeEditJson(message)) { + return null; // JSON 看起来想返回 {content:...} 但解析失败 —— 截断 + } + return { content: message, responseFormat: 'raw' }; + } + + /** + * OpenAI 兼容分支:POST {baseURL}/chat/completions。 + */ + async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) { + const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; + const baseURL = String(config.aiBaseUrl || '').trim(); + const apiKey = String(config.aiApiKey || '').trim(); + const model = String(config.aiModel || '').trim(); + const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT; + + if (!baseURL || !apiKey || !model) { + return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' }; + } + // M1 (audit):拒绝 javascript: / data: / file: 等伪协议,避免 fetch TypeError 报成"网络错误" + const urlCheck = validateBaseUrl(baseURL); + if (!urlCheck.ok) { + return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason }; + } + // audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 + // 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 / ::1 + // 且不需要 Key —— apiKey 留空就过;这里有 apiKey 意味着第三方 API,必须 HTTPS。 + // 静默放行会让 Key 在网线上裸奔到攻击者嗅探点,错误必须显式。 + // localhost/127.0.0.1/::1 仍然放过:本地抓包门槛远高于公网,本地代理是合法场景。 + if (/^http:\/\//i.test(urlCheck.value) && apiKey) { + try { + const parsed = new URL(urlCheck.value); + const host = (parsed.hostname || '').toLowerCase(); + const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1'; + if (!isLoopback) { + console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(urlCheck.value)); + return { + ok: false, + error: ERR_NOT_CONFIGURED, + message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。', + }; + } + } catch { /* validateBaseUrl 已校验过 URL,这里兜底 */ } + } + + const url = joinUrl(baseURL, 'chat/completions'); + // H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒; + // 同时按模型名决定发 max_tokens 还是 max_completion_tokens(gpt-5 / o-series 只接受后者)。 + // o-series / gpt-5 也不接受自定义 temperature(o1 强制为 1,发 0.2 会 400)。 + const { capTokens, tokenField, includeTemperature } = pickOpenAITokenConfig(model); + /** @type {Record} */ + const body = { + model, + [tokenField]: capTokens, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }), + }, + ], + stream: false, + }; + if (includeTemperature) body.temperature = 0.2; + + logFn('ai:request', { provider: 'openai', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length }); + + const result = await postJson({ requestId, url, headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, body, timeoutMs }); + if (!result.ok) return result; + + const json = result.json; + const choice = json && Array.isArray(json.choices) ? json.choices[0] : null; + if (!choice) { + return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' }; + } + if (choice.finish_reason === 'length') { + return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; + } + if (choice.finish_reason === 'content_filter') { + return { ok: false, error: ERR_FORMAT, message: 'AI 返回被服务方过滤,请调整要求后重试' }; + } + const message = choice.message && typeof choice.message.content === 'string' ? choice.message.content : ''; + if (!message) { + return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' }; + } + + const normalized = normalizeAssistantText(message, content); + if (normalized === null) { + return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; + } + return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat }; + } + + /** + * Anthropic 分支:POST {baseURL}/v1/messages。 + * system 单独字段(不在 messages 里);messages 只有 user/assistant 两轮对话。 + */ + async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) { + const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; + const baseURL = String(config.aiBaseUrl || '').trim(); + const apiKey = String(config.aiApiKey || '').trim(); + const model = String(config.aiModel || '').trim(); + const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT; + + if (!baseURL || !apiKey || !model) { + return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' }; + } + // M1 (audit):同样校验协议前缀,与 OpenAI 分支一致 + const urlCheck = validateBaseUrl(baseURL); + if (!urlCheck.ok) { + return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason }; + } + // audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 + // 与 OpenAI 分支共用同样防御,本地代理不需要 Key,留空即可。 + if (/^http:\/\//i.test(urlCheck.value) && apiKey) { + try { + const parsed = new URL(urlCheck.value); + const host = (parsed.hostname || '').toLowerCase(); + const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1'; + if (!isLoopback) { + console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(urlCheck.value)); + return { + ok: false, + error: ERR_NOT_CONFIGURED, + message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。', + }; + } + } catch { /* validateBaseUrl 已校验过 URL */ } + } + + const url = joinUrl(baseURL, 'v1/messages'); + // H1 (audit):Anthropic 各模型 max_tokens 上限不同,超额会 400 + const capTokens = pickAnthropicTokenConfig(model); + const body = { + model, + max_tokens: capTokens, + system: systemPrompt, + messages: [ + { + role: 'user', + content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }), + }, + ], + }; + + logFn('ai:request', { provider: 'anthropic', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length }); + + const result = await postJson({ requestId, url, headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + }, body, timeoutMs }); + if (!result.ok) return result; + + const json = result.json; + // 拼接所有 type==='text' 的 content 块;忽略 tool_use / tool_result 等。 + if (!json || !Array.isArray(json.content)) { + return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' }; + } + if (json.stop_reason === 'max_tokens') { + return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; + } + /** @type {string} */ + let message = ''; + let hasNonTextBlock = false; + for (const block of json.content) { + if (block && block.type === 'text' && typeof block.text === 'string') { + message += block.text; + } else if (block && block.type !== 'text') { + // audit fix (shared-M11):tool_use / tool_result / image 等非文本块 + // 当前版本不消费,但若整条 response 只有这些块就给一个明确的中文 + // 提示,而不是误导用户「AI 返回了空内容」(实际是格式我们暂时不支持)。 + hasNonTextBlock = true; + } + } + if (!message) { + // audit fix (shared-M11):把「模型只回了 tool_use / image 但没有正文」 + // 与「模型真的没回东西」区分开 —— 后者才报「空内容」。 + if (hasNonTextBlock) { + return { + ok: false, + error: ERR_FORMAT, + message: 'AI 返回了无法识别的内容(仅含 tool_use / 图像块,无文本回复)', + }; + } + return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' }; + } + + const normalized = normalizeAssistantText(message, content); + if (normalized === null) { + return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; + } + return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat }; + } + + /** + * 执行一次 AI 修改请求。 + * @param {{ prompt:string, content:string, filename:string, requestId:string }} input + * @returns {Promise<{ ok:true, id:string, content:string, responseFormat:'json'|'raw' } | { ok:false, error:string, message:string }>} + */ + async function runEdit({ prompt, content, filename, requestId }) { + if (typeof prompt !== 'string' || !prompt.trim()) { + return { ok: false, error: 'INVALID_PROMPT', message: '修改要求不能为空' }; + } + if (typeof content !== 'string') { + return { ok: false, error: 'INVALID_CONTENT', message: '当前文件内容无效' }; + } + // 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估) + if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) { + return { + ok: false, + error: 'CONTENT_TOO_LARGE', + message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB),请拆分后再让 AI 修改`, + }; + } + if (Buffer.byteLength(prompt, 'utf8') > 64_000) { + return { + ok: false, + error: 'PROMPT_TOO_LARGE', + message: '提示词过长(超过 64 KB),请简化要求', + }; + } + if (typeof filename !== 'string') { + filename = ''; + } + if (typeof requestId !== 'string' || !requestId) { + requestId = `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; + const provider = String(config.aiProvider || 'openai').trim().toLowerCase(); + const timeoutMs = DEFAULT_TIMEOUT_MS; + + // audit fix (C4):原 `if (pending.has(requestId)) cancel(requestId)` 是死代码 — + // pending 是 postJson 内部填的,postJson 在 runEdit 异步返回之后才执行, + // runEdit 入口检查 pending 时它一定是空的,双击防护其实由 renderer 端 + // ai-chat-panel 的 _submitting 守门。这里改成真正的 inFlightRequestIds: + // 进入 runEdit 即登记(早于 postJson),finally 清理,覆盖整个请求生命周期。 + if (inFlightRequestIds.has(requestId)) { + cancel(requestId); + } + inFlightRequestIds.add(requestId); + + // 注:token 上限不再用入参 maxTokens(已被 pick*TokenConfig 按模型路由取代) + const baseArgs = { prompt, content, filename, requestId, timeoutMs }; + try { + if (provider === 'anthropic') { + return await runAnthropicEdit(baseArgs); + } + if (provider === 'openai') { + return await runOpenAIEdit(baseArgs); + } + // audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。 + // schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。 + return { + ok: false, + error: ERR_NOT_CONFIGURED, + message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`, + }; + } finally { + // audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId + // 再来能正常进入;防 Set 缓慢增长。 + inFlightRequestIds.delete(requestId); + } + } + + return { + runEdit, + cancel, + cancelAll, + }; +} + +module.exports = { + createAiProxy, + // 共享错误码表 —— preload 经 contextBridge 把同一份 AI_ERROR 暴露到 + // window.api.aiErrors,renderer 直接 window.api?.aiErrors?.AI_ERROR。 + // main/ai.js 内部仍用 ERR_* 命名别名(line 59-63),纯粹是阅读性,无外部 + // 调用方 —— 别名不出 module.exports,避免「两个相同字面值漂移」风险。 + AI_ERROR, +}; diff --git a/main/config-store.js b/main/config-store.js new file mode 100644 index 0000000..20160ba --- /dev/null +++ b/main/config-store.js @@ -0,0 +1,518 @@ +// 配置持久化层(Stage 4b.1 抽离) +// +// 职责: +// - 加载 / 保存用户配置(userData/config.json) +// - 解析「实际生效」的数据目录(用户自定义 vs 默认 ~/Notes) +// - 首次启动把 data/welcome.md 种子化到默认数据目录 +// +// 设计: +// - 本模块自给自足:不读 mainWindow / tray / IPC 等任何 main.js 状态。 +// - 暴露单一对象 { load, save, get, resolveDataDir, seedDefault, getDefaultDataDir } +// - main.js 调一次 load() 拿到当前配置后,可随时通过 save() / get() 操作。 +// - schema 校验 / 默认值仍由 shared/settings-schema.js 提供(单一事实源)。 + +const electron = require('electron'); +const path = require('path'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const os = require('os'); + +// rename 重试 backoff 阶梯(ms)—— Windows Defender / 杀毒 / 同步盘 +// 会在极短时间内持锁目标文件,单次 rename 失败率约 1-3%。3 次 backoff +// 后仍失败才抛错(见 renameWithRetry)。 +const RENAME_BACKOFF_MS = [50, 100, 200]; +const { + DEFAULT_SETTINGS: DEFAULT_CONFIG, + coerceLoadedSettings, +} = require('../shared/settings-schema.js'); +// audit fix (Round 12 P2):saveConfig 失败时的 e.message 直接走 friendly-fs-error, +// 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到 +// 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。 +const { friendlyFsError } = require('../shared/friendly-fs-error.js'); + +// 测试注入点:默认走真实 electron.app,单测可以换成 mock app。 +// 下划线前缀表示「仅测试用」—— 生产代码不应调用。 +let _app = electron && electron.app; + +function getConfigPath() { + return path.join(_app.getPath('userData'), 'config.json'); +} + +/** + * 默认数据目录:用户主目录下的 Notes 子目录 + * 跨平台统一(不像 Todo List 那样在 Windows 上探测 D: 盘)—— 这是阅读器,单用户跨平台直接可用。 + */ +function getDefaultDataDir() { + try { + const home = _app.getPath('home'); + return path.join(home, 'Notes'); + } catch { + return path.join(os.homedir(), 'Notes'); + } +} + +let appConfig = {}; +let DEFAULT_DATA_DIR = null; +let configLoaded = false; + +function loadConfig() { + const cfgPath = getConfigPath(); + let raw; + try { + raw = fsSync.readFileSync(cfgPath, 'utf-8'); + } catch (e) { + // ENOENT 是「首次启动」,静默回退默认值是合理的; + // 其他错误(权限 / 磁盘坏道)也走默认值,但要在控制台留痕,方便用户反馈。 + if (e.code !== 'ENOENT') { + console.warn('[config-store] 配置读取失败,使用默认值:', e.code || e.message); + } + appConfig = { ...DEFAULT_CONFIG }; + cleanupStaleTmpFiles(); + configLoaded = true; + return appConfig; + } + + try { + const parsed = JSON.parse(raw); + appConfig = coerceLoadedSettings(parsed); + } catch (e) { + // 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json + // 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir + // 会无声丢失;下一次 saveConfig() 又会把默认值覆盖回去,损坏永久化。 + // 现在把损坏的配置文件改名备份(config.json.broken-),再走默认值。 + // 用户在设置里重新填值后会写回新的 config.json,备份留在旁边方便排查。 + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${cfgPath}.broken-${ts}`; + try { + fsSync.renameSync(cfgPath, backupPath); + console.warn(`[config-store] config.json 解析失败 (${e.message}),已备份到 ${backupPath},使用默认值`); + } catch (renameErr) { + console.warn(`[config-store] config.json 解析失败且备份失败 (${renameErr.message}),使用默认值`); + } + appConfig = { ...DEFAULT_CONFIG }; + } + // audit fix (4.4):清理上次异常退出留下的 config.json.tmp.* 残留。 + // 正常路径下 saveConfig 会在 rename 成功后留下 0 个 tmp,rename 失败路径会立刻 unlink; + // 但断电 / kill -9 会跳过 unlink,长期积累成百上千个 tmp。 + // 启动时扫一遍目录、删除自己的残留(不动当前 pid / 当前时间戳的,避免误删正在写的)。 + cleanupStaleTmpFiles(); + configLoaded = true; + return appConfig; +} + +/** + * 删除数据目录里残留的 config.json.tmp.* 文件。 + * 只删自己 pid 的(其它进程残留不碰,避免多开实例互相影响)。 + */ +function cleanupStaleTmpFiles() { + try { + const dir = path.dirname(getConfigPath()); + const files = fsSync.readdirSync(dir); + const myPid = process.pid; + const now = Date.now(); + const STALE_THRESHOLD_MS = 60_000; // 60s 之前留下的才视为残留(避免误删正在写的) + for (const f of files) { + if (!/^config\.json\.tmp\./.test(f)) continue; + const full = path.join(dir, f); + try { + const st = fsSync.statSync(full); + // 自己的 pid + 超过 60s 前 → 视为残留;跨 pid 不动(不归本进程管) + const isMine = f.includes(`.${myPid}.`); + const ageMs = now - st.mtimeMs; + if (isMine && ageMs > STALE_THRESHOLD_MS) { + fsSync.unlinkSync(full); + } + } catch { /* 单个文件 stat/unlink 失败不影响整体 */ } + } + } catch { + // readdir 失败(目录不存在等)静默 —— 配置读不到已 try/catch 兜过 + } +} + +/** + * 保存(部分)配置到磁盘。失败仅 console.error,不抛 —— 调用方继续用内存里的值。 + * 返回合并后的当前配置。 + * + * 写盘用「tmp + rename」原子模式(audit #5): + * - 先写到 config.json.tmp,再 fs.renameSync 覆盖 config.json + * - 进程在写入中途崩溃时磁盘上要么是旧文件,要么是新文件,绝不会半截 JSON + * - Windows 上 renameSync 在目标已存在时会成功(POSIX 语义),覆盖是原子的 + * - Linux 上 rename 也是原子的(同分区) + * 这样 config.json 永远可解析 —— 启动时再坏也只会回到默认(loadConfig 已 try/catch), + * 不会让整个 app 因为 settings 损坏而拒绝启动。 + */ +/** + * audit fix (Phase 3 C2):异步 + 串行 promise queue + 失败回滚。 + * + * 旧实现三个问题: + * 1. writeFileSync/renameSync 在主进程事件循环里同步阻塞 —— 拖 AI 面板分割条 + * 这类高频触发会卡 UI(settings-store 在 200ms 防抖后多次调用,每次都阻塞 + * 几 ms~几十 ms;慢盘 / OneDrive 同步冲突会更糟)。 + * 2. 多调用并发时,appConfig = {...appConfig, ...next} 在内存里已经合并, + * 但只有最后一次 writeFile 落盘;如果中间某次失败,前一次的合并内容丢失 + * 但 appConfig 还显示「成功」(next 返回合并后的状态)。 + * 3. catch 只 unlink tmp,appConfig 不回滚 —— 调用方以为保存成功,磁盘实际 + * 是旧值。 + * + * 新实现: + * - 每次 saveConfig 排队到 saveQueue 的链尾,前一次写完才执行下一次 + * - 用 fs.promises.writeFile + rename 异步 IO,不再阻塞事件循环 + * - rename 重试 EBUSY/EPERM 3 次(50/100/200ms backoff)—— Windows Defender + * 短暂持锁常见,retry-on-busy 比直接放弃更稳 + * - 写入失败:appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error } + */ +let saveQueue = Promise.resolve(); +function saveConfig(next) { + // audit fix (H1):merged 不能在 enqueue 时计算,否则两个并发调用 + // 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动 + // (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。 + // 改成在 task 内部取最新 appConfig 合并;同时 before 也在 task 内取, + // 保证回滚到「本次任务开始前一刻」的状态,而不是「所有任务开始前一刻」。 + const task = async () => { + const before = { ...appConfig }; + const merged = { ...appConfig, ...next }; + // auto-fallback 2026-08:dataDir 改了 → 让 resolveDataDirOrFallback 下次重新 stat。 + // 之前没有缓存,没有这个 hook;现在 saveConfig 走完同步更新 appConfig, + // 旧缓存条目还指向改前的路径 → 必须在此清掉。 + if ('dataDir' in next && before.dataDir !== next.dataDir) { + _invalidateResolveCache(); + } + const cfgPath = getConfigPath(); + const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}`; + let fh = null; + try { + await fs.mkdir(path.dirname(cfgPath), { recursive: true }); + // audit fix (Round 4 P0-1):改走 fs.open + writeFile + sync + close —— fs.writeFile + // 内部只把数据送进 page cache,没保证落盘就 close。rename 之后再断电,磁盘上 + // 可能是新名字 + 零字节 / 半截 JSON(之前 C2 修过「不写半截 JSON」靠的是 rename + // 原子性,但 fsync 缺失让断电后实际文件可能不是新文件)。AI key / 自定义 dataDir + // 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能 + // 保证断电后磁盘上要么是旧 config.json(rename 前断电),要么是完整新文件。 + fh = await fs.open(tmpPath, 'w'); + await fh.writeFile(JSON.stringify(merged, null, 2), 'utf-8'); + await fh.sync(); + await fh.close(); + fh = null; + await renameWithRetry(tmpPath, cfgPath); + // audit fix (Round 12 P1):parent dir fsync (POSIX only)。 + // main/file-ops.js#atomicWriteFile 已在 Round 4 加了这段对称保护, + // saveConfig 漏修。POSIX rename(2) 同分区下原子,但「目录项本身」的 + // 落盘时机由内核控制 —— rename 完直接断电,下次启动目录里可能仍是旧 + // 名字 + 新 inode 已分配但未刷盘 → 用户保存的 AI Key / 自定义 dataDir + // 在断电窗口后「看起来没保存」。Windows NTFS journal 元数据已带 fsync + // 语义,跳过;macOS / Linux 走 open('r')+sync+close 兜底。 + // 注意:fsync 失败 ≠ 数据丢失(rename 已生效),只 warn 不抛错。 + if (process.platform !== 'win32') { + try { + const dirFh = await fs.open(path.dirname(cfgPath), 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } + } catch (fsyncErr) { + console.warn('[config-store] parent dir fsync failed (non-fatal):', fsyncErr && fsyncErr.message); + } + } + // 写盘成功 → 同步到 in-memory cache + appConfig = merged; + return { ok: true, value: merged }; + } catch (e) { + if (fh) { try { await fh.close(); } catch { /* ignore */ } } + console.error('[config-store] 配置保存失败:', e.message); + // audit fix (C2):回滚 appConfig 到 merge 前状态,避免 UI 看到「已保存」 + // 但磁盘实际是旧值(renderer 端 settingsStore.optimisticUpdate 已经把 UI + // 改成新值,需要靠下一次 save 失败时回滚避免误以为成功)。 + appConfig = before; + // 清理残余 tmp(不影响下次保存) + try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ } + return { ok: false, error: friendlyFsError(e && e.code, e && e.message) || '配置保存失败' }; + } + }; + saveQueue = saveQueue.then(task, task); + return saveQueue; +} + +/** + * audit fix (C2):rename 重试 EBUSY/EPERM —— Windows Defender / 杀毒 / 同步盘 + * 会在极短时间内持锁目标文件,单次 rename 失败率约 1-3%。3 次 + * RENAME_BACKOFF_MS 阶梯 backoff 后仍失败才抛错。 + * @param {string} src + * @param {string} dst + */ +async function renameWithRetry(src, dst) { + const delays = RENAME_BACKOFF_MS; + for (let i = 0; i <= delays.length; i += 1) { + try { + await fs.rename(src, dst); + return; + } catch (e) { + const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'; + // 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。 + if (!transient || i === delays.length) throw e; + await new Promise((r) => setTimeout(r, delays[i])); + } + } +} + +/** + * 获取实际生效的数据目录路径(用户自定义优先,否则默认 ~/Notes) + * + * 纯字符串返回,不做磁盘存在性检查 —— schema 校验 / 写入前的便宜判断用这个。 + * 真正运行时(如 fsWatcher 启动 / scanDir 之前)请用 `resolveDataDirOrFallback()`, + * 那个会 stat 路径、缺失时自动回退到默认。 + */ +function resolveDataDir() { + const custom = appConfig.dataDir; + if (custom && typeof custom === 'string' && custom.trim()) { + return custom; + } + return DEFAULT_DATA_DIR; +} + +/** + * 解析数据目录,路径不存在时自动回退到默认(runtime-only,不修改 appConfig.dataDir)。 + * + * 设计动机(auto-fallback 2026-08): + * 旧版 `resolveDataDir()` 只做字符串返回 —— 当 `appConfig.dataDir` 指向的目录 + * 被外部删除 / 移动 / 离线(OneDrive / U 盘 / 网盘常见)时,fsWatcher 静默死亡、 + * scanDir 返回 ENOENT、侧栏空白,用户唯一恢复路径是手动「切换数据文件夹」再选。 + * 这里加一层 stat 存在性检查:缺失 → runtime 回退到默认(saved 字段记录原值, + * 持久化的 appConfig.dataDir 不动 —— 用户插回 U 盘下次启动还能用回去)。 + * + * 缓存: + * 同步 statSync 在每个 IPC handler 里都跑会很贵(file:list / file:read 等高频调用 + * 都会走 currentDataRoot())。这里按 customDir 字符串做键的同步缓存: + * - 同一 customDir 连续调用 → 只 stat 一次 + * - saveConfig 改了 dataDir → 缓存清掉,下次再 stat + * stat 失败后再次调用也命中缓存(避免 stat 一个不存在的路径反复失败)。 + * + * 错误处理: + * - ENOENT / ENOTDIR → 视为不存在,回退到默认 + * - 其他 errno(EACCES / EBUSY / EPERM / EIO)→ 视为存在(可能是瞬时 —— + * U 盘读权限慢 / Windows Defender 持锁等),让上层 scanDir 自然失败 + * 而不是「看似可用但其实打开就崩」 + * + * @returns {{ dir: string, fellBack: boolean, saved: string }} + * dir:实际可用的目录(默认或 custom) + * fellBack:true 表示 custom 路径不可用、临时回退到默认 + * saved:appConfig.dataDir 的当前持久化值(trim 后);empty 表示从未设置 + */ +let _resolveCache = null; // { customDir: string, exists: boolean } +/** + * 同步确保默认数据目录存在。默认目录可能是用户首次启动还没建出来、 + * 也可能是 customDir 失效 fallback 时默认目录也从未被用过 —— 任何要返回 + * DEFAULT_DATA_DIR 的路径都必须先确保它存在,否则 fsWatcher.startWatchingDir + * 会立刻 ENOENT 失败、scanDir 也读不出文件。 + * + * 同步而不是异步:resolveDataDirOrFallback 是 sync 接口(被 IPC handler / + * fsWatcher 启动路径同步调用),下面 fsWatcher 紧接着就拿这个 dir 去 + * fs.watch —— 异步 mkdir 会有竞态。mkdir recursive 已幂等,目录存在 no-op。 + */ +function ensureDefaultDataDirSync() { + if (!DEFAULT_DATA_DIR) return; + try { + fsSync.mkdirSync(DEFAULT_DATA_DIR, { recursive: true }); + } catch (e) { + console.warn(`[config-store] 创建默认数据目录失败(${DEFAULT_DATA_DIR}):`, e.message); + } +} + +function resolveDataDirOrFallback() { + const custom = (typeof appConfig.dataDir === 'string') ? appConfig.dataDir.trim() : ''; + if (!custom) { + ensureDefaultDataDirSync(); + scheduleEnsureDefaultDataDir(); + return { dir: DEFAULT_DATA_DIR, fellBack: false, saved: '' }; + } + // 命中缓存:相同 customDir 不重复 stat + if (_resolveCache && _resolveCache.customDir === custom) { + if (_resolveCache.exists) { + return { dir: custom, fellBack: false, saved: custom }; + } + ensureDefaultDataDirSync(); + scheduleEnsureDefaultDataDir(); + return { dir: DEFAULT_DATA_DIR, fellBack: true, saved: custom }; + } + // 重新 stat + let exists = true; + try { + const st = fsSync.statSync(custom); + // statSync 在普通文件 / 符号链接上不会抛 ENOTDIR —— ENOTDIR 只在「当目录用」时 + // 才会报。这里额外检查「不是目录」:dataDir 字段意外指向了一个文件路径 + // (用户在设置对话框里手填、或 config.json 被改坏),仍应 fallback,否则 + // 上层 scanDir / watchDir 立刻 ENOTDIR 报上来,体验割裂。 + if (!st.isDirectory()) exists = false; + } catch (e) { + if (e.code === 'ENOENT' || e.code === 'ENOTDIR') { + exists = false; + } else { + // EACCES / EBUSY / EPERM / EIO 等:当作存在,让上层自然处理错误 + // (fallback 反而会掩盖真实问题,比如 U 盘权限错误需要用户介入) + exists = true; + } + } + _resolveCache = { customDir: custom, exists }; + if (exists) { + return { dir: custom, fellBack: false, saved: custom }; + } + console.warn(`[config-store] 数据目录不可访问(${custom}),临时回退到默认 ${DEFAULT_DATA_DIR}`); + ensureDefaultDataDirSync(); + scheduleEnsureDefaultDataDir(); + return { dir: DEFAULT_DATA_DIR, fellBack: true, saved: custom }; +} + +/** + * 清掉 resolveDataDirOrFallback 的缓存。 + * + * 内部仅由 saveConfig 在 dataDir 字段变化时调用 —— 让 schema 校验后改值不会让旧 + * 缓存继续返回错误结果。外部无需直接调用(reset IPC 自己会改 dataDir 走 saveConfig)。 + */ +function _invalidateResolveCache() { + _resolveCache = null; +} + +function getConfig() { + if (!configLoaded) { + // 防御:调用方忘了 load() —— 隐式初始化一次 + loadConfig(); + } + return appConfig; +} + +/** + * 首次启动把 data/welcome.md 种子化到默认数据目录。 + * + * 触发条件(任一为真则跳过): + * - 默认目录里已有 welcome.md(已种子过)→ 直接 return + * - 默认目录里有其它 .md 文件(用户笔记)→ 不覆盖 + * + * 用 welcome.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件 + * —— 用户数据目录应该只放用户的内容。 + * + * 不再守卫「用户没设 dataDir」:auto-fallback 路径下用户设了 dataDir 但路径失效, + * 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 welcome.md + * 让用户立刻看到内容(而不是打开一个空侧栏)。 + * + * 失败仅 console.warn,不弹窗;用户用「打开数据文件夹」按钮可以自己补救。 + */ +async function ensureDefaultDataDir() { + if (!DEFAULT_DATA_DIR) return; + const targetDir = DEFAULT_DATA_DIR; + const welcomeDst = path.join(targetDir, 'welcome.md'); + + try { + // 先确保目录存在(mkdir recursive 不存在就建、存在 no-op)。 + // ensureDefaultDataDirSync 已经在 resolveDataDirOrFallback 同步路径里调过 + // 一次,这里再调一次是 idempotent 的——但 async 路径是 fire-and-forget, + // 可能在 sync 路径之前跑(scheduleEnsureDefaultDataDir 从 IPC handler 进) + // 或之后(启动 bootstrap),所以这里再保险一次。 + await fs.mkdir(targetDir, { recursive: true }); + + // welcome.md 已存在 → 已经种子过(或用户改过),跳过 + try { + await fs.stat(welcomeDst); + return; + } catch (e) { + if (e.code !== 'ENOENT') throw e; + } + + // 已有其它 .md → 用户笔记,不覆盖 welcome 也不打扰用户 + const dirEntries = await fs.readdir(targetDir).catch((err) => { + if (err.code === 'ENOENT') return []; + throw err; + }); + if (dirEntries.some((n) => n.toLowerCase().endsWith('.md'))) { + return; + } + + // 种子化 welcome.md + const src = path.join(__dirname, '..', 'data', 'welcome.md'); + await fs.copyFile(src, welcomeDst); + console.log('[config-store] 已种子 welcome.md 到默认数据目录:', welcomeDst); + } catch (e) { + console.warn('[config-store] welcome 种子失败:', e.message); + } +} + +/** + * 首次启动种子别名 —— 保留旧 API 名字(main.js 调用方),内部委托给 ensureDefaultDataDir。 + * 新代码优先用 ensureDefaultDataDir;旧名仅作 bootstrap 时的语义入口。 + */ +async function seedDefaultDataDir() { + return ensureDefaultDataDir(); +} + +/** + * fire-and-forget 异步种子 —— resolveDataDirOrFallback 内每次返回 DEFAULT_DATA_DIR + * 都会调它,确保在 IPC handler 同步返回之后异步把 welcome.md 种子进去。 + * + * 用 _ensureInFlight Promise 去重:同一时间多次触发只跑一次(fsWatcher 启动后 + * 第一次 IPC scanDir 触发 fallback + renderer 启动后的 resetDataDir + 启动自身 + * 三条路径都会调用,全部共享同一份 in-flight promise)。 + */ +let _ensureInFlight = null; +function scheduleEnsureDefaultDataDir() { + if (_ensureInFlight) return _ensureInFlight; + _ensureInFlight = ensureDefaultDataDir().finally(() => { + _ensureInFlight = null; + }); + return _ensureInFlight; +} + +/** + * 一站式初始化:loadConfig + 设定 DEFAULT_DATA_DIR。 + * 必须在 app ready 之后调(getDefaultDataDir 用到 app.getPath)。 + */ +function init() { + appConfig = loadConfig(); + DEFAULT_DATA_DIR = getDefaultDataDir(); + return { appConfig, defaultDataDir: DEFAULT_DATA_DIR }; +} + +/** + * 测试辅助:把模块级状态重置回初始值(appConfig={}, DEFAULT_DATA_DIR=null, configLoaded=false)。 + * + * 下划线前缀表示「仅测试用」—— 生产代码不应调用。单测之间需要干净的隔离。 + */ +function _reset() { + appConfig = {}; + DEFAULT_DATA_DIR = null; + configLoaded = false; + // auto-fallback 2026-08:测试间清理缓存,避免上一个 case 残留 customDir + // 命中率污染下一个 case 的首次 stat。 + _resolveCache = null; + // ensureDefaultDataDir 的 in-flight 也要清,避免上一个 case 的种子任务 + // 在新 case 期间仍在跑(race)。 + _ensureInFlight = null; + // audit fix (Round 12 P2):saveQueue 也要清。前一个 case 的 saveConfig + // 若没 await 就 _reset,新 case 的 saveConfig 会链到旧 case 的尾后 → + // 跨 case 串扰(renderer 端 / 用户态不会触发,但单测 _reset 后再 save + // 必须从干净 queue 开始)。 + saveQueue = Promise.resolve(); +} + +/** + * 测试辅助:注入 fake electron.app。单测里 vi.mock('electron') 拦不住 CJS 的 require, + * 所以提供 setter 让测试换掉内部 app 引用。 + */ +function _setApp(mockApp) { + _app = mockApp; +} + +module.exports = { + init, + loadConfig, + saveConfig, + getConfig, + getConfigPath, + getDefaultDataDir, + resolveDataDir, + resolveDataDirOrFallback, + ensureDefaultDataDir, + ensureDefaultDataDirSync, + scheduleEnsureDefaultDataDir, + seedDefaultDataDir, + _reset, + _setApp, +}; diff --git a/main/file-ops.js b/main/file-ops.js new file mode 100644 index 0000000..9bfa80f --- /dev/null +++ b/main/file-ops.js @@ -0,0 +1,694 @@ +// 文件操作 helper(Stage 7 抽出 + Folder Browser 扩展) +// +// 职责: +// - 暴露 file IPC 所需的可测试纯函数(isWithinDataDir / resolveFileName / scanFiles / scanDir / classifyEntry) +// - 不包含 ipcMain 注册逻辑(仍留在 main.js,与 config-store 的拆分风格一致) +// +// 边界: +// - 不引用 mainWindow / ipcMain / shell —— 这些留在 main.js 里就近注册 +// - 不读 configStore —— 由调用方注入数据目录 +// - scanFiles 的 ENOENT 行为:返回错误(不再静默 mkdir)—— +// 防止「数据目录被外部删了之后被自动重建掩盖数据丢失」 +// 见 audit #3 +// +// Folder Browser(2026-08): +// - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类 +// - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary' +// - EDITABLE_EXTS(来自 shared/extension-lists.js)是「可打开 + 编辑」 +// 的扩展名白名单;markdown (.md/.markdown) 与常见纯文本均在内 +// +// 测试:tests/unit/file-ops.test.js(jsdom 环境之外;纯 Node 即可) + +const fs = require('fs').promises; +const path = require('path'); +// 单一事实源:扩展名白名单(main + preload + renderer 三处共用) +const { EDITABLE_EXTS } = require('../shared/extension-lists.js'); +// 单一事实源:errno → 中文提示(main + renderer 共享,preload 过桥) +const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js'); + +/** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */ +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +/** + * 检查 fullPath 的占用情况 —— 返回三种状态: + * - 'free' 路径不存在,可用 + * - 'file' 路径已被一个文件占用,需要重命名避让 + * - 'directory' 路径已被一个目录占用(用户手动 mkdir 出来),也需要避让 + * + * 审计修复 (Round 11 deep-fix P2-4):旧版用 fs.access() 只能判断「存在与否」, + * 不能区分 file vs directory。用户新建笔记取名 `foo.md` 但数据目录里已经有一个 + * 同名目录(手动 mkdir 出来的笔记文件夹),resolveFileName 直接把目录路径当成 + * 可用文件返回;后续 atomicWriteFile 打开目录路径得到 EISDIR,UI 弹「无效参数」 + * 误导信息。改用 fs.stat() 拿 dirent 类型确认。 + */ +async function pathOccupancy(fullPath) { + try { + const st = await fs.stat(fullPath); + if (st.isDirectory()) return 'directory'; + if (st.isFile()) return 'file'; + // socket / fifo / device —— 数据目录里不应出现,但既然存在也当占用 + return 'file'; + } catch (e) { + if (e && e.code === 'ENOENT') return 'free'; + // EACCES / EPERM 等:保守起见当「占用」,让上层走避让分支 + return 'file'; + } +} + +/** + * 给一个文件名分类为 folder / editable / binary。 + * 纯函数:不读文件系统、不感知 path —— 仅看 name 末尾扩展名。 + * + * 注意:folder 类型由调用方(scanDir)传入时显式标记, + * 本函数在 entryType === 'folder' 时不会被调用(参见 scanDir 内部)。 + * + * @param {string} name + * @returns {'editable'|'binary'} + */ +function classifyEntry(name) { + if (typeof name !== 'string' || !name) return 'binary'; + const dot = name.lastIndexOf('.'); + // 没有扩展名 → binary + if (dot < 0) return 'binary'; + const ext = name.slice(dot + 1).toLowerCase(); + // 隐藏文件 / 末尾多余点:扩展名部分为空(".env" 有扩展名 "env",不是这种) + if (!ext) return 'binary'; + // dot === 0 的隐藏文件名(如 ".gitignore"):扩展名部分非空但不在白名单 → binary + // 注意 ".env" 现在会走 EDITABLE_EXTS.has('env') → 'editable'(修旧版的列表/链接割裂 bug) + return EDITABLE_EXTS.has(ext) ? 'editable' : 'binary'; +} + +/** + * 拒绝 symlink:Notes 用户的工作流是「编辑自己数据目录里的 .md」, + * 数据目录里出现 symlink 通常来自外部同步盘 / 误操作 / 恶意数据, + * 写 symlink 会跟随到外部真实文件,绕过 isWithinDataDir 边界。 + * 见 audit #7。 + * + * @param {string} filePath + * @returns {Promise<{ok: true, isFile: boolean} | {ok: false, error: string, code?: string}>} + */ +async function assertNotSymlink(filePath) { + let st; + try { + st = await fs.lstat(filePath); + } catch (e) { + if (e.code === 'ENOENT') return { ok: true, isFile: false }; // 不存在:留给调用方走 FILE_NOT_FOUND 路径 + return { ok: false, error: e.message, code: e.code }; + } + if (st.isSymbolicLink()) { + return { ok: false, error: 'SYMLINK_NOT_ALLOWED', message: '不允许编辑符号链接(防止越权写入)' }; + } + return { ok: true, isFile: st.isFile() }; +} + +/** + * audit fix (M1 main):递归检查 filePath 与其祖先(直到 stopAt 目录), + * 任一层级出现 symlink 即拒绝。光检查 target 文件不够 —— 若父目录是 symlink + * 指向 dataDir 外部,整个 target 的「真实路径」就会跳出 dataDir,绕过 + * isWithinDataDir 的字符串前缀检查。同步盘 / 误操作 / 外部攻击都可能制造 + * 这种中间层 symlink。 + * + * 策略:从 filePath 父目录开始向上 lstat,到 stopAt(一般是 dataDir)为止, + * 任一是 symlink 就拒绝。stopAt 本身允许是 symlink?通常不允许,但它的边界 + * 由 assertDataDirSafe 等其它检查负责,这里只覆盖 target 这棵子树。 + * + * @param {string} filePath - 目标文件路径 + * @param {string} [stopAt] - 终止祖先链的目录(默认不传 = 一路 lstat 到根, + * 但调用方一般会传 dataDir 来限定范围) + * @returns {Promise<{ok:true} | {ok:false, error:string, code?:string}>} + */ +async function assertNoSymlinkAncestor(filePath, stopAt) { + if (!filePath) return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径不能为空' }; + const stopResolved = stopAt ? path.resolve(stopAt) : null; + // audit fix (Phase O fix):原实现只从 filePath 的**父目录**开始向上 lstat, + // 漏掉 filePath 自身。若 filePath 本身是 symlink(指向外部目标盘),父目录 + // 通常都是合法的 dataDir 子项,但 filePath 本身跟随 symlink 解析后就跳到 + // dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。 + // 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。 + const resolvedPath = path.resolve(filePath); + if (!stopResolved || resolvedPath !== stopResolved) { + try { + const stSelf = await fs.lstat(resolvedPath); + if (stSelf.isSymbolicLink()) { + return { + ok: false, + error: 'SYMLINK_NOT_ALLOWED', + code: 'SYMLINK_NOT_ALLOWED', + message: `路径中包含符号链接,不允许编辑(${resolvedPath})`, + }; + } + } catch (e) { + // ENOENT:目标本身不存在是允许的(scan-dir 创建场景、新文件);其它错误上报 + if (e.code !== 'ENOENT') { + return { ok: false, error: e.message, code: e.code, message: `读取路径状态失败:${e.code || e.message}` }; + } + } + } + let dir = path.dirname(resolvedPath); + // 已经走到根盘符 / 根目录就停 + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + // 终止祖先链的判定:stopAt 目录已检查过、不再向上 lstat。 + // audit fix (main-M10):之前三连 OR 里 `dir === stopResolved.toLowerCase?.()` + // 是死分支 —— dir 来自 path.dirname(),Windows 上是混合大小写,根本不会 + // 全小写化。删掉,只保留 POSIX 严格相等 + Windows 大小写无关两条。 + if (stopResolved && (dir === stopResolved + || (process.platform === 'win32' && dir.toLowerCase() === stopResolved.toLowerCase()))) { + break; + } + try { + const st = await fs.lstat(dir); + if (st.isSymbolicLink()) { + return { + ok: false, + error: 'SYMLINK_NOT_ALLOWED', + code: 'SYMLINK_NOT_ALLOWED', + message: `路径中包含符号链接目录,不允许编辑(${dir})`, + }; + } + } catch (e) { + // 父目录不存在是允许的(target 可能尚未创建);其它错误上报 + if (e.code !== 'ENOENT') { + // audit fix (K1-R4):统一带 code + message 字段,与 assertNotSymlink 对齐。 + return { ok: false, error: e.message, code: e.code, message: `读取目录状态失败:${e.code || e.message}` }; + } + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; // 已经到盘符根 + dir = parent; + } + return { ok: true }; +} + +/** + * 检查目标路径是否在数据目录内(防止越权读写) + * - Windows 不区分大小写 + * - 字符串前缀比对(path.resolve 后再做) + * - 未调用 fs.realpath(symlink 不解);file:write/rename/delete 已先 lstat 拒绝 symlink + * + * @param {string} target + * @param {string} root + */ +function isWithinDataDir(target, root) { + if (!root || !target) return false; + const rootResolved = path.resolve(root); + const targetResolved = path.resolve(target); + if (process.platform === 'win32') { + const rootLower = rootResolved.toLowerCase(); + const targetLower = targetResolved.toLowerCase(); + return targetLower.startsWith(rootLower + path.sep) + || targetLower === rootLower; + } + return targetResolved === rootResolved + || targetResolved.startsWith(rootResolved + path.sep); +} + +/** + * 文件名清洗:拒绝路径分隔符 / `..` / 空字符串 / 控制字符;用户输入的扩展名 + * 原样保留;重名 (2)、(3)...。 + * + * audit fix (Phase L3-FS 4A/4B):拒绝 Windows 保留字符(<>:"/\\|?*)和保留设备名 + * (CON / PRN / AUX / NUL / COM1-9 / LPT1-9)。之前 name 里含 `|` 或 `:` 仍能通过 + * resolveFileName,到 fs.rename 才在 Windows 上撞 EPERM/EBUSY,UI 弹出 + * 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。 + * + * 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动 + * 加上」—— 本函数不再强制补 .md。用户输入 `foo.md` / `foo.txt` / `foo`(无扩展名) + * 都按字面保留。重名避让时扩展名也原样保留(foo.txt → foo (2).txt)。 + * 重命名场景走 resolveRenameName,行为完全对称。 + * + * @param {string} raw + * @param {string} dir - 数据目录绝对路径 + * @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>} + */ +// Windows 保留设备名(基础名,无扩展名时)。注意是大写比对;用户的实际 +// 输入通常是大小写混合,做 toUpperCase 后再校验。带扩展名也照样拒 +// (Windows 把 CON.md / con.txt 都视作设备名 —— 这是 NTFS 的硬规则, +// fs.rename 一定撞 EPERM)。 +const WIN_RESERVED_DEVICE_NAMES = new Set([ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', +]); + +async function resolveFileName(raw, dir) { + if (typeof raw !== 'string' || !raw.trim()) { + return { ok: false, error: '文件名不能为空' }; + } + const name = raw.trim(); + // 拒绝路径分隔符与 .. 段 + if (/[/\\]/.test(name) || name.includes('..')) { + return { ok: false, error: '文件名不能包含路径分隔符或 ..' }; + } + // Windows 保留字符:<>:"/\\|?*(/\\ 已被上面挡一次,这里再列一遍保持语义独立, + // 让错误信息精确指向 Windows 保留字符而非「路径分隔符」)。 + if (/[<>:"|?*]/.test(name)) { + return { ok: false, error: '文件名包含 Windows 保留字符(< > : " | ? *)' }; + } + // 控制字符 + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(name)) { + return { ok: false, error: '文件名包含非法字符' }; + } + // Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算) + const lastDot = name.lastIndexOf('.'); + const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase(); + if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) { + return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` }; + } + // 用户输入的扩展名原样保留 —— 故意不强制 .md(与 resolveRenameName 对齐)。 + + let candidate = name; + let counter = 2; + for (;;) { + const fullPath = path.join(dir, candidate); + // 任何候选路径都必须仍在数据目录内(防止 join 出 ..\) + if (!isWithinDataDir(fullPath, dir)) { + return { ok: false, error: '非法路径' }; + } + const occupancy = await pathOccupancy(fullPath); + if (occupancy === 'free') { + return { ok: true, path: fullPath, name: candidate }; + } + // 'file' / 'directory' 都视为占用 → 走避让分支 + // 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留(与 resolveRenameName 对齐)。 + // foo.md → foo (2).md;bar.txt → bar (2).txt;baz(无扩展名) → baz (2) + const cutAt = lastDot > 0 ? lastDot : name.length; + const base = name.slice(0, cutAt); + const ext = name.slice(cutAt); + candidate = `${base} (${counter})${ext}`; + counter += 1; + if (counter > 1000) return { ok: false, error: '重名次数过多' }; + } +} + +/** + * 重命名专用的文件名清洗。 + * + * 用户反馈:「重命名不要自动补后缀」—— 重命名场景下用户输入什么就用什么, + * 包括完全去掉扩展名(foo.md → bar)或换成别的扩展名(foo.md → bar.txt)。 + * + * 2026-08-28 反馈后,resolveFileName(新建文件)与本函数语义完全对齐: + * 都不强制补 .md,新建时用户输入什么就用什么(默认 .md 仍由 renderer 的 + * defaultName 带出来)。这样用户想新建一个 .txt 纯文本笔记 / .json 数据笔记 + * 不再需要「先建 .md → 重命名成 .txt」两步。 + * + * 仍然保留的校验: + * - 路径分隔符 / .. + * - Windows 保留字符 + * - Windows 保留设备名(基础名按 . 之前的部分取,"CON.md" 也算) + * - 控制字符 + * - 重名避让((2)/(3) 后缀保留原始扩展名:foo.md → foo (2).md; + * foo.txt → foo (2).txt;无扩展名 foo → foo (2)) + * + * @param {string} raw + * @param {string} dir + */ +async function resolveRenameName(raw, dir) { + if (typeof raw !== 'string' || !raw.trim()) { + return { ok: false, error: '文件名不能为空' }; + } + const name = raw.trim(); + // 拒绝路径分隔符与 .. 段 + if (/[/\\]/.test(name) || name.includes('..')) { + return { ok: false, error: '文件名不能包含路径分隔符或 ..' }; + } + if (/[<>:"|?*]/.test(name)) { + return { ok: false, error: '文件名包含 Windows 保留字符(< > : " | ? *)' }; + } + // 控制字符 + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(name)) { + return { ok: false, error: '文件名包含非法字符' }; + } + // Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算) + const lastDot = name.lastIndexOf('.'); + const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase(); + if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) { + return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` }; + } + + let candidate = name; + let counter = 2; + for (;;) { + const fullPath = path.join(dir, candidate); + if (!isWithinDataDir(fullPath, dir)) { + return { ok: false, error: '非法路径' }; + } + // 审计修复 (Round 11 deep-fix P2-4):用 pathOccupancy 替代 fs.access, + // 区分 file vs directory —— 同名目录不能被当作可用文件返回。 + const occupancy = await pathOccupancy(fullPath); + if (occupancy === 'free') { + return { ok: true, path: fullPath, name: candidate }; + } + // 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留。 + // foo.md → foo (2).md;bar.txt → bar (2).txt;baz(无扩展名) → baz (2) + const cutAt = lastDot > 0 ? lastDot : name.length; + const base = name.slice(0, cutAt); + const ext = name.slice(cutAt); + candidate = `${base} (${counter})${ext}`; + counter += 1; + if (counter > 1000) return { ok: false, error: '重名次数过多' }; + } +} + +/** + * 扫描数据目录下的所有 .md / .markdown 文件,按名称排序(locale zh-CN)。 + * + * 重要变更(audit #3):目录不存在(ENOENT)时不再自动 mkdir, + * 直接返回错误让上层决定如何处理(弹对话框 / 切换目录), + * 避免「数据目录被外部删了之后被静默重建掩盖数据丢失」。 + * + * @param {string} dir + * @returns {Promise<{ok:true, files:Array} | {ok:false, error:string, code?:string}>} + */ +async function scanFiles(dir) { + if (!dir || typeof dir !== 'string') { + return { ok: false, error: 'invalid dir', code: 'EINVAL' }; + } + try { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (e) { + // ENOENT:目录不存在 —— 不再自动重建,让用户看到明确错误 + if (e.code === 'ENOENT') { + return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `数据目录不存在:${dir}` }; + } + throw e; + } + const mdFiles = entries.filter((e) => { + if (!e.isFile()) return false; + const lower = e.name.toLowerCase(); + return lower.endsWith('.md') || lower.endsWith('.markdown'); + }); + + const files = await Promise.all(mdFiles.map(async (e) => { + const fullPath = path.join(dir, e.name); + try { + const st = await fs.stat(fullPath); + return { + name: e.name, + path: fullPath, + size: st.size, + mtimeMs: st.mtimeMs, + }; + } catch { + return null; + } + })); + + const list = files + .filter((f) => f !== null) + .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')); + return { ok: true, files: list }; + } catch (e) { + return { ok: false, error: e.message, code: e.code }; + } +} + +/** + * 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。 + * Folder Browser(2026-08)的核心入口。 + * + * 行为约定: + * - 不递归:只列 absDir 的一层直接子项;想看深层 → 点文件夹进入 + * - 跳过符号链接文件夹(与 assertNotSymlink 同源策略; + * 不跟随,避免 symlink 指向 dataDir 外部造成越权) + * - 跳过符号链接文件(同样防御) + * - 文件 entryType 由 classifyEntry(name) 决定: + * 'editable' = Markdown 或常见纯文本(白名单内) + * 'binary' = 其它扩展名(侧栏仍显示但灰掉、点击弹 toast) + * - 文件夹 entryType 固定为 'folder' + * - 排序:文件夹在前(按名称),文件在后(按名称); + * 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉 + * + * @param {string} absDir - 数据目录下某一层的绝对路径 + * @returns {Promise<{ + * ok: true, + * dir: string, // 回传绝对路径,便于调用方对照 + * entries: Array<{ + * name: string, + * path: string, // 绝对路径 + * entryType: 'folder'|'editable'|'binary', + * isFolder: boolean, + * size?: number, + * mtimeMs?: number, + * }> + * } | { ok:false, error, code?, message? }>} + */ +async function scanDir(absDir) { + if (!absDir || typeof absDir !== 'string') { + return { ok: false, error: 'invalid dir', code: 'EINVAL' }; + } + let dirents; + try { + dirents = await fs.readdir(absDir, { withFileTypes: true }); + } catch (e) { + if (e.code === 'ENOENT') { + return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `目录不存在:${absDir}` }; + } + // audit fix (Round 12 P2):非 ENOENT 路径不再把原始 e.message(英文 errno + // + 绝对路径)透出去。renderer fs-watcher 推过来时直接当成 toast 文案 + // 显示,会泄漏路径 + 看着割裂。走 friendly-fs-error 与 Round 8 EROFS / + // ENAMETOOLONG 等统一:未知 errno 时拿 e.message 作为 fallback(兜底 + // 业务码),不能让用户看空白 toast。 + return { + ok: false, + error: sharedFriendlyFsError(e && e.code, e && e.message) || '扫描目录失败', + code: e.code, + }; + } + + // 用 lstat 一次性拿每一项的类型,过滤掉符号链接(防止越权)。 + // 注意 fs.readdir(..., {withFileTypes:true}) 给的 dirent.isSymbolicLink() + // 在 Windows 上对 junction 也判定为 true —— 这里统一跳,避免跟随。 + const safeDirents = []; + for (const d of dirents) { + if (d.isSymbolicLink()) continue; + safeDirents.push(d); + } + + const entries = await Promise.all(safeDirents.map(async (d) => { + const fullPath = path.join(absDir, d.name); + if (d.isDirectory()) { + return { + name: d.name, + path: fullPath, + entryType: 'folder', + isFolder: true, + }; + } + if (d.isFile()) { + try { + const st = await fs.stat(fullPath); + return { + name: d.name, + path: fullPath, + entryType: classifyEntry(d.name), + isFolder: false, + size: st.size, + mtimeMs: st.mtimeMs, + }; + } catch { + // 文件在 readdir 与 stat 之间被删了 —— 静默跳过 + return null; + } + } + // 其它(socket / fifo / block device 等)—— 不展示 + return null; + })); + + const list = entries.filter(Boolean); + // 排序:folder 在前、file 在后,同组内按 zh-CN locale + list.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; + return a.name.localeCompare(b.name, 'zh-CN'); + }); + + return { ok: true, dir: absDir, entries: list }; +} + +/** + * 把数据目录根下的相对路径解析成绝对路径,并做防御性校验。 + * + * 渲染端传来的 relDir 必须满足: + * - 空字符串 → dataRoot(根目录) + * - 正斜杠分隔的相对路径(如 'notes/2026'),不含 .. + * - 不含绝对路径前缀(Windows 盘符 / POSIX /) + * + * 解析后用 isWithinDataDir 二次校验(防止路径穿越 / symlink 跟随)。 + * + * @param {string} relDir + * @param {string} dataRoot - 数据目录绝对路径 + * @returns {{ok:true, absDir:string, relDir:string} | {ok:false, error:string, message?:string}} + */ +function resolveDirRelative(relDir, dataRoot) { + if (typeof relDir !== 'string') { + return { ok: false, error: 'INVALID_REL_DIR', message: 'relDir 必须是字符串' }; + } + // 拒绝盘符 / 绝对路径前缀(必须在 strip 前做,否则 '/etc/passwd' 被剥成 + // 'etc/passwd' 反而通过校验) + if (/^[a-z]:[\\/]/i.test(relDir) || relDir.startsWith('/') || relDir.startsWith('\\')) { + return { ok: false, error: 'PATH_NOT_ALLOWED', message: '不允许使用绝对路径' }; + } + const normalized = relDir.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + // 拒绝 .. 段(即使藏在中间) + if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) { + return { ok: false, error: 'PATH_NOT_ALLOWED', message: '相对路径不能包含 . 或 ..' }; + } + const absDir = normalized === '' ? dataRoot : path.join(dataRoot, normalized); + if (!isWithinDataDir(absDir, dataRoot)) { + return { ok: false, error: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; + } + // 重新标准化回 POSIX(与传入保持一致)+ 末尾无 / + const relOut = normalized; + return { ok: true, absDir, relDir: relOut }; +} + +/** + * 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。 + * 渲染端用于比对 fs-watcher 推送的 relDir 字段。 + * + * @param {string} absDir + * @param {string} dataRoot + * @returns {string} POSIX 相对路径,根目录时为 '' + */ +function toRelativeDir(absDir, dataRoot) { + if (!absDir || !dataRoot) return ''; + const a = absDir.replace(/\\/g, '/').replace(/\/+$/, ''); + const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, ''); + if (a === r) return ''; + const prefix = r + '/'; + if (a.startsWith(prefix)) return a.slice(prefix.length); + // 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支 + return ''; +} + +/** + * 原子写文件 —— audit fix(C1/C2 file-IO): + * 1. 写到 dst.tmp.. + * 2. fsync tmp(让内容确实落盘,再 rename 才不会丢) + * 3. renameWithRetry 覆盖 dst(Windows Defender / 杀毒 / 同步盘 + * 偶尔瞬态持锁,retry-on-busy 比直接放弃稳得多) + * + * 失败路径: + * - 写入 tmp 失败 → unlink tmp + 抛原始 errno + * - rename 失败(retry 后)→ unlink tmp + 抛原始 errno + * + * 副作用:成功后磁盘上要么是旧文件(旧文件全程未动),要么是新文件; + * 永远不会有半截内容被读到。 + * + * @param {string} dst 目标绝对路径 + * @param {string} content UTF-8 文本 + */ +async function atomicWriteFile(dst, content) { + const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}`; + let fh; + try { + fh = await fs.open(tmpPath, 'w'); + await fh.writeFile(content, 'utf-8'); + // fsync 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了 + // 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价 + // FlushFileBuffers,rename 之前必须强制落盘。 + await fh.sync(); + await fh.close(); + fh = null; + } catch (e) { + if (fh) { try { await fh.close(); } catch { /* ignore */ } } + try { await fs.unlink(tmpPath); } catch { /* tmp 不存在也忽略 */ } + throw e; + } + + try { + await renameWithRetry(tmpPath, dst); + // audit fix (Round 4 P0-1):rename 后 fsync 父目录(仅 Linux / POSIX)。 + // POSIX rename(2) 在同分区下原子,但「目录项本身」写入磁盘的时机由内核 + // 控制;不 fsync 目录就断电,磁盘上可能仍是旧名字 → 文件彻底丢失。 + // Windows 上 NTFS 文件系统层 journal 元数据,这条 fsync 不需要(也无害, + // 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。 + // 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。 + if (process.platform !== 'win32') { + try { + const dirFh = await fs.open(path.dirname(dst), 'r'); + await dirFh.sync(); + await dirFh.close(); + } catch (dirFsyncErr) { + console.warn('[file-ops] 父目录 fsync 失败(不影响本次保存内容,但跨崩溃可能丢目录项):', dirFsyncErr.message); + } + } + } catch (e) { + // rename 失败:清理残余 tmp(不影响下次保存) + try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ } + throw e; + } +} + +/** + * audit fix (C3 file-IO):rename 重试 EBUSY/EPERM/EACCES。 + * Windows Defender / 杀毒 / 同步盘会在极短时间内持锁目标文件, + * 单次 rename 失败率约 1-3%。3 次 50/100/200ms backoff 后仍失败才抛。 + * 与 config-store 的同名实现保持一致的 backoff 时序,避免两条路径表现差异。 + * @param {string} src + * @param {string} dst + */ +async function renameWithRetry(src, dst) { + const delays = [50, 100, 200]; + for (let i = 0; i <= delays.length; i += 1) { + try { + await fs.rename(src, dst); + return; + } catch (e) { + const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'; + // 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。 + if (!transient || i === delays.length) throw e; + await new Promise((r) => setTimeout(r, delays[i])); + } + } +} + +/** + * audit fix (Round 4 收尾):file:write 的 errno 翻译 —— 与 main.js 各 IPC handler + * (_friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError) + * 共用一份文案。Round 4 之前本函数与 src/app.js#friendlyWriteError、 + * src/file-ops.js#friendlyFsError 三处独立,EROFS / ENAMETOOLONG / ENOTDIR 文案 + * 三处不同,ENOTEMPTY 在 src/file-ops.js 独有 —— 用户看到的提示不一致。 + * + * 改用 shared/friendly-fs-error.js 单一事实源(preload 同时把它过桥到 + * window.api.friendlyFsError,renderer 两处旧实现也走同一份)。本函数保留壳子 + * 是为了不重写所有调用点的语义(main 内部仍叫 friendlyWriteError 表达 + * 「写盘错误翻译」,renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。 + * + * @param {NodeJS.ErrnoException|null|undefined} e + * @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」) + */ +function friendlyWriteError(e) { + // 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。 + // - 已知 errno:shared 模块返回固定中文文案,与 e.message 无关 + // - 未知 errno / null e / e.code 缺失:shared 模块走 fallback || '未知错误' + // → e?.message 有就透传英文 errno + 路径(renderer 拿到的是已经走 + // friendlyFsError 二次翻译过的中文,不会再让英文 errno 漏到这里); + // 没有就回退到「未知错误」(注意:旧版这里固定传「写入文件失败」, + // 与 renderer 三处的「未知错误」兜底文案不一致,统一为后者) + return sharedFriendlyFsError(e && e.code, e && e.message); +} + +module.exports = { + MAX_FILE_SIZE, + EDITABLE_EXTS, + assertNotSymlink, + assertNoSymlinkAncestor, + classifyEntry, + isWithinDataDir, + resolveFileName, + resolveRenameName, + scanFiles, + scanDir, + resolveDirRelative, + toRelativeDir, + atomicWriteFile, + renameWithRetry, + friendlyWriteError, +}; diff --git a/main/fs-watcher.js b/main/fs-watcher.js new file mode 100644 index 0000000..a242855 --- /dev/null +++ b/main/fs-watcher.js @@ -0,0 +1,500 @@ +// 目录监听层(Stage 4b.2 抽离 + Stage 8 扩展) +// +// 职责: +// - fs.watch + readdir 轮询双通道捕获数据目录变化 +// - 把变化推送给 renderer(IPC 'files:changed',payload 含 relDir / dir) +// - 窗口最小化 / 隐藏时暂停轮询(fs.watch 保持运行),恢复时补扫一次 +// - Stage 8:支持 rewatch() 把监听目标切换到子目录(Folder Browser) +// +// 设计: +// - factory 模式:依赖通过参数注入,不引用 mainWindow / configStore 等具名符号 +// - 状态(dirWatcher / dirPollTimer / lastFilesSnapshot / watchedDir / emitDebounceTimer) +// 全部闭包在 factory 返回的实例里,不污染模块全局 +// +// 边界: +// - 不读 mainWindow 具名变量 —— 由 deps.getMainWindow() 提供(避免循环引用) +// - 不调 configStore —— 由 deps.resolveDataDir() 注入(dir 变化也走这里) +// - 不重复实现 scanDir —— 由 deps.scanDir() 注入(同一份扫描逻辑) + +const fsSync = require('fs'); + +const DIR_POLL_INTERVAL_MS = 2000; +const EMIT_DEBOUNCE_MS = 300; +// audit fix (Round 7 FS-F8):debounce maxWait —— 持续事件流(git checkout / +// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush, +// fs.watch 通道一次都不 emit。maxWait=1000ms 强制 1s 内必须 emit 一次。 +const EMIT_MAX_WAIT_MS = 1000; +const REATTACH_BASE_MS = 5_000; +const REATTACH_MAX_MS = 60_000; +// audit fix (Round 7 FS-F1):错误闩锁重复抑制窗口。数据目录被删除时 +// updateSnapshot 每 2s 失败一次 → renderer 每 2s 弹一次 error toast → +// 屏幕常驻 5 条警告。30s 重发间隔让用户有充裕时间响应,又不至于漏掉 +// 真实新错误(同 error 不同 dir 也算新错误,立即重发)。 +const ERROR_REPEAT_THROTTLE_MS = 30_000; + +/** + * @typedef {Object} FsWatcherDeps + * @property {() => (object|null)} getMainWindow - 返回 BrowserWindow 或 null + * @property {() => (string|null)} resolveDataDir - 当前生效的数据目录 + * @property {(dir: string) => Promise<{ok:boolean, entries?:Array, error?:string}>} scanDir + * 扫描目录,返回 {ok:true,entries} 或 {ok:false,error}。 + * Stage 8:必须返回 entries(含文件夹 + 文件),否则非 md 文件变化会漏报。 + * @property {(absDir: string) => string} [toRelDir] + * 可选:把绝对目录路径转成 dataRoot 下的 POSIX 风格相对路径。 + * 没传则 payload 不带 relDir 字段(旧调用方完全兼容)。 + */ + +function createFsWatcher(deps) { + // 解构 + 一次性校验:避免后面每个调用都判 null + const { getMainWindow, resolveDataDir, scanDir, toRelDir } = deps; + if (typeof getMainWindow !== 'function') throw new Error('getMainWindow 必须是函数'); + if (typeof resolveDataDir !== 'function') throw new Error('resolveDataDir 必须是函数'); + if (typeof scanDir !== 'function') throw new Error('scanDir 必须是函数'); + const resolveRelDir = typeof toRelDir === 'function' ? toRelDir : null; + + let dirWatcher = null; + let dirPollTimer = null; + let lastFilesSnapshot = ''; + /** 当前被监听的目录(供轮询暂停/恢复复用,不必重新传参) */ + let watchedDir = null; + let emitDebounceTimer = null; + // audit fix (Round 7 FS-F8):burst 首事件时间戳。持续事件流在 + // burstStartedAt + EMIT_MAX_WAIT_MS 时强制 flush,避免 trailing-only + // debounce 在永不静默的流上不发任何事件。 + let burstStartedAt = 0; + // audit fix (shared-M9):generation token。startWatchingDir / rewatch 每次 + // 切换目录都自增;updateSnapshot 内 await 结束后比对 token: + // - 不匹配 → 这是「上一代目录」的扫描结果,丢弃(既不要写 baseline, + // 也不要 emitFilesChanged —— 否则基线会被旧目录列表覆盖,新目录的 + // 真实首次扫描结果反而被当成「无变化」忽略,造成侧栏与磁盘状态 + // 不一致的幽灵 bug) + // - 匹配 → 当前活跃扫描,正常写 baseline + 推送 + let dirGeneration = 0; + // audit fix (Round 7 FS-F1):错误闩锁。lastErrorKey = error+code+relDir 拼接; + // 同 key 在 ERROR_REPEAT_THROTTLE_MS 内不再重发,新 key 立即重发,扫描恢复 + // (ok=true)时清空。避免「数据目录被删除」类持续错误每 2s 弹一次 toast。 + let lastErrorKey = ''; + let lastErrorAt = 0; + // audit fix (Round 7 FS-F5):重连退避计数。OneDrive / 网盘短时不可用时 + // 重连失败指数退避 5s → 10s → 30s → 60s(封顶),避免「目录长时间不存在」 + // 时每 5s 打一行 warn 的忙循环。挂载成功后 nextBackoffMs 重置回基线。 + let nextBackoffMs = REATTACH_BASE_MS; + // audit fix (Round 7 FS-F7):轮询重入保护。慢盘(OneDrive / SMB)下 + // scanDir 可能 > 2s,setInterval 触发新调用与上一轮 await 并发争写 + // lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。 + // pollInFlight 守卫:上一轮未返回时直接跳过本次。 + let pollInFlight = false; + + function isWindowVisible() { + const win = getMainWindow(); + return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized(); + } + + /** + * 条目列表的稳定指纹:folder 用 name;file 用 name + size + mtime。 + * 只在这一个地方定义,两个调用点(updateSnapshot / emitFilesChanged)共用。 + * @param {Array<{name:string,isFolder?:boolean,size?:number,mtimeMs?:number}>} entries + * @returns {string} + */ + function entriesSnapshot(entries) { + return entries.map((e) => { + if (e.isFolder) return `d:${e.name}`; + return `f:${e.name}|${e.size}|${e.mtimeMs}`; + }).join('\n'); + } + + /** 把绝对目录转成 relDir(payload 字段);内部统一走 resolveRelDir。 */ + function relOf(absDir) { + if (!resolveRelDir || !absDir) return null; + try { + const r = resolveRelDir(absDir); + return typeof r === 'string' ? r : null; + } catch { + return null; + } + } + + /** + * 判断当前是否应该 push 这次错误(基于 lastErrorKey + lastErrorAt 闩锁)。 + * 同 key 在节流窗口内 → 抑制;新 key → 立即放行;恢复 ok → 调用方自动清闩。 + * @param {string} errorKey - "error:code:relDir" 拼接 + * @returns {boolean} + */ + function shouldReportError(errorKey) { + const now = Date.now(); + if (errorKey !== lastErrorKey) return true; + return (now - lastErrorAt) >= ERROR_REPEAT_THROTTLE_MS; + } + + /** 记录错误已上报(写闩锁)。 */ + function recordError(errorKey) { + lastErrorKey = errorKey; + lastErrorAt = Date.now(); + } + + /** 清错误闩锁(扫描恢复 ok 时调用)。 */ + function clearErrorLatch() { + lastErrorKey = ''; + lastErrorAt = 0; + } + + /** + * 安全发送(main-M7:teardown 窗口过程抛 throw → unhandledException)。 + * @param {object} payload + */ + function safeSend(payload) { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + try { + win.webContents.send('files:changed', payload); + } catch (e) { + console.warn('[fs-watcher] send failed:', e && e.message); + } + } + + async function updateSnapshot(dir, skipInitialEmit = false) { + // audit fix (shared-M9):捕获「这一代」的 generation token,await 结束后 + // 与当前 dirGeneration 比对 —— 不一致说明用户在扫描期间又切换了目录, + // 本次结果属于上一代,必须丢弃(不能写 baseline,也不能 push)。 + const myGen = dirGeneration; + let result; + try { + result = await scanDir(dir); + } catch (e) { + // audit fix (Round 7 FS-F16):scanDir 是注入依赖,契约未约定「绝不 + // reject」。catch 兜住,避免 unhandledRejection 杀进程(--unhandled- + // rejections=strict 下直接 fatal)。 + console.warn('[fs-watcher] scanDir 抛出:', e && e.message); + return; + } + if (myGen !== dirGeneration) { + // 切到新目录了:旧扫描结果作废。新目录的扫描正在另一份 updateSnapshot + // 跑着,让它去写基线 / 推送即可。 + return; + } + if (!result.ok) { + // 扫描失败(audit #3 续): + // 以前只是 console.warn + 不更新基线,结果用户看到的是「目录被删了但列表还显示着旧文件」—— + // 误以为数据还在,点了文件得到 FILE_NOT_FOUND 报错。 + // 现在:把错误推给 renderer,让它在保留旧列表的同时显式提示用户 + // ("数据目录不可访问"),并提供一个「重新选择」的入口。 + // 基线故意不更新 —— 恢复访问后下次轮询自然会有新基线。 + console.warn('[fs-watcher] updateSnapshot 失败:', result.error); + if (!skipInitialEmit) { + // audit fix (Round 7 FS-F1):错误闩锁。DATA_DIR_NOT_FOUND / EACCES + // 这类每 2s 复发的错误不再每次都 toast。error+code+relDir 拼接作为 + // 闩锁 key;同 key 30s 内只发一次,新 key 立即发,恢复 ok 时清空。 + // audit fix (Round 7 FS-F13):relDir 用本次扫描的 dir(不是 + // currentRelDir()/watchedDir)—— generation 语义上「本次扫描属于 + // 哪个 dir」应该跟着参数走。 + const relDir = relOf(dir); + const errorKey = `${result.error || ''}:${result.code || ''}:${relDir || ''}`; + if (shouldReportError(errorKey)) { + recordError(errorKey); + safeSend({ + error: result.error, + code: result.code, + dir, + relDir, + }); + } + } + return; + } + // 恢复 ok → 清错误闩锁 + clearErrorLatch(); + const list = result.entries || []; + const snapshot = entriesSnapshot(list); + if (snapshot !== lastFilesSnapshot) { + lastFilesSnapshot = snapshot; + if (!skipInitialEmit) { + emitFilesChanged(list, dir); + } + } + } + + /** + * 向 renderer 推送文件列表。 + * + * @param {Array=} precomputed - 调用方已经扫描好的列表(updateSnapshot 会传), + * 传了就不再重复 scanDir。 + * @param {string=} dir - 列表对应的目录绝对路径;不传则用 watchedDir。 + * + * 注意:走「自己扫描」分支时必须同步 lastFilesSnapshot 基线。否则 fs.watch 触发的 + * 这次推送不会更新基线,紧随其后的 2 秒轮询会认为列表「又变了」,导致每次外部改动 + * 都发生一次重复扫描 + 重复 IPC 推送。 + * + * Stage 8:payload 新增 relDir 字段,告诉渲染端「这是哪个目录的变更」, + * 渲染端比对当前显示目录决定是否重扫。 + * + * audit fix (Round 7 FS-F11):payload 同时带 `dir` 字段(绝对路径)与 + * `relDir`(POSIX 相对路径),与 file:scan-dir handler 的返回形状对齐, + * 方便 renderer 兜底逻辑无需依赖 IPC 调用上下文。 + */ + async function emitFilesChanged(precomputed, dir) { + const targetDir = dir || watchedDir || resolveDataDir(); + // audit fix (shared-M9):同样的 generation 防护。emitFilesChanged 自带 + // scanDir 分支时同样有「scan 期间用户切目录 → 旧结果覆盖新基线」的竞态。 + const myGen = dirGeneration; + let list; + if (precomputed) { + list = precomputed; + } else { + let result; + try { + result = await scanDir(targetDir); + } catch (e) { + // audit fix (Round 7 FS-F16):floating promise 兜底。 + console.warn('[fs-watcher] emitFilesChanged scanDir 抛出:', e && e.message); + return; + } + if (myGen !== dirGeneration) return; + if (!result.ok) { + // 推送错误而不是清空列表 —— 临时错误不应让用户失去对已有文件的视图。 + // 渲染端会保留上次的列表,仅显示错误提示。 + // audit fix (Round 7 FS-F12):与 updateSnapshot 错误分支对齐, + // payload 同时带 code 字段。 + const relDir = relOf(targetDir); + const errorKey = `${result.error || ''}:${result.code || ''}:${relDir || ''}`; + if (shouldReportError(errorKey)) { + recordError(errorKey); + safeSend({ + error: result.error, + code: result.code, + dir: targetDir, + relDir, + }); + } + return; + } + list = result.entries || []; + // 同步基线,避免轮询把这次改动再报一遍 + lastFilesSnapshot = entriesSnapshot(list); + } + if (myGen !== dirGeneration) return; + // 成功路径同样补 dir 字段(FS-F11)。 + safeSend({ entries: list, dir: targetDir, relDir: relOf(targetDir) }); + } + + function scheduleEmit() { + const now = Date.now(); + // audit fix (Round 7 FS-F8):maxWait —— 持续事件流(git checkout / + // 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。 + // burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。 + if (!burstStartedAt) burstStartedAt = now; + const elapsed = now - burstStartedAt; + const delay = elapsed >= EMIT_MAX_WAIT_MS ? 0 : EMIT_DEBOUNCE_MS; + if (emitDebounceTimer) clearTimeout(emitDebounceTimer); + // audit fix (main-M6):闭包里捕获触发本次 scheduleEmit 时的 watchedDir, + // 300ms 后即便用户改了 dataDir(或 Folder Browser 切了子目录), + // emitFilesChanged 仍按触发时的 dir 推送 + payload.relDir 与之对齐。 + // 否则事件会被「错路由」到新目录的 renderer 视图,造成侧栏幽灵更新。 + const dirAtSchedule = watchedDir; + const t = setTimeout(() => { + emitDebounceTimer = null; + burstStartedAt = 0; + emitFilesChanged(undefined, dirAtSchedule); + }, delay); + // audit fix (main-M8):unref —— 否则 fs-watcher 还在挂定时器时 Electron + // 主进程不会自然退出(before-quit 取消 close path 后这 300ms 会再卡一下)。 + if (typeof t.unref === 'function') t.unref(); + emitDebounceTimer = t; + } + + // audit fix:fs.watch emit error 后退避重连。 + // 用户场景:OneDrive 暂时离线 → 网盘驱动报 EPERM → fs.watch 死掉; + // 5s 后重连,若目录又可访问就恢复事件推送。退避期间 2s 轮询仍兜底。 + // audit fix (Round 7 FS-F5):指数退避 5s → 10s → 30s → 60s(封顶), + // 长期不存在的目录不再每 5s 打一行 warn。成功挂载后 nextBackoffMs 重置。 + let reattachTimer = null; + function scheduleReattach() { + if (reattachTimer || !watchedDir) return; + const delay = nextBackoffMs; + const t = setTimeout(() => { + reattachTimer = null; + if (!watchedDir) return; + // startWatchingDir 内部 stopWatchingDir 会先关旧句柄,再开新句柄; + // 即使同名目录也会重新挂 error handler。失败时 startWatchingDir 自己 + // console.warn,仍依赖轮询兜底。 + try { + startWatchingDir(watchedDir); + // 挂载成功:重置退避计数,给未来新错误回到基线 5s。 + nextBackoffMs = REATTACH_BASE_MS; + } catch (e) { + console.warn(`[fs-watcher] 重连失败(${delay}ms 后再试):`, e.message); + // 失败:指数退避,封顶 60s。 + nextBackoffMs = Math.min(nextBackoffMs * 2, REATTACH_MAX_MS); + scheduleReattach(); + } + }, delay); + // audit fix (main-M8):unref —— 5s 重连定时器若还挂着会拖住进程退出。 + if (typeof t.unref === 'function') t.unref(); + reattachTimer = t; + } + + function startDirPoll() { + if (dirPollTimer || !watchedDir) return; + // audit fix (Round 7 FS-F14):unref —— 这个 timer 是长期存活的,叠加 + // will-quit 不清 fsWatcher 会在退出路径上拖住主进程。 + dirPollTimer = setInterval(() => { + // audit fix (Round 7 FS-F7):重入保护。慢盘(OneDrive / SMB)上 + // scanDir 可能 > 2s,setInterval 触发新一轮与上一轮 await 并发, + // 争写 lastFilesSnapshot → 后完成的可能是先发起的(旧覆盖新)。 + if (pollInFlight) return; + pollInFlight = true; + updateSnapshot(watchedDir) + .catch((e) => { + // audit fix (Round 7 FS-F16):floating promise 兜底。 + console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message); + }) + .finally(() => { + pollInFlight = false; + }); + }, DIR_POLL_INTERVAL_MS); + if (typeof dirPollTimer.unref === 'function') dirPollTimer.unref(); + } + + function stopDirPoll() { + if (dirPollTimer) { + clearInterval(dirPollTimer); + dirPollTimer = null; + } + } + + /** + * 启动目录监听: + * - fs.watch: 立即触发,但 Windows / 云同步盘 / 网络盘可能不发事件 + * - readdir 轮询: 兜底(每 2 秒) + * - 任一通道发现列表变化 → 通过 IPC 'files:changed' 推送新列表给 renderer + * + * Stage 8:监听的目录可以是 dataRoot 本身,也可以是其下的子目录(Folder Browser 导航时 + * 通过 rewatch() 切换)。监听目标改变时,fs.watch 句柄会先 close 再在新目录上重新打开。 + */ + function startWatchingDir(dir) { + stopWatchingDir(); + // audit fix (shared-M9):新一世代,旧扫描全部作废(见 updateSnapshot + // / emitFilesChanged 的 myGen 校验)。 + dirGeneration += 1; + watchedDir = dir; + + // 初始化基线(启动时不把历史文件当作「变化」) + // 第三个参数 skipInitialEmit = true:仅同步基线,不向 renderer 推送启动时的文件列表 + // (renderer 会通过 ipc 'file:list' / 'file:scan-dir' 自己拉取,避免重复更新) + updateSnapshot(dir, true); + + try { + dirWatcher = fsSync.watch(dir, (eventType, filename) => { + // 不再按扩展名过滤 —— Stage 8 后列表里包含文件夹 + 各种扩展名, + // 任何变化都可能影响侧栏展示,让轮询做最终判定 + // audit fix (Round 7 FS-F6):Windows ReadDirectoryChangesW 缓冲区 + // 溢出时 Node 用 filename=null 上报(旧版当成噪声直接 return, + // 等于在最需要重扫的时刻跳过重扫)。批量操作(git checkout / + // 解压 / AI 批量写)正好触发这个路径 —— 必须 scheduleEmit 触发重扫。 + if (!filename) { + scheduleEmit(); + return; + } + // Windows 上可能触发多次事件,加 300ms 防抖合并 + scheduleEmit(); + }); + // audit fix:监听底层错误。 + // 之前 fs.watch 句柄没有 .on('error') —— 一旦 watchedDir 在外部被 + // 删除 / 重命名 / 所在盘符消失,fs.watch 会 emit 'error',没人接, + // 默认变 unhandledException;poll 循环还在继续扫这个不存在的目录, + // 每次 updateSnapshot 都返回 ENOENT,但 fs.watch 已经死了, + // 用户对数据目录的任何后续修改都收不到事件(只能靠 2s 轮询)。 + // 现在挂上 error handler:打日志 + 触发一次重新监听(5s 退避), + // 让用户重命名 / 重新挂载盘后 fs.watch 自动恢复。 + dirWatcher.on('error', (err) => { + console.warn('[fs-watcher] fs.watch 报错,重连退避中:', err.message); + scheduleReattach(); + }); + } catch (e) { + // audit fix (Round 7 FS-F5):startWatchingDir 内 fs.watch 同步抛 + // ENOENT/EACCES/EPERM 时,dirWatcher=null,没有 error handler 可挂, + // scheduleReattach 永远不会再次触发。手动 schedule 一次,下一次轮询 + // 失败 + fs.watch 仍没恢复也会被 scheduleReattach 自循环退避接管。 + console.warn('[fs-watcher] 目录监听启动失败(仅依赖轮询):', e.message); + scheduleReattach(); + } + + // 窗口不可见时不必开轮询(会在 show/restore 时恢复) + if (isWindowVisible()) startDirPoll(); + } + + /** + * 切换监听目标到另一个子目录(Folder Browser 用)。 + * + * 与 startWatchingDir 的区别:startWatchingDir 内部先 stopWatchingDir(清基线),所以 + * 即使新旧目录完全相同也会重置基线;rewatch 同名目录直接 no-op,省一次基线重置。 + * + * @param {string} dir - 新的绝对目录 + */ + function rewatch(dir) { + if (!dir || typeof dir !== 'string') return; + if (dir === watchedDir) return; + startWatchingDir(dir); + } + + /** + * 窗口最小化 / 收进托盘时暂停轮询 —— 没人在看文件列表,每 2 秒一次 + * readdir + N 次 stat 是纯浪费(在 OneDrive / 坚果云这类同步盘上尤其贵)。 + * + * fs.watch 故意保持运行:它是事件驱动的,开着几乎不花钱,隐藏期间的改动仍能捕获。 + */ + function pauseDirPolling() { + stopDirPoll(); + } + + /** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */ + function resumeDirPolling() { + if (!watchedDir) return; + startDirPoll(); + updateSnapshot(watchedDir); + } + + function stopWatchingDir() { + // audit fix (Round 7 FS-F9):自增 dirGeneration,让在飞的 + // updateSnapshot / emitFilesChanged await 返回后 myGen 校验失败、 + // 不会回写刚清空的 baseline。 + dirGeneration += 1; + if (emitDebounceTimer) { + clearTimeout(emitDebounceTimer); + emitDebounceTimer = null; + } + burstStartedAt = 0; + if (reattachTimer) { + clearTimeout(reattachTimer); + reattachTimer = null; + } + if (dirWatcher) { + try { dirWatcher.close(); } catch {} + dirWatcher = null; + } + stopDirPoll(); + // audit fix (Round 7 FS-F5):停止监听也重置退避计数,避免下次启动 + // 接着用上一会话的指数退避值。 + nextBackoffMs = REATTACH_BASE_MS; + clearErrorLatch(); + pollInFlight = false; + watchedDir = null; + lastFilesSnapshot = ''; + } + + return { + startWatchingDir, + rewatch, + pauseDirPolling, + resumeDirPolling, + stopWatchingDir, + /** 测试 / 调试用:当前是否在监听某个目录 */ + get watchedDir() { return watchedDir; }, + }; +} + +module.exports = { + createFsWatcher, + // DIR_POLL_INTERVAL_MS 故意不出 module.exports:只是模块内 setTimeout + // 的常量(兜底 fs.watch 不稳的场景),无任何外部调用方 —— 出 exports + // 会让上层误以为「可以调整轮询频率」,徒增 API surface 风险。 +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ba3ad4a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6998 @@ +{ + "name": "notes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notes", + "version": "1.0.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.28.0", + "codemirror": "^6.0.2", + "dompurify": "^3.1.0", + "marked": "^12.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "electron": "^44.0.0", + "electron-builder": "^26.15.3", + "eslint": "^9.39.5", + "jsdom": "^25.0.1", + "patch-package": "^8.0.1", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=20.18.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", + "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", + "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.9", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz", + "integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz", + "integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", + "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.34.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.34.0.tgz", + "integrity": "sha512-4Oy5Q6/Ftna9sXyrkdnKypfvm9uWRpxUPvlw4oA192QNMN39aq8k4l36TUUUU/ONw7ivGVi402Ud+UBPVDYh6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9cbbaff --- /dev/null +++ b/package.json @@ -0,0 +1,106 @@ +{ + "name": "notes", + "version": "1.0.0", + "description": "简洁的 Markdown 阅读器 - Markdown 文件与界面始终一致", + "main": "main.js", + "scripts": { + "start": "node scripts/launch.js", + "start:raw": "electron .", + "lint": "eslint .", + "check": "node scripts/check-syntax.js && node scripts/check-ipc.js", + "test": "vitest run", + "test:watch": "vitest", + "postinstall": "patch-package", + "build": "electron-builder", + "build:win": "electron-builder --win", + "build:mac": "electron-builder --mac", + "build:linux": "electron-builder --linux" + }, + "keywords": [ + "notes", + "markdown", + "reader", + "electron", + "desktop" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.28.0", + "codemirror": "^6.0.2", + "dompurify": "^3.1.0", + "marked": "^12.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "electron": "^44.0.0", + "electron-builder": "^26.15.3", + "eslint": "^9.39.5", + "jsdom": "^25.0.1", + "patch-package": "^8.0.1", + "vitest": "^2.1.9" + }, + "build": { + "appId": "com.notes.app", + "productName": "Notes", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "main/**/*", + "preload.js", + "index.html", + "src/**/*", + "shared/**/*", + "icon.ico", + "data/**/*", + "package.json" + ], + "win": { + "icon": "icon.ico", + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "installerIcon": "icon.ico", + "uninstallerIcon": "icon.ico" + }, + "mac": { + "target": "dir", + "category": "public.app-category.productivity" + }, + "linux": { + "target": "AppImage", + "category": "Office", + "icon": "icon.ico" + } + }, + "engines": { + "node": ">=20.18.0" + }, + "overrides": { + "@noble/hashes": "1.4.0" + }, + "config": { + "electron_mirror": "https://registry.npmmirror.com/-/binary/electron/" + }, + "allowScripts": { + "electron@44.0.0": true, + "electron-winstaller@5.4.0": true, + "esbuild@0.21.5": true + } +} diff --git a/patches/app-builder-lib+26.15.3.patch b/patches/app-builder-lib+26.15.3.patch new file mode 100644 index 0000000..6172d49 --- /dev/null +++ b/patches/app-builder-lib+26.15.3.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js b/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js +index 2492bc7..6587e60 100644 +--- a/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js ++++ b/node_modules/app-builder-lib/out/targets/blockmap/blockmap.js +@@ -5,7 +5,7 @@ const crypto_1 = require("crypto"); + const fs_1 = require("fs"); + const promises_1 = require("fs/promises"); + const zlib = require("zlib"); +-const blake2_js_1 = require("@noble/hashes/blake2.js"); ++const blake2_js_1 = require("@noble/hashes/blake2b"); + // Rabin fingerprinting constants from github.com/aclements/go-rabin: + // Poly64 = 0xbfe6b8a5bf378d83 (degree-63 irreducible polynomial over GF(2)) + // Window = 64 bytes, Avg = 16 KB, Min = 8 KB, Max = 32 KB diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..ec4d1ec --- /dev/null +++ b/preload.js @@ -0,0 +1,415 @@ +// 安全的 IPC 桥 +// 通过 contextBridge 暴露最小化的 API 给渲染进程 +// +// marked 和 DOMPurify 是从主进程 require 进来再暴露给渲染端使用。 +// 这是因为 Electron 渲染进程在 contextIsolation 下无法直接用 `import 'marked'` +// 这种 bare specifier(需要打包工具 + import map),而我们坚持零打包。 +// +// 关于 DOMPurify 初始化: +// DOMPurify 需要一个 window/document 才能工作。Preload 脚本运行在隔离的 +// JS context 中(contextIsolation),但同一进程里的 window 对象已经存在。 +// 我们在 preload 中用 `createDOMPurify(window)` 初始化,它返回的实例可以 +// 把 sanitize 后的 HTML 字符串通过 contextBridge 暴露给渲染端 —— +// 返回的是字符串而非 DOM 节点,跨 contextBridge 没问题。 +// +// IPC 通道配对由 scripts/check-ipc.js 自动审计(`npm run check`)。 +// 新增 API 前先在 main.js 写好 ipcMain.handle / ipcMain.on / webContents.send, +// 再回到 preload 加 wrapper;脚本会校验通道双向配对。 + +const { contextBridge, ipcRenderer, nativeImage } = require('electron'); +const { marked } = require('marked'); +const nodePath = require('path'); +const nodeFs = require('fs'); + +// 把 icon.ico 在 preload 启动时一次性转成 PNG data URL,渲染端作为 用。 +// nativeImage 直接读 .ico 会拿到 Windows 编码, 不一定能渲染;toDataURL() +// 输出标准 PNG data URL,浏览器 100% 兼容。文件路径在 build.files 里已经包含, +// 打包后路径仍指向资源目录。读不到 → 返回 null,渲染端继续走 .app-title::before 占位。 +let APP_ICON_DATA_URL = null; +try { + const iconPath = nodePath.join(__dirname, 'icon.ico'); + if (nodeFs.existsSync(iconPath)) { + const img = nativeImage.createFromPath(iconPath); + if (img && !img.isEmpty()) { + APP_ICON_DATA_URL = img.toDataURL(); + } + } +} catch { + // nativeImage 在某些 headless 测试 / 非 Electron 上下文里会失败;吞掉走 null 路径 +} +const createDOMPurify = require('dompurify'); +// 设置 schema(单一事实源)。preload 是 CJS,可以直接 require。 +// +// renderer 不能自己 import 这个文件 —— 它是 CommonJS(module.exports),而 +// renderer 跑在 Chromium 里(nodeIntegration:false),浏览器原生 ESM 没有任何 +// CommonJS 互操作:改扩展名(.cjs → .js)只能让 ESM loader 不因 MIME 拒绝它, +// 模块本身依然一个具名导出都没有,import 会在链接阶段直接抛 +// "does not provide an export named 'DEFAULT_SETTINGS'" 并让整个应用起不来。 +// 所以 schema 必须和其他主进程能力一样,经 contextBridge 过桥。 +const settingsSchema = require('./shared/settings-schema.js'); +// Markdown → 安全 HTML 的核心规则(ALLOWED_URI_REGEXP + 钩子)。与 tests 共用同一份, +// 防止规则在「实现」与「测试」之间漂移。 +const { ALLOWED_URI_REGEXP, installHooks } = require('./shared/render-sanitize.js'); +// 文件扩展名白名单(EDITABLE_EXTS / MARKDOWN_EXTS)—— main + renderer 共用一份, +// 防止「侧栏显示可编辑但 md 链接打不开」或反之的体验割裂。renderer 经 contextBridge +// 拿数组(contextBridge 结构化克隆对 Set/Map 的支持视 Electron 版本而定,数组 +// 是 100% 可靠的形态,渲染端直接 Array.includes 即可)。 +const { EDITABLE_EXTS, MARKDOWN_EXTS } = require('./shared/extension-lists.js'); +const EDITABLE_EXTS_LIST = Array.from(EDITABLE_EXTS); +const MARKDOWN_EXTS_LIST = Array.from(MARKDOWN_EXTS); +// Heading slug 算法(preload + 锚点滚动共用同一份,否则点击文内锚点会定位失败)。 +// 实现见 shared/slug.js;renderer 端通过 window.api.slugifyHeadingBase 调用同一份, +// 不再源码级镜像,避免两边正则漂移(audit fix 3.1)。 +const { slugifyHeading: sharedSlugify, slugifyHeadingBase: sharedSlugifyBase } = require('./shared/slug.js'); +// AI 修改用的行级 + 词级 diff 算法。renderer 是 ESM + Chromium 原生,不能 +// 直接 require CJS,所以经 contextBridge 暴露成纯函数(返回纯对象,结构化克隆 OK)。 +const markdownDiff = require('./shared/markdown-diff.js'); +// AI 错误码字面量(main / renderer 共享)—— renderer 是 ESM 不能直接 require CJS, +// 所以经 contextBridge 把整个 AI_ERROR 对象暴露到 window.api.aiErrors, +// renderer 在 src/ai/ai-status.js 顶层直接 window.api?.aiErrors?.AI_ERROR 取值。 +const { AI_ERROR: sharedAiErrors } = require('./shared/ai-errors.js'); +// errno → 中文提示(main + renderer 共享)—— 同一理由经 contextBridge 过桥, +// 详见 shared/friendly-fs-error.js 注释。renderer 在 src/app.js / src/file-ops.js +// 顶层取 window.api.friendlyFsError 调用,不再各自维护 mapping(避免 EROFS / +// ENAMETOOLONG / ENOTDIR / ENOTEMPTY 三处文案漂移)。 +const { friendlyFsError: sharedFriendlyFsError } = require('./shared/friendly-fs-error.js'); + +// 兜底 beforeunload 清理:单条 IPC 通道一个 helper,pagehide 未跑时再拆 listener。 +// +// 【必须在模块顶层定义,不能放成 contextBridge 对象的方法】 +// contextBridge 暴露的 on* wrapper 都是箭头函数,箭头函数没有自己的 this, +// 它们的 this 来自词法作用域 = 模块顶层(Node CJS 里是 module.exports, +// 不是 contextBridge 的 API 对象)。如果 cleanupOn 是 API 对象的方法, +// 下面的 onXxx 调 this.cleanupOn(...) 会拿不到函数,渲染端 bootstrap 在 +// 第一个 subscribeIpc 调用就会抛「this.cleanupOn is not a function」直接挂掉。 +// 抽到模块顶层用普通函数声明,下面 on* 直接 cleanupOn(...) 调用。 +// +// renderer 端 subscribeIpc 仍然有 pagehide 主清理路径,这里只是 pagehide +// 顺序漂移时的兜底。 +function cleanupOn(channel, handler) { + const beforeUnload = () => { + try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ } + try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ } + }; + try { window.addEventListener('beforeunload', beforeUnload, { once: true }); } catch { /* ignore */ } + return () => { + try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ } + try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ } + }; +} + +// 配置 marked —— 在 preload 一次性完成 +const mdRenderer = new marked.Renderer(); +const baseLink = mdRenderer.link.bind(mdRenderer); +mdRenderer.link = (href, title, text) => { + const html = baseLink(href, title, text); + return html.replace(/^ + `${text}\n`; + +marked.setOptions({ + gfm: true, + breaks: false, + renderer: mdRenderer, +}); + +// 拿到工厂函数(兼容 ESM/CJS 互操作差异) +const DOMPurifyFactory = (typeof createDOMPurify === 'function') + ? createDOMPurify + : (createDOMPurify && typeof createDOMPurify.default === 'function') + ? createDOMPurify.default + : null; + +// DOMPurify 实例(绑定到 preload 的 window) +let DOMPurifyInstance = null; +if (DOMPurifyFactory && typeof window !== 'undefined') { + try { + DOMPurifyInstance = DOMPurifyFactory(window); + } catch (e) { + console.error('[preload] DOMPurify 初始化失败:', e); + } +} else if (!DOMPurifyFactory) { + console.error('[preload] createDOMPurify 不可用'); +} + +if (!DOMPurifyInstance) { + console.warn('[preload] DOMPurify 未初始化;renderMarkdown 将跳过 XSS 清洗'); +} + +// 允许的 URI 协议与 DOMPurify 钩子见 shared/render-sanitize.js。 +// 顶部 require 已引入 ALLOWED_URI_REGEXP 与 installHooks;钩子在拿到 DOMPurify +// 实例后立即注册。 +installHooks(DOMPurifyInstance); + +contextBridge.exposeInMainWorld('api', { + /** + * 设置 schema —— 纯数据,供 renderer 同步读取。 + * + * preload 在 renderer 模块求值之前就跑完了,所以 settings-store.js / + * settings-dialog.js 可以在模块顶层直接取 window.api.settingsSchema。 + * contextBridge 会做结构化克隆,renderer 拿到的是只读副本。 + */ + settingsSchema: { + DEFAULT_SETTINGS: settingsSchema.DEFAULT_SETTINGS, + SETTINGS_UI_OPTIONS: settingsSchema.SETTINGS_UI_OPTIONS, + }, + + /** + * AI 错误码表 —— 与 main/ai.js 同一份字面量(shared/ai-errors.js), + * 经 contextBridge 过桥后 renderer 端不会与主进程漂移。纯数据对象。 + */ + aiErrors: { + AI_ERROR: sharedAiErrors, + }, + + /** + * errno → 中文提示(Round 4 收尾:合并三处独立 mapping)。 + * 与 main/file-ops.js#friendlyWriteError 同一份事实源(shared/friendly-fs-error.js), + * renderer 端友好提示文案不再与主进程漂移。 + * + * @param {string|null|undefined} code - errno 或业务码 + * @param {string|undefined} fallback - 未知 code 时的回退文案 + * @returns {string} + */ + friendlyFsError: (code, fallback) => sharedFriendlyFsError(code, fallback), + + /** + * 用 schema 把任意对象归一成完整 settings(补默认值 + 丢未知键)。 + * @param {*} raw + * @returns {Record} + */ + coerceLoadedSettings: (raw) => settingsSchema.coerceLoadedSettings(raw), + + /** + * Markdown → 安全 HTML + * @param {string} markdown + * @returns {string} + */ + renderMarkdown: (markdown) => { + if (typeof markdown !== 'string' || !markdown) return ''; + try { + // 同名标题的去重后缀按文档计数,每次 parse 前重置 + headingSlugs.clear(); + const rawHtml = marked.parse(markdown); + // 【fail-closed】DOMPurify 不可用 → 返回转义后的纯文本,绝不返回未清洗 HTML + // 否则 attacker 写入 "" 就能 + // 借 viewer.innerHTML 拿到 privileged API 调用权(preload 通过 contextBridge + // 暴露的 api 在 renderer 同源策略下被认为是同源可执行)。 + if (!DOMPurifyInstance) { + const escaped = String(rawHtml) + .replace(/&/g, '&') + .replace(//g, '>'); + return `
${escaped}
`; + } + return DOMPurifyInstance.sanitize(rawHtml, { + ADD_ATTR: ['target', 'rel', 'id'], + ALLOWED_URI_REGEXP, + // uponSanitizeAttribute 钩子在文件顶部通过 addHook 全局注册 + // (v3 不再支持 sanitize 配置传 hook),按 tag 白名单收口。 + // audit fix (Round 13 / Sec-M): + // - 拒绝 / / 显示在自定义 + * 标题栏左侧。文件缺失 / nativeImage 加载失败时返回 null,渲染端继续用 + * .app-title::before 占位(保持现有视觉)。 + * @returns {string|null} + */ + getAppIconDataUrl: () => APP_ICON_DATA_URL, +}); diff --git a/scripts/check-ipc.js b/scripts/check-ipc.js new file mode 100644 index 0000000..3171803 --- /dev/null +++ b/scripts/check-ipc.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node +// IPC 通道配对审计(取代 preload.js 旧的那 45 行手工注释) +// +// 规则: +// 1. renderer → main(invoke / send) +// preload.js 中 `ipcRenderer.invoke('foo')` 必须有 main.js 中 `ipcMain.handle('foo', ...)` 配对。 +// `ipcRenderer.send('foo')` 必须有 `ipcMain.on('foo', ...)` 或 `ipcMain.once('foo', ...)` 配对。 +// 2. main → renderer(推送) +// main.js 中 `webContents.send('foo')` 必须有 preload.js 中 `ipcRenderer.on('foo', ...)` 配对。 +// "on" 订阅也可以走 `onXxx` 高阶函数(事件名出现在数组/字符串字面量里)—— 用宽松匹配。 +// 3. 动态通道名(如 renderer:save-result:${reqId})按"前缀"匹配:发起方提供完整字面量, +// 接收方按前缀 ipcMain.on(`renderer:save-result:${...}`) 即可。 +// +// 输出:失败时返回非零退出码 + 把缺漏通道写到 stderr。 + +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const PRELOAD = path.join(ROOT, 'preload.js'); +const MAIN = path.join(ROOT, 'main.js'); +const MAIN_DIR = path.join(ROOT, 'main'); + +/** + * 递归收集目录下所有 .js 文件(排除 node_modules / dist 等)。 + * @param {string} dir + * @returns {string[]} + */ +function walkJs(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (ent.name === 'node_modules' || ent.name === 'dist') continue; + out.push(...walkJs(p)); + } else if (ent.isFile() && ent.name.endsWith('.js')) { + out.push(p); + } + } + return out; +} + +/** + * 读文件,返回内容。 + * @param {string} p + */ +function read(p) { + return fs.readFileSync(p, 'utf8'); +} + +/** + * 提取引号字符串字面量(单引号 / 双引号 / 反引号)。 + * @param {string} src + * @returns {string[]} + */ +function extractStringLiterals(src) { + /** @type {string[]} */ + const out = []; + // 单/双引号 + const re1 = /(['"])((?:\\.|(?!\1).)*)\1/g; + let m; + while ((m = re1.exec(src))) out.push(m[2]); + // 模板字符串(无 ${}) — 不展开 + const re2 = /`([^`\\]*(?:\\.[^`\\]*)*)`/g; + while ((m = re2.exec(src))) { + // 如果包含 ${...},整段作为"模板前缀" + const tpl = m[1]; + if (tpl.includes('${')) { + // 取 ${ 之前的字面前缀 + const idx = tpl.indexOf('${'); + out.push(tpl.slice(0, idx) + '${...}'); + } else { + out.push(tpl); + } + } + return out; +} + +/** + * 从文件中抽取 ipcRenderer.{invoke,send,on} 的通道名。 + * @param {string} src + * @param {RegExp} headRe 形如 /ipcRenderer\.(invoke|send|on)\(\s*(['"`])/g + * @returns {string[]} 通道字面量 + */ +function extractIpcCalls(src, headRe) { + /** @type {string[]} */ + const out = []; + for (const m of src.matchAll(headRe)) { + const quote = m[1]; + const start = m.index + m[0].length; + const end = src.indexOf(quote, start); + if (end === -1) continue; + let literal = src.slice(start, end); + if (literal.includes('${')) { + const idx = literal.indexOf('${'); + literal = literal.slice(0, idx) + '${...}'; + } + out.push(literal); + } + return out; +} + +/** + * 给定发送方通道集合 + 接收方"模式"集合(字面量 + 通配前缀),找出漏配的通道。 + * 接收方可以是字面量或 prefix${...},prefix 部分前缀匹配即可。 + * + * @param {string[]} sent + * @param {string[]} received + * @returns {string[]} 漏配(按 sent 顺序) + */ +/** + * 收集 `const X = '...' | \`...\`` 形式的字符串字面量赋值(包括带 ${...} 的模板)。 + * 仅在脚本作用域内查找(不在函数体内更精确,但 main.js 顶层都在 module 作用域,问题不大)。 + * @param {string} src + * @returns {Map} varName → 字面量 + */ +function collectStringVarAssignments(src) { + /** @type {Map} */ + const map = new Map(); + const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([`'"])((?:\\.|(?!\2).)*)\2/g; + let m; + while ((m = re.exec(src))) { + const name = m[1]; + let literal = m[3]; + if (literal.includes('${')) { + const idx = literal.indexOf('${'); + literal = literal.slice(0, idx) + '${...}'; + } + map.set(name, literal); + } + return map; +} + +/** + * 提取 ipcMain.once(VAR, ...) / webContents.send(VAR, ...) 中的字面量(用变量映射)。 + * @param {string} src + * @param {RegExp} varCallRe + * @param {Map} varMap + * @returns {string[]} + */ +function extractIpcCallsByVar(src, varCallRe, varMap) { + /** @type {string[]} */ + const out = []; + for (const m of src.matchAll(varCallRe)) { + const varName = m[1]; + const lit = varMap.get(varName); + if (lit) out.push(lit); + } + return out; +} + +function diffMissing(sent, received) { + return sent.filter((s) => { + if (!s) return false; + if (received.includes(s)) return false; + // 动态模板:sent 是字面,received 是前缀${...}?两边都可能是字面或模板 + for (const r of received) { + if (r === s) return false; + // 一边是 "foo:${...}" 另一边是 "foo:bar" → 视为配对(只要前缀对得上) + if (r.endsWith('${...}') && s.startsWith(r.slice(0, -6))) return false; + if (s.endsWith('${...}') && r.startsWith(s.slice(0, -6))) return false; + } + return true; + }); +} + +function main() { + const preloadSrc = read(PRELOAD); + const mainSrc = read(MAIN); + // audit fix (Round 8 R-2):合并 main.js + main/**/*.js。 + // 旧实现只读 main.js,主进程拆出来的子文件(main/ai.js / main/file-ops.js / + // main/fs-watcher.js / main/config-store.js)里的 ipcMain.handle / webContents.send + // 完全不在审计图里 —— `files:changed`(main/fs-watcher.js:149)这种动态事件名 + // 容易因重构漏配对。 + const mainSubSrc = walkJs(MAIN_DIR).map(read).join('\n\n'); + const combinedMainSrc = mainSrc + '\n\n' + mainSubSrc; + + // preload.js:ipcRenderer.invoke / send / on + const invokeRe = /ipcRenderer\.invoke\(\s*([`'"])/g; + const sendRe = /ipcRenderer\.send\(\s*([`'"])/g; + const onRe = /ipcRenderer\.on\(\s*([`'"])/g; + + const preloadInvoke = extractIpcCalls(preloadSrc, invokeRe); + const preloadSend = extractIpcCalls(preloadSrc, sendRe); + const preloadOn = extractIpcCalls(preloadSrc, onRe); + + // 订阅也可能在 onMenuCommand([...]) 的字符串数组里 — 简单提取那些 ['menu:foo', 'menu:bar'] 数组 + const preloadArrOn = []; + const arrRe = /\[\s*((?:['"][\w:.-]+['"]\s*,\s*)+['"][\w:.-]+['"])\s*\]/g; + let am; + while ((am = arrRe.exec(preloadSrc))) { + for (const lit of extractStringLiterals(am[1])) { + if (lit.includes(':') && !lit.includes(' ')) preloadArrOn.push(lit); + } + } + + // main.js + main/**/*.js:ipcMain.handle / on / once + webContents.send + // 同时识别用变量中转的动态通道:先收集 const X = `prefix:${...}` 这种声明, + // 再把 ipcMain.once(channel, ...) / webContents.send(channel, ...) 里 channel 替换为它的字面量。 + const stringVarLiterals = collectStringVarAssignments(combinedMainSrc); + + const handleRe = /ipcMain\.handle\(\s*([`'"])/g; + const onMainRe = /ipcMain\.on\(\s*([`'"])/g; + const onceMainRe = /ipcMain\.once\(\s*([`'"])/g; + const sendFromMain = extractIpcCalls(combinedMainSrc, handleRe); + const onMain = extractIpcCalls(combinedMainSrc, onMainRe).concat(extractIpcCalls(combinedMainSrc, onceMainRe)); + + // 变量形式:ipcMain.once(channel, ...) —— channel 来自 stringVarLiterals + const onMainByVar = extractIpcCallsByVar(combinedMainSrc, /ipcMain\.(?:on|once)\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals); + const handleByVar = extractIpcCallsByVar(combinedMainSrc, /ipcMain\.handle\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals); + onMain.push(...onMainByVar); + sendFromMain.push(...handleByVar); + + // webContents.send(...) 的通道 + const wcSendRe = /webContents\.send\(\s*([`'"])/g; + const webContentsSend = extractIpcCalls(combinedMainSrc, wcSendRe); + // 变量形式 + const wcSendByVar = extractIpcCallsByVar(combinedMainSrc, /webContents\.send\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals); + webContentsSend.push(...wcSendByVar); + + const errors = []; + + // 1. renderer→main invoke 必须配 main.handle + const missingForInvoke = diffMissing(preloadInvoke, sendFromMain); + if (missingForInvoke.length) { + errors.push(`renderer→main invoke 缺 main.handle 配对:\n ${missingForInvoke.join('\n ')}`); + } + + // 2. renderer→main send 必须配 main.on/once + const missingForSend = diffMissing(preloadSend, onMain); + if (missingForSend.length) { + errors.push(`renderer→main send 缺 main.on/once 配对:\n ${missingForSend.join('\n ')}`); + } + + // 3. main→renderer webContents.send 必须配 preload.on (或 onXxx 数组) + const preloadSubscribed = new Set([...preloadOn, ...preloadArrOn]); + const missingForWcSend = diffMissing(webContentsSend, [...preloadSubscribed]); + if (missingForWcSend.length) { + errors.push(`main→renderer send 缺 preload.on 订阅:\n ${missingForWcSend.join('\n ')}`); + } + + // 4. 列出所有声明的通道,便于人工核对(不算错误) + const allChannels = new Set([ + ...preloadInvoke, + ...preloadSend, + ...preloadOn, + ...preloadArrOn, + ...sendFromMain, + ...onMain, + ...webContentsSend, + ]); + + if (errors.length) { + console.error('[check-ipc] 失败:\n' + errors.map((e) => ` ❌ ${e}`).join('\n\n')); + process.exit(1); + } + + console.log(`[check-ipc] 通过 (${allChannels.size} 个通道全部配对)`); +} + +main(); diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js new file mode 100644 index 0000000..4ffc7ad --- /dev/null +++ b/scripts/check-syntax.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// 语法体检:对 main / preload / scripts / src 全部 JS 文件跑 `node --check`。 +// 比 ESLint 严格度低,但能捕获**最基础的**语法错误(漏括号、错引号等) +// 在文件压根还没被 linted 之前的快速反馈。 + +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); + +/** @type {string[]} */ +const targets = [ + 'main.js', + 'preload.js', + 'scripts/launch.js', + // src/ 下用 ESM import/export,node --check 会按 ESM 解析(package.json 无 "type" 字段 → CJS), + // 因此 src/**/*.js 必须以 .mjs 单独跑。 +]; + +const esmTargets = [ + 'src', +]; + +function walk(dir, out) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + // audit fix (Q3.7):跳过 symlink 文件 + 目录。 + // 之前只用 isFile()/isDirectory() 判定,fs.readdirSync withFileTypes 不会跟随 symlink, + // 但 isSymbolicLink() 会同步报告出来 —— 显式跳过避免后续 stat 跟着 symlink 跑到 + // 项目外文件。实际风险低(项目里没有 symlink),但保留防御。 + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + walk(full, out); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + out.push(full); + } + } +} + +/** @type {string[]} */ +const allCjs = targets.map((p) => path.join(ROOT, p)).filter(fs.existsSync); + +/** @type {string[]} */ +const allEsm = []; +for (const dir of esmTargets) { + const abs = path.join(ROOT, dir); + if (fs.existsSync(abs)) walk(abs, allEsm); +} + +let failed = false; + +for (const file of allCjs) { + try { + // audit fix (Q3.7):改用 execFileSync 不带 shell。 + // 之前用 ``execSync(`node --check "${file}"`, { stdio: 'pipe' })`` 把路径拼进 + // shell 字符串 —— 文件名里出现 `; rm -rf ~ #` 就会被 cmd.exe / sh 解释执行。 + // 项目内的文件名目前可信,但这是审计工具自身表面的隐患,留着没意义。 + execFileSync('node', ['--check', file], { stdio: 'pipe' }); + } catch (err) { + failed = true; + console.error(`[check-syntax] CJS 失败: ${path.relative(ROOT, file)}`); + if (err.stderr) console.error(err.stderr.toString()); + } +} + +for (const file of allEsm) { + try { + // audit fix (Q3.7):同样改用 execFileSync。 + // ESM 走 stdin 重定向:用 { input: fs.readFileSync(file) } 把文件内容喂给子进程 + // stdin,避开 shell 重定向 / 文件名展开。 + const source = fs.readFileSync(file); + execFileSync('node', ['--check', '--input-type=module'], { stdio: ['pipe', 'pipe', 'pipe'], input: source }); + } catch (err) { + failed = true; + console.error(`[check-syntax] ESM 失败: ${path.relative(ROOT, file)}`); + if (err.stderr) console.error(err.stderr.toString()); + } +} + +if (failed) { + console.error('[check-syntax] 失败'); + process.exit(1); +} +console.log(`[check-syntax] 通过 (${allCjs.length} CJS + ${allEsm.length} ESM)`); diff --git a/scripts/launch.js b/scripts/launch.js new file mode 100644 index 0000000..7caa199 --- /dev/null +++ b/scripts/launch.js @@ -0,0 +1,117 @@ +// Notes 启动包装 +// +// 主要职责: +// 1. 处理 Windows 控制台编码(cp936 → utf-8)让中文日志不乱码 +// 2. 在 Windows 上以管理员权限运行时切换工作目录到项目根 +// (某些路径如 `C:\Windows\System32` 在普通权限下无法读取 electron 二进制) +// 3. 避免被 ELECTRON_RUN_AS_NODE 影响(main.js 也会清除,这里是双保险) +// +// 与 Todo List 的 launch.js 逻辑完全一致 —— 它经过实际使用验证。 + +const path = require('path'); +const { spawn } = require('child_process'); +const fs = require('fs'); + +// 防止 ELECTRON_RUN_AS_NODE=1 让我们作为普通 Node 运行 +if (process.env.ELECTRON_RUN_AS_NODE) { + console.log('[launch] 清除 ELECTRON_RUN_AS_NODE 环境变量'); + delete process.env.ELECTRON_RUN_AS_NODE; +} + +// Windows 控制台 UTF-8 +if (process.platform === 'win32') { + try { + process.stdout.setDefaultEncoding('utf8'); + process.stderr.setDefaultEncoding('utf8'); + } catch { + // 某些嵌入式环境下不可用,忽略 + } + + // 把当前终端的代码页切换到 UTF-8。 + // 否则即使我们输出 UTF-8 字节,cmd.exe 默认 cp936 会按 GBK 解析成乱码。 + // chcp 65001 = UTF-8 code page + try { + require('child_process').execSync('chcp 65001 > nul', { + stdio: 'ignore', + shell: true, + }); + } catch { + // chcp 在某些嵌入式终端不可用,忽略 + } +} + +// 解析 electron 可执行文件路径 +function resolveElectronBinary() { + try { + // 优先使用 require('electron') 暴露的二进制路径(dev 模式) + const electronPath = require('electron'); + if (typeof electronPath === 'string' && fs.existsSync(electronPath)) { + return electronPath; + } + } catch { + // fallthrough + } + // 兜底:尝试常见路径 + const candidates = [ + path.join(__dirname, '..', 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'), + path.join(__dirname, '..', 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron'), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + throw new Error('未找到 electron 可执行文件,请先运行 npm install'); +} + +const electronPath = resolveElectronBinary(); +const projectRoot = path.resolve(__dirname, '..'); +console.log('[launch] Electron:', electronPath); +console.log('[launch] 项目根目录:', projectRoot); + +// 在 Windows 上以管理员权限运行时,当前工作目录可能是 System32 +// electron 二进制所在目录可能存在空格或权限问题,统一切换到项目根 +if (process.cwd() !== projectRoot) { + try { + process.chdir(projectRoot); + } catch (e) { + // 切换失败只发警告,不要静默吞掉 —— spawn 仍会用 cwd: projectRoot, + // 所以这里失败通常不影响 electron 启动,但日志里有必要让用户看到。 + console.warn(`[launch] 切换工作目录到 ${projectRoot} 失败:${e.message}`); + } +} + +// 传递所有参数给 electron +const args = [projectRoot, ...process.argv.slice(2)]; + +console.log('[launch] 启动参数:', args.join(' ')); + +const child = spawn(electronPath, args, { + stdio: 'inherit', + cwd: projectRoot, + env: { + ...process.env, + // 确保不会被子进程继承为 Node 模式 + ELECTRON_RUN_AS_NODE: undefined, + }, + windowsHide: false, +}); + +child.on('error', (err) => { + console.error('[launch] 启动失败:', err.message); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + console.log(`[launch] Electron 被信号终止: ${signal}`); + process.exit(1); + } + process.exit(code ?? 0); +}); + +// 透传 Ctrl+C +process.on('SIGINT', () => { + child.kill('SIGINT'); +}); +process.on('SIGTERM', () => { + child.kill('SIGTERM'); +}); diff --git a/shared/ai-errors.js b/shared/ai-errors.js new file mode 100644 index 0000000..934a740 --- /dev/null +++ b/shared/ai-errors.js @@ -0,0 +1,42 @@ +// AI 错误码 —— 主进程与 renderer 端共享的字符串字面值。 +// +// audit fix (CQ-MED-7):之前错误码字面量分散在 main/ai.js#ERR_* 与 +// src/ai/ai-status.js#AI_ERROR 两处,靠注释提醒同步 —— 实际曾漂移(renderer 旧版 +// 把 'AI_TIMEOUT' 写成 'ERR_TIMEOUT',导致 ai-controller 收到 main 返回的错误码 +// 后 aiErrorMessage switch 永不命中、永远走 fallback 'AI 修改失败')。 +// 现在所有跨进程错误码集中在本文件,main / preload / renderer 三处共用同一份字面值, +// 任何一边改了另一边不会失配。 +// +// 加载: +// - main/ai.js: const { AI_ERROR } = require('../shared/ai-errors.js'); +// - preload.js: contextBridge.exposeInMainWorld('api', { aiErrors: { AI_ERROR } }) +// - renderer: const { AI_ERROR } = window.api.aiErrors; +// (fallback 见 src/ai/ai-status.js,jsdom 单测环境走本地副本) +// +// 命名规则:AI_ERROR.* 的 value 是字符串字面值;调用方比较时用 AI_ERROR.FOO 引用, +// 永远不要直接引用字符串字面量。 + +'use strict'; + +const AI_ERROR = Object.freeze({ + // ---- 跨进程(main ↔ renderer)---- + // 主进程 ai.js 抛出 + renderer 端 controller 检测本地未配置时同样抛出。 + NOT_CONFIGURED: 'AI_NOT_CONFIGURED', + // fetch 超时(AbortError + timeout 信号) + ERR_TIMEOUT: 'AI_TIMEOUT', + // HTTP 4xx/5xx 或非 2xx 响应 + ERR_PROVIDER: 'AI_PROVIDER_ERROR', + // 响应体 JSON 解析失败 / 不符合 OpenAI/Anthropic 协议 + ERR_FORMAT: 'AI_BAD_RESPONSE', + // 用户主动取消(renderer abort signal / controller.cancelled 标志) + ERR_CANCELLED: 'AI_CANCELLED', + + // ---- renderer 端辅助判定(main 不会产生这些值,但放一起便于集中引用)---- + NO_FILE: 'NO_FILE', + EDITOR_UNAVAILABLE: 'EDITOR_UNAVAILABLE', + IPC_FAILED: 'IPC_FAILED', + GENERATION_STALE: 'GENERATION_STALE', + FILE_CHANGED: 'FILE_CHANGED', +}); + +module.exports = { AI_ERROR }; \ No newline at end of file diff --git a/shared/extension-lists.js b/shared/extension-lists.js new file mode 100644 index 0000000..2afed70 --- /dev/null +++ b/shared/extension-lists.js @@ -0,0 +1,111 @@ +// 文件扩展名白名单(main / preload / renderer 三处共用) +// +// ============================================================================ +// +// 之前一批可编辑扩展名同时维护在两处(具体数量已不准): +// - main/file-ops.js 的 EDITABLE_EXTS Set(classifyEntry 入口决定文件 +// entryType,关系到 sidebar 展示 + 打开提示) +// - src/app.js 的 LOCAL_FILE_EXTS 数组(resolveLocalFile 判断 md 内 +// 链接是否可以跳转到该文件) +// +// 两边列表一旦漂移,「侧栏显示可编辑 / 点击却提示不支持」或「侧栏灰掉 +// 但 md 里能点开」的体验割裂就会出现。本文件是单一事实源: +// +// • EDITABLE_EXTS — 可打开编辑的文件扩展名(小写、不含点) +// • MARKDOWN_EXTS — 支持 Markdown 渲染预览的扩展名(EDITABLE_EXTS 子集) +// +// 加载方式: +// • main / preload(CJS):const exts = require('../shared/extension-lists.js') +// • renderer:经 preload contextBridge 过桥(window.api.EDITABLE_EXTS), +// 因为 renderer 是 ESM + Chromium 原生,不能直接 import CJS。 +// preload 内对 Set 调 Array.from 后再 exposeInMainWorld,所以 renderer 端 +// 拿到的是 Array(不是 Set),用 .includes / Array.isArray 判断即可。 +// +// 修改流程:只改本文件一处,main + renderer 两侧自动同步。 +// ============================================================================ + +'use strict'; + +/** + * 可打开编辑的文件扩展名白名单(小写、不含点)。 + * + * 包含 Markdown + 常见纯文本 + 常见编程语言;其它扩展名一律视为 binary + * (侧栏仍展示但灰掉、点击提示"不支持的文件类型")。 + * + * 范围决策(2026-08-25 反馈):覆盖 Web 脚本 + 主流语言,避免「写笔记 + * 时顺手维护个 .py / .sql 也打不开」的体验割裂。编辑器是 CodeMirror + * Markdown 6,没有专门的语法高亮(import map 里只配了 markdown / + * javascript / css / html),所以 .py / .go / .rs 等虽然可编辑但会 + * 是纯文本色 —— 后续要做 syntax highlighting 再补 lang-* 包。 + * + * 列表约定: + * - 按「用途族」分组:Markdown / 数据 / 配置 / 文档 / Python / + * JS-TS / Web 样式 / 组件框架 / JVM / Go / Rust / Ruby / PHP / + * Shell / Windows / SQL / C-C++ / C# / Swift / Scala / Lua / + * Perl / R / Dart + * - 排序方便 review 时一眼看清覆盖了哪些,没别的语义 + */ +const EDITABLE_EXTS = new Set([ + // Markdown + 'md', 'markdown', + // 常见纯文本 + 'txt', 'text', 'log', + // 表格 / 结构化数据 + 'csv', 'tsv', 'json', 'xml', 'yaml', 'yml', 'toml', + // 配置 / 环境 + 'ini', 'cfg', 'conf', 'env', + 'gitconfig', // 隐藏文件 .gitconfig 走 lastIndexOf('.') === 0 路径,需在白名单才能 editable + // 文档 / 排版 + 'rst', 'tex', + + // Python + 'py', 'pyi', 'pyw', + // JavaScript / TypeScript(jsx/tsx 让 React 用户也能直接编辑) + 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', + // Web:HTML / CSS 衍生 + 'html', 'htm', 'css', 'scss', 'sass', 'less', + // 组件框架 + 'vue', 'svelte', + // JVM 系 + 'java', 'kt', 'kts', + // Go + 'go', + // Rust + 'rs', + // Ruby + 'rb', + // PHP + 'php', + // Shell 系(POSIX 主流) + 'sh', 'bash', 'zsh', + // Windows 脚本 + 'ps1', + // SQL + 'sql', + // C / C++ + 'c', 'h', 'cpp', 'hpp', 'cc', 'cxx', + // C# + 'cs', + // Swift + 'swift', + // Scala + 'scala', + // Lua + 'lua', + // Perl + 'pl', + // R + 'r', + // Dart + 'dart', +]); + +/** + * 支持 Markdown 渲染预览的扩展名(EDITABLE_EXTS 的子集)。 + * + * 仅 .md / .markdown 走 viewer 预览,其它可编辑文件(.txt/.py/.json/...) + * 在 viewer 显示「不支持预览」空态,但仍可在编辑器里修改。 + */ +const MARKDOWN_EXTS = new Set(['md', 'markdown']); + +module.exports = { EDITABLE_EXTS, MARKDOWN_EXTS }; \ No newline at end of file diff --git a/shared/friendly-fs-error.js b/shared/friendly-fs-error.js new file mode 100644 index 0000000..95b5e25 --- /dev/null +++ b/shared/friendly-fs-error.js @@ -0,0 +1,77 @@ +// 文件系统 errno → 用户能看懂的提示(main + renderer 共享) +// ============================================================================ +// +// 单一事实源。解决 Round 3 之前的三份独立实现漂移: +// - main/file-ops.js#friendlyWriteError(e) —— 主进程内部,签名 e.code +// - src/app.js#friendlyWriteError(code, fallback) —— renderer save 路径 +// - src/file-ops.js#friendlyFsError(code, fallback) —— renderer 文件 CRUD +// +// 三份在 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 上文案不同 —— 用户看到 +// 不一致提示。每加一个 errno 都要同步改三处,每处都有人漏改。 +// +// 修法(Round 4 收尾):本文件 CJS export 函数 friendlyFsError(code, fallback), +// - main/file-ops.js 用 require('./shared/friendly-fs-error.js'), +// 调用时友好FsError(e?.code, e?.message || '写入文件失败') +// - preload 用 require + contextBridge 暴露 window.api.friendlyFsError, +// renderer 端走 window.api.friendlyFsError(...) 拿到同一份文案 +// +// 加载方式: +// - main.js / main/file-ops.js / preload.js (CJS):const { friendlyFsError } = require('...') +// - renderer:经 preload contextBridge 过桥(window.api.friendlyFsError) +// —— renderer 跑在 Chromium 原生 ESM,不能直接 import CJS +// +// 【fallback 行为】 +// - 已知 errno:返回固定中文文案(与 fileOps.friendlyFsError 测试矩阵对齐) +// - 未知 errno:返回 fallback(业务码如 PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED / +// FILE_TOO_LARGE / FILE_CHANGED_EXTERNALLY 等不是 fs errno,由 IPC 调用方 +// 把 result.message 中文文案塞进 fallback) +// - fallback 为空串 / undefined:走兜底「未知错误」,避免空 toast +// ============================================================================ + +'use strict'; + +/** + * 把后端返回的 errno 翻译成中文用户提示。 + * + * @param {string|null|undefined} code - 原始 errno(EACCES / EPERM / ENOSPC ...) + * 或业务码字符串(PATH_NOT_ALLOWED 等),业务码一律走 fallback + * @param {string|undefined} fallback - errno 未匹配时使用的回退文案 + * (主进程一般传 e?.message;renderer 一般传 IPC result.message / result.error) + * @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」) + */ +function friendlyFsError(code, fallback) { + switch (code) { + case 'EACCES': + case 'EPERM': + return '文件被占用或没有写入权限(可能是只读文件 / 另一进程独占 / 权限不足)'; + case 'ENOSPC': + return '磁盘空间不足'; + case 'EROFS': + return '只读文件系统,无法写入'; + case 'EIO': + return '磁盘 I/O 错误'; + case 'EBUSY': + return '文件被其他程序占用'; + case 'ENAMETOOLONG': + return '路径过长'; + case 'ENOTDIR': + return '父目录不是目录'; + case 'EISDIR': + return '目标路径是文件夹,无法写入'; + case 'ENOTEMPTY': + return '目标文件夹不为空'; + // audit fix (Settings P3 / ENOENT mapping):原本 ENOENT 没在 mapping 里, + // 走 default → fallback 兜底成「未知错误」。ENOENT 是最常见的 fs errno + // 之一(rename 源文件已删 / delete 已被外部删 / 路径打错 / watch 到一半 + // 文件被替换),用户看到一个空泛"未知错误"会以为 Notes 出 bug。补上。 + // 措辞区分「文件不存在」与「路径里某一级目录不存在」也覆盖 ENOTDIR + // 已有的 case —— ENOENT 统一按"目标路径不存在"处理(精确到「文件还是 + // 目录」要 main 端自己抛业务码,不该让 errno mapping 揣测)。 + case 'ENOENT': + return '文件或目录不存在(可能已被移动、重命名或删除)'; + default: + return fallback || '未知错误'; + } +} + +module.exports = { friendlyFsError }; \ No newline at end of file diff --git a/shared/markdown-diff.js b/shared/markdown-diff.js new file mode 100644 index 0000000..a39538a --- /dev/null +++ b/shared/markdown-diff.js @@ -0,0 +1,604 @@ +// 行级 + 词级 Markdown diff —— 用于 AI 修改预览 +// ============================================================================ +// +// 从参考项目 markdown.guanjihuan.com 的 src/lib/markdownDiff.ts 移植为 CJS, +// 以便在 main / preload / renderer / 单元测试之间共用同一份算法。 +// +// 核心思路: +// 1. 行级 LCS diff(动态规划,O(n*m) 但有上限保护)找到 add/remove/context 序列 +// 2. 把连续的 add/remove 序列打包成"region"(一个修改块) +// 3. region 内部做词级 diff(同上,tokenize 后 LCS) +// 4. applyDiffRegionSafely 用 region 上下文(前后各 2 行)安全定位并替换 +// +// 关键保护: +// - MAX_LINE_DIFF_CELLS / MAX_TOKEN_DIFF_CELLS / MAX_TOKEN_DIFF_CHARS +// 防止大文档(>1MB)触发指数级内存 / CPU 爆炸 +// - endsWithNewline 跟踪:拼接回去时保留尾换行,避免与原文件 diff + +'use strict'; + +/** + * @typedef {Object} DiffSegment + * @property {'equal'|'removed'|'added'} type + * @property {string} text + */ + +/** + * @typedef {Object} FullDiffRow + * @property {string} id + * @property {string} [regionId] + * @property {'context'|'removed'|'added'} type + * @property {number} [oldLineNumber] + * @property {number} [newLineNumber] + * @property {DiffSegment[]} segments + */ + +/** + * @typedef {Object} AiDiffRegion + * @property {string} id + * @property {number} oldStart + * @property {string[]} oldLines + * @property {number} newStart + * @property {string[]} newLines + * @property {string[]} beforeContext + * @property {string[]} afterContext + * @property {string} [conflict] + */ + +/** + * @typedef {Object} PendingAiDiffProposal + * @property {string} id + * @property {string} baseContent + * @property {string} nextContent + * @property {boolean} [isReadOnly] + * @property {FullDiffRow[]} rows + * @property {AiDiffRegion[]} regions + */ + +/** + * @typedef {Object} ComputeFullMarkdownDiffOptions + * @property {boolean} [tokenDiff] + */ + +/** + * @typedef {Object} DiffOp + * @property {'context'|'removed'|'added'} type + * @property {string} text + * @property {number} [oldLineNumber] + * @property {number} [newLineNumber] + */ + +const CONTEXT_SIZE = 2; +const MAX_LINE_DIFF_CELLS = 200_000; +const MAX_TOKEN_DIFF_CHARS = 4_000; +const MAX_TOKEN_DIFF_CELLS = 40_000; + +function normalizeContent(content) { + // audit fix:Windows Notepad / 某些 PowerShell pipeline 会写 UTF-8 BOM + // (U+FEFF) 在文件头。不剥 BOM 会让第一行变成 "\uFEFF# Title", + // computeDiffOps 里 oldLines[0] === newLines[0] 永远 false, + // 每个 BOM-prefixed 文件都会在 AI diff 面板里把首行当成「被改」渲染, + // 即使内容一字未动。仅在文件起始位置剥一次(intra-content 的 BOM 保留)。 + // \u5BA1\u8BA1\u4FEE\u590D (Round 11 deep-fix P2-1)\uFF1A\u628A BOM \u5265\u9664\u4ECE\u300C\u4EC5\u6587\u4EF6\u5934\u300D\u6269\u5230\u300C\u5168\u6587 BOM \u5B57\u7B26\u300D\u3002 + // \u65E7\u7248 intra-file BOM\uFF08\u4F8B\u5982\u591A\u6B21 cat \u62FC\u63A5\u65F6\u5076\u5C14\u51FA\u73B0\uFF09\u4F1A\u88AB\u5F53\u6210\u6B63\u6587\u4E00\u90E8\u5206\uFF0C + // \u8BA9\u5BF9\u5E94\u884C\u6C38\u4E0D\u7B49\u4E8E oldLines \u91CC\u540C\u6837\u4F4D\u7F6E\u7684\u884C \u2192 \u6574\u884C\u88AB\u8BEF\u5224\u4E3A\u300C\u4FEE\u6539\u300D\u3002 + // \uFEFF \u5728 Markdown \u91CC\u6CA1\u6709\u4EFB\u4F55\u6709\u610F\u4E49\u7684\u8BED\u4E49\uFF08\u96F6\u5BBD BOM \u6807\u8BB0\uFF09\uFF0C\u5168\u6587\u5265\u662F\u5B89\u5168\u7684\u3002 + // + // \u884C\u5C3E\u89C4\u6574\uFF1A\r\n \u2192 \n\uFF1B**\u4FDD\u7559\u88F8 \r**\uFF08\u65E7 Mac \u98CE\u683C\u5408\u6CD5\u53EF\u542B\uFF09\u3002 + // \u539F `replace(/\r\n?/g, '\n')` \u4F1A\u541E\u6389\u88F8 \r \u628A\u4E00\u884C\u62C6\u6210\u4E24\u884C\uFF08Round 8 BOM \u4FEE\u590D\u7684\u526F\u4F5C\u7528\uFF09\uFF0C + // \u73B0\u5728\u6539\u7528 /\r\n/g \u53EA\u5339\u914D CRLF\u3002 + return String(content).replace(/\r\n/g, '\n').replace(/\uFEFF/g, ''); +} + +function splitLines(content) { + const normalized = normalizeContent(content); + if (normalized.length === 0) return []; + // audit fix:「空文件」与「只有一个换行的文件」语义上等价 —— 都是「无内容行」, + // 但前者 splitLines 返回 [],后者返回 [""],diff LCS 在这两种输入下会产生 + // 不同的 ops(added 与 nothing),让 AI diff 偶现「空文件被加了一行」的幽灵。 + // 统一成 []。 + const lines = normalized.endsWith('\n') + ? normalized.slice(0, -1).split('\n') + : normalized.split('\n'); + if (lines.length === 1 && lines[0] === '') return []; + return lines; +} + +function joinLines(lines, endsWithNewline, eol = '\n') { + if (lines.length === 0) return endsWithNewline ? eol : ''; + return `${lines.join(eol)}${endsWithNewline ? eol : ''}`; +} + +function computeDiffOps(oldLines, newLines, onFallback) { + let prefixLength = 0; + const maxPrefixLength = Math.min(oldLines.length, newLines.length); + + while (prefixLength < maxPrefixLength && oldLines[prefixLength] === newLines[prefixLength]) { + prefixLength += 1; + } + + let oldSuffixStart = oldLines.length; + let newSuffixStart = newLines.length; + + while ( + oldSuffixStart > prefixLength + && newSuffixStart > prefixLength + && oldLines[oldSuffixStart - 1] === newLines[newSuffixStart - 1] + ) { + oldSuffixStart -= 1; + newSuffixStart -= 1; + } + + const oldMiddle = oldLines.slice(prefixLength, oldSuffixStart); + const newMiddle = newLines.slice(prefixLength, newSuffixStart); + /** @type {DiffOp[]} */ + const ops = []; + + for (let index = 0; index < prefixLength; index += 1) { + ops.push({ type: 'context', text: oldLines[index], oldLineNumber: index + 1, newLineNumber: index + 1 }); + } + + if (oldMiddle.length * newMiddle.length > MAX_LINE_DIFF_CELLS) { + // H3 fix (audit):cells 超过 MAX_LINE_DIFF_CELLS 时退化到「整段删 + 整段加」 + // fallback。原来的实现不告诉调用方 —— UI 看到「整篇被改」会误以为 AI 整篇重写。 + // onFallback 让 controller toast「diff 过大,已退化为整段替换」。 + if (typeof onFallback === 'function') { + onFallback(`行级 diff 超过 ${MAX_LINE_DIFF_CELLS} cells 上限,已退化为整段替换(${oldMiddle.length} 行 removed + ${newMiddle.length} 行 added)`); + } + oldMiddle.forEach((text, index) => { + ops.push({ type: 'removed', text, oldLineNumber: prefixLength + index + 1 }); + }); + newMiddle.forEach((text, index) => { + ops.push({ type: 'added', text, newLineNumber: prefixLength + index + 1 }); + }); + } else { + // LCS 动态规划 + const dp = Array.from({ length: oldMiddle.length + 1 }, () => new Array(newMiddle.length + 1).fill(0)); + + for (let i = oldMiddle.length - 1; i >= 0; i -= 1) { + for (let j = newMiddle.length - 1; j >= 0; j -= 1) { + dp[i][j] = oldMiddle[i] === newMiddle[j] + ? dp[i + 1][j + 1] + 1 + : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + let i = 0; + let j = 0; + + while (i < oldMiddle.length && j < newMiddle.length) { + if (oldMiddle[i] === newMiddle[j]) { + ops.push({ + type: 'context', + text: oldMiddle[i], + oldLineNumber: prefixLength + i + 1, + newLineNumber: prefixLength + j + 1, + }); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 }); + i += 1; + } else { + ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 }); + j += 1; + } + } + + while (i < oldMiddle.length) { + ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 }); + i += 1; + } + + while (j < newMiddle.length) { + ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 }); + j += 1; + } + } + + for (let index = oldSuffixStart; index < oldLines.length; index += 1) { + ops.push({ + type: 'context', + text: oldLines[index], + oldLineNumber: index + 1, + newLineNumber: newSuffixStart + index - oldSuffixStart + 1, + }); + } + + return ops; +} + +function findSequence(lines, sequence) { + if (sequence.length === 0) return []; + + const matchesAt = (index) => sequence.every((line, offset) => lines[index + offset] === line); + + // fix(audit 2026-08):原版在 preferredIndex 命中时直接 [preferredIndex] 返回, + // 跳过扫描全文 → 其它位置的重复匹配被静默忽略。背景:AI 从 base 算 diff 时只 + // 有 1 个匹配;但用户编辑后可能在其它位置粘了相同行 → 当前文档有 N 个匹配, + // preferredIndex hint 仍指向原位置。直接套用 hint 会改错位置(旧位置可能是 + // 用户新增的副本,而非 AI 原意要改的那一行)。 + // 修复:总是扫描全文,仅当只有 1 个匹配时才信任 hint 返回单元素数组。 + // hint 现在在调用方(applyDiffRegionSafely)通过 beforeContext/afterContext 实现, + // 见 :547+ region clamp 切片。 + const matches = []; + for (let i = 0; i <= lines.length - sequence.length; i += 1) { + if (matchesAt(i)) matches.push(i); + } + return matches; +} + +function tokenize(text) { + const tokens = []; + let index = 0; + + while (index < text.length) { + const char = text[index]; + // 用贪婪匹配,匹配 $...$ 行内数学(Katex 风格的简单 token) + const mathMatch = char === '$' ? text.slice(index).match(/^\$[^$]+\$/) : null; + const wordMatch = text.slice(index).match(/^[A-Za-z0-9_]+/); + const spaceMatch = text.slice(index).match(/^\s+/); + + if (mathMatch) { + tokens.push(mathMatch[0]); + index += mathMatch[0].length; + } else if (spaceMatch) { + tokens.push(spaceMatch[0]); + index += spaceMatch[0].length; + } else if (wordMatch) { + tokens.push(wordMatch[0]); + index += wordMatch[0].length; + } else { + tokens.push(char); + index += 1; + } + } + + return tokens; +} + +function diffTokens(removedText, addedText, onFallback) { + if (removedText.length + addedText.length > MAX_TOKEN_DIFF_CHARS) { + // H3 fix (audit):token 字符数超过上限 → 退化到行级 fallback。 + // 通过 onFallback 把警告挂到 computeFullMarkdownDiff 的 warnings 字段。 + if (typeof onFallback === 'function') { + onFallback(`词级 diff 超过 ${MAX_TOKEN_DIFF_CHARS} 字符上限,已退化为行级(${removedText.length}+${addedText.length} chars)`); + } + return { + removedSegments: [{ type: 'removed', text: removedText }], + addedSegments: [{ type: 'added', text: addedText }], + }; + } + + const oldTokens = tokenize(removedText); + const newTokens = tokenize(addedText); + + if (oldTokens.length * newTokens.length > MAX_TOKEN_DIFF_CELLS) { + // H3 fix (audit):token cells 超上限同样退化。 + if (typeof onFallback === 'function') { + onFallback(`词级 diff cells 超过 ${MAX_TOKEN_DIFF_CELLS} 上限,已退化为行级(${oldTokens.length}×${newTokens.length} tokens)`); + } + return { + removedSegments: [{ type: 'removed', text: removedText }], + addedSegments: [{ type: 'added', text: addedText }], + }; + } + + const dp = Array.from({ length: oldTokens.length + 1 }, () => new Array(newTokens.length + 1).fill(0)); + + for (let i = oldTokens.length - 1; i >= 0; i -= 1) { + for (let j = newTokens.length - 1; j >= 0; j -= 1) { + dp[i][j] = oldTokens[i] === newTokens[j] + ? dp[i + 1][j + 1] + 1 + : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + /** @type {DiffSegment[]} */ + const removedSegments = []; + /** @type {DiffSegment[]} */ + const addedSegments = []; + let i = 0; + let j = 0; + + const pushSegment = (segments, type, text) => { + const last = segments[segments.length - 1]; + if (last && last.type === type) { + last.text += text; + } else { + segments.push({ type, text }); + } + }; + + while (i < oldTokens.length && j < newTokens.length) { + if (oldTokens[i] === newTokens[j]) { + pushSegment(removedSegments, 'equal', oldTokens[i]); + pushSegment(addedSegments, 'equal', newTokens[j]); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + pushSegment(removedSegments, 'removed', oldTokens[i]); + i += 1; + } else { + pushSegment(addedSegments, 'added', newTokens[j]); + j += 1; + } + } + + while (i < oldTokens.length) { + pushSegment(removedSegments, 'removed', oldTokens[i]); + i += 1; + } + + while (j < newTokens.length) { + pushSegment(addedSegments, 'added', newTokens[j]); + j += 1; + } + + return { removedSegments, addedSegments }; +} + +function buildContextRows(lines) { + return lines.map((text, index) => ({ + id: `context-${index}`, + type: 'context', + oldLineNumber: index + 1, + newLineNumber: index + 1, + segments: [{ type: 'equal', text }], + })); +} + +function buildChangedRows(regionId, changedOps, options, onFallback) { + /** @type {FullDiffRow[]} */ + const rows = []; + const removedOps = changedOps.filter((op) => op.type === 'removed'); + const addedOps = changedOps.filter((op) => op.type === 'added'); + const pairCount = Math.min(removedOps.length, addedOps.length); + const useTokenDiff = options.tokenDiff !== false; + + for (let i = 0; i < pairCount; i += 1) { + const removedOp = removedOps[i]; + const addedOp = addedOps[i]; + const { removedSegments, addedSegments } = useTokenDiff + ? diffTokens(removedOp.text, addedOp.text, onFallback) + : { + removedSegments: [{ type: 'removed', text: removedOp.text }], + addedSegments: [{ type: 'added', text: addedOp.text }], + }; + rows.push({ + id: `${regionId}-removed-${i}`, + regionId, + type: 'removed', + oldLineNumber: removedOp.oldLineNumber, + segments: removedSegments.length > 0 ? removedSegments : [{ type: 'removed', text: removedOp.text }], + }); + rows.push({ + id: `${regionId}-added-${i}`, + regionId, + type: 'added', + newLineNumber: addedOp.newLineNumber, + segments: addedSegments.length > 0 ? addedSegments : [{ type: 'added', text: addedOp.text }], + }); + } + + for (let i = pairCount; i < removedOps.length; i += 1) { + rows.push({ + id: `${regionId}-removed-${i}`, + regionId, + type: 'removed', + oldLineNumber: removedOps[i].oldLineNumber, + segments: [{ type: 'removed', text: removedOps[i].text }], + }); + } + + for (let i = pairCount; i < addedOps.length; i += 1) { + rows.push({ + id: `${regionId}-added-${i}`, + regionId, + type: 'added', + newLineNumber: addedOps[i].newLineNumber, + segments: [{ type: 'added', text: addedOps[i].text }], + }); + } + + return rows; +} + +/** + * 计算两段 Markdown 文本的完整 diff。 + * @param {string} baseContent + * @param {string} nextContent + * @param {ComputeFullMarkdownDiffOptions} [options] + * @returns {{ rows: FullDiffRow[], regions: AiDiffRegion[] }} + */ +function computeFullMarkdownDiff(baseContent, nextContent, options = {}) { + const normalizedBaseContent = normalizeContent(baseContent); + const normalizedNextContent = normalizeContent(nextContent); + // H3 fix (audit):收集 fallback 警告(行级 cells 超 MAX_LINE_DIFF_CELLS / + // 词级超 MAX_TOKEN_DIFF_CHARS 触发整段替换 / 退化为行级时挂警告), + // 让 controller toast「diff 过大,已退化为整段替换」/「词级已退化为行级」, + // 避免 UI 上看到「整篇被改」以为是 AI 整篇重写。 + const warnings = []; + const emitWarning = (msg) => { warnings.push(msg); }; + if (normalizedBaseContent === normalizedNextContent) { + return { rows: buildContextRows(splitLines(normalizedNextContent)), regions: [] }; + } + + const oldLines = splitLines(normalizedBaseContent); + const newLines = splitLines(normalizedNextContent); + const ops = computeDiffOps(oldLines, newLines, emitWarning); + /** @type {FullDiffRow[]} */ + const rows = []; + /** @type {AiDiffRegion[]} */ + const regions = []; + let index = 0; + + while (index < ops.length) { + const op = ops[index]; + + if (op.type === 'context') { + rows.push({ + id: `context-${rows.length}`, + type: 'context', + oldLineNumber: op.oldLineNumber, + newLineNumber: op.newLineNumber, + segments: [{ type: 'equal', text: op.text }], + }); + index += 1; + continue; + } + + const changeStart = index; + while (index < ops.length && ops[index].type !== 'context') index += 1; + const changedOps = ops.slice(changeStart, index); + const oldLinesInRegion = changedOps.filter((item) => item.type === 'removed').map((item) => item.text); + const newLinesInRegion = changedOps.filter((item) => item.type === 'added').map((item) => item.text); + const beforeContextStart = Math.max(0, changeStart - CONTEXT_SIZE); + const afterContextEnd = Math.min(ops.length, index + CONTEXT_SIZE); + const beforeContext = ops.slice(beforeContextStart, changeStart).filter((item) => item.type === 'context').map((item) => item.text); + const afterContext = ops.slice(index, afterContextEnd).filter((item) => item.type === 'context').map((item) => item.text); + const firstOldLine = changedOps.find((item) => item.oldLineNumber !== undefined); + const firstNewLine = changedOps.find((item) => item.newLineNumber !== undefined); + const firstOldLineNumber = firstOldLine ? firstOldLine.oldLineNumber : undefined; + const firstNewLineNumber = firstNewLine ? firstNewLine.newLineNumber : undefined; + const regionId = `region-${regions.length + 1}`; + + // C1 fix (audit):若一个 region 完全没有 beforeContext + afterContext, + // 「应用此处」无法唯一定位(applyDiffRegionSafely 对纯新增/整篇删除 fallback + // 到 currentLines.length===0 才生效,正常文档永远 conflict)。在 UI 显式标 conflict + // 引导用户走「应用全部」。这是 AI 返回「整篇替换」或「在空文档插入内容」时的合理退化。 + const needsWholeDocHint = beforeContext.length === 0 && afterContext.length === 0; + + regions.push({ + id: regionId, + oldStart: firstOldLineNumber !== undefined ? firstOldLineNumber - 1 : Math.max(0, (firstNewLineNumber || 1) - 1), + oldLines: oldLinesInRegion, + newStart: firstNewLineNumber !== undefined ? firstNewLineNumber - 1 : Math.max(0, (firstOldLineNumber || 1) - 1), + newLines: newLinesInRegion, + beforeContext, + afterContext, + ...(needsWholeDocHint ? { conflict: '无上下文定位,请使用「应用全部」' } : {}), + }); + rows.push(...buildChangedRows(regionId, changedOps, options, emitWarning)); + } + + // H3 fix (audit):只有真有 fallback 才带 warnings 字段,避免污染 return shape。 + return warnings.length > 0 ? { rows, regions, warnings } : { rows, regions }; +} + +/** + * 用 region 安全替换 currentContent 中对应位置的内容。 + * 找不到唯一匹配(0 个或多个)时返回 { ok:false, reason },供 UI 标红提示。 + * @param {string} currentContent + * @param {AiDiffRegion} region + * @returns {{ ok: true, content: string } | { ok: false, reason: string }} + */ +function applyDiffRegionSafely(currentContent, region) { + const original = String(currentContent); + const normalized = normalizeContent(original); + const currentLines = splitLines(normalized); + const endsWithNewline = normalized.endsWith('\n'); + // 保留原稿行尾:检测是否含 CRLF(含至少一处即视为 CRLF 文件)。 + // 之前 normalizeContent 把 \r\n → \n 后 joinLines 又硬写 \n, + // 会把 Windows 用户的 .md 文件静默改成 LF —— 污染文件格式。 + const eol = /\r\n/.test(original) ? '\r\n' : '\n'; + + if (region.oldLines.length > 0) { + // findSequence 现在总是扫描全文(不再走 preferredIndex early-return), + // 拿到所有 oldLines 匹配的下标。再用 beforeContext/afterContext 二次过滤, + // 既保留 C3 上下文漂移检测,又能在「多个匹配但只有一个上下文一致」时安全应用。 + const allMatches = findSequence(currentLines, region.oldLines, region.oldStart); + if (allMatches.length === 0) { + return { ok: false, reason: '无法安全定位该修改位置' }; + } + + // C3 fix (audit):多匹配时用 context 过滤,定位唯一正确的位置。 + // 场景:用户复制了相同行到多处(如原 c 后面又粘一个 c),preferredIndex 仍 + // 命中旧位置,但旧位置的 context 已和 region 算出时不同;其它新位置的 + // context 同样不对。三个候选都漂移 → "上下文漂移"。 + // 单匹配且有 context 时也照常校验(防止单匹配也漂移的极端情况)。 + if (region.beforeContext.length > 0 || region.afterContext.length > 0) { + const contextMatches = allMatches.filter((idx) => { + const beforeActual = currentLines.slice( + Math.max(0, idx - region.beforeContext.length), + idx, + ); + const afterActual = currentLines.slice( + idx + region.oldLines.length, + idx + region.oldLines.length + region.afterContext.length, + ); + return beforeActual.join('\n') === region.beforeContext.join('\n') + && afterActual.join('\n') === region.afterContext.join('\n'); + }); + if (contextMatches.length === 0) { + // 所有匹配位置的 context 都不对 → drift。 + return { ok: false, reason: '上下文漂移,无法确定该修改的位置' }; + } + if (contextMatches.length > 1) { + // 多处 context 都和 region 一致,但 oldLines 重复了 → 真歧义。 + return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' }; + } + // 唯一匹配:context 一致的位置就是 base 时的位置,安全应用。 + const nextLines = currentLines.slice(); + nextLines.splice(contextMatches[0], region.oldLines.length, ...region.newLines); + return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) }; + } + + // 无 context:要求 oldLines 全局唯一。 + if (allMatches.length !== 1) { + return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' }; + } + const nextLines = currentLines.slice(); + nextLines.splice(allMatches[0], region.oldLines.length, ...region.newLines); + return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) }; + } + + // 纯新增:尝试用 beforeContext + afterContext 定位插入点 + const preferredIndex = Math.min(Math.max(region.oldStart, 0), currentLines.length); + const beforeMatches = findSequence(currentLines, region.beforeContext); + const afterMatches = findSequence(currentLines, region.afterContext); + /** @type {number | null} */ + let insertionIndex = null; + + if ( + region.beforeContext.length > 0 + && region.afterContext.length > 0 + // clamp 切片端点:preferredIndex - region.beforeContext.length 在文档开头 + // 可能是负数 → JS 的 slice(-N, 0) 会拿"末尾 N 项"误匹配;显式 clamp 到 0。 + && preferredIndex - region.beforeContext.length >= 0 + && currentLines.slice(preferredIndex - region.beforeContext.length, preferredIndex).join('\n') === region.beforeContext.join('\n') + && currentLines.slice(preferredIndex, preferredIndex + region.afterContext.length).join('\n') === region.afterContext.join('\n') + ) { + insertionIndex = preferredIndex; + } else if (beforeMatches.length === 1 && afterMatches.length === 1) { + // P3 fix:before + after 同时各只有一个匹配,但 preferredIndex 错位了。 + // 此时必须校验 before 末尾 === after 起点(即「这两段上下文在文档里相邻」), + // 否则 before 和 after 是两个独立匹配,盲选 before 会插到错位置。 + const beforeEnd = beforeMatches[0] + region.beforeContext.length; + if (beforeEnd === afterMatches[0]) { + insertionIndex = beforeEnd; + } else { + // 上下文冲突:让 UI 走 conflict 分支(标灰、应用按钮 disable)。 + return { ok: false, reason: '前后上下文位置冲突,无法确定插入点' }; + } + } else if (beforeMatches.length === 1) { + insertionIndex = beforeMatches[0] + region.beforeContext.length; + } else if (afterMatches.length === 1) { + insertionIndex = afterMatches[0]; + } else if (currentLines.length === 0) { + insertionIndex = 0; + } + + if (insertionIndex === null) { + return { ok: false, reason: '无法安全定位插入位置' }; + } + + const nextLines = currentLines.slice(); + nextLines.splice(insertionIndex, 0, ...region.newLines); + return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) }; +} + +module.exports = { + computeFullMarkdownDiff, + applyDiffRegionSafely, +}; diff --git a/shared/render-sanitize.js b/shared/render-sanitize.js new file mode 100644 index 0000000..3d432ba --- /dev/null +++ b/shared/render-sanitize.js @@ -0,0 +1,239 @@ +// Markdown → 安全 HTML 的核心规则(Stage 8:与 preload.js 共用) +// +// 职责: +// - 定义 ALLOWED_URI_REGEXP(URI scheme 白名单 + 相对路径分支) +// - 注册 uponSanitizeAttribute 钩子(统一走 ALLOWED_URI_REGEXP; +// 危险协议 javascript:/vbscript:/data:text\/html/file: 在所有标签上都剥) +// - 导出 renderMarkdown(markdown, dompurifyInstance) → safeHtml +// +// 边界: +// - 不引用 electron / contextBridge / marked —— 由 preload 注入 +// - 不导出 DOMPurify 实例(每个进程各自创建,避免泄漏 window) +// +// preload.js 与 tests/unit/render-sanitize.test.js 都 require 本文件, +// 保证规则不会在「实现」与「测试」之间漂移。 + +/** + * 允许的 URI 协议: + * - 前半段:显式 scheme 白名单(http/https/mailto/tel/callto/file/data:image/raster) + * —— file: 必须先放行,DOMPurify 才会回调 uponSanitizeAttribute 钩子 + * —— data:image 限定为栅格格式(png/jpeg/gif/webp/bmp/ico/tiff)—— 显式 + * 拒绝 svg+xml/svg 等可携带 JS / XML 外部实体的格式。SVG 数据 URL 在 + * `
` 上点击会导航到 top-level SVG 上下文,现代 Chromium 多半 + * 拦截脚本执行,但跨浏览器 / 跨版本一致性差。Markdown 用例几乎不需 + * SVG 内嵌图,raster 已覆盖 99% 场景。 + * - 后半段:相对路径分支('img/a.png'、'./other.md'、'/abs/x.png'、'../up.md') + * —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头; + * `(?![/\\]{2})` 显式拒绝「协议相对 URL」(`//evil.com/x.png` / + * `\\evil.com\x.png`),避免静默导航到外站 + * —— `[a-z+.-]+(?:[\\/][^a-z]*|$)` 覆盖字母开头的相对路径 + * + * 危险协议 (javascript:/vbscript:/data:text\/html) 走「字母+冒号」分支——因 + * scheme 不在前列、不以 `#` 开头、不含 `/`,全部不匹配。 + * + * Windows 路径分隔符 `\`:用户在 Windows 上常写 `![](img\foo.png)`, + * 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此), + * 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。 + */ +const ALLOWED_URI_REGEXP = + /^(?:(?:https?|mailto|tel|callto|file):|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,|#|(?:(?![/\\]{2})[/\\]|[^a-z/\\])|[a-z+.-]+(?:[\\/][^a-z]*|$))/i; + +// data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图 +// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。 +const DATA_IMAGE_MAX_URL_LENGTH = 10 * 1024 ** 2; + +/** + * 危险 URI scheme —— 在非 IMG 标签(<a href> / <form action> / iframe 等) + * 上剥,包括 file:(导航/数据外流通道)。IMG/src 例外:file:/// 在 IMG 上是 + * Notes 数据目录本地图片路径,需要保留。 + * + * audit fix (C1):钩子里先 trim 再匹配。原正则 `^...javascript:...` 锚定首字符, + * 若 DOMPurify 未规范化前导空格 / 控制字符(` ` 等实体), + * 像 `` 就能绕过钩子。trim 后再 `^` 匹配, + * 同时把 NUL/控制字符一并处理(C0 控制字符 0x00-0x1F + 0x7F)。 + */ +const DANGEROUS_URI_NON_IMG_REGEXP = /^(?:javascript|vbscript|data(?!:image\/)|file):/i; + +function isDangerousUriNonImg(value) { + if (typeof value !== 'string') return false; + // 剥前导空白 + 控制字符:HTML 实体解码后可能留下的 \x00-\x1F / \x7F / 空格 / 换行。 + // 用循环 + charCodeAt 比较而非 regex —— ESLint no-control-regex 会拒绝 regex 字面里的控制字符。 + let i = 0; + while (i < value.length) { + const code = value.charCodeAt(i); + // 空白:\t(9) \n(10) \v(11) \f(12) \r(13) 空格(32) NBSP(160) 等 + // 控制字符:0-31 与 127 + if (code <= 32 || code === 127 || code === 160) { + i += 1; + continue; + } + break; + } + return DANGEROUS_URI_NON_IMG_REGEXP.test(value.slice(i)); +} + +/** + * audit fix (Round 9):style 属性里的危险 CSS 模式防御。 + * + * 场景:`` —— 上面 + * isDangerousUriNonImg 检的是 attrValue **开头**,对「值里嵌套 javascript: URL + * 的 style」无能为力。同理 `style="width:expression(alert(1))"`(legacy IE)、 + * `style="-moz-binding:url(...)"`(legacy Mozilla XBL)、`behavior:url(...)` + * (legacy IE HTC)。 + * + * DOMPurify v3 默认对这些模式有部分保护(CSS sanitizer 拒 url(javascript:)), + * 但行为跨版本 / 跨浏览器一致性差。本函数做防御性深度扫描:发现任一危险模式 + * → 整条 style 属性剥掉(保守:宁可错杀不可漏过)。 + * + * 同时扫描 @import(可绕过 background-image 限定的外链资源加载)和 + * url(javascript:|vbscript:|data:text/html|file:) 等危险 URL scheme。 + * + * 注意:url() 内的空白 / 引号 / 大小写都要容忍 —— 用不区分大小写的 regex, + * 容忍 url 关键字后的可选空白。 + */ +const DANGEROUS_STYLE_PATTERNS = [ + // url(javascript:...) / url("javascript:...") / url('javascript:...') + // 容忍可选空白 + 单/双引号包裹 + 大小写 + /url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i, + // legacy IE CSS expression() + /expression\s*\(/i, + // legacy IE HTC behavior + /\bbehavior\s*:\s*url\s*\(/i, + // legacy Mozilla XBL binding + /-moz-binding\s*:/i, + // CSS @import(外链资源加载 / CSP 绕过) + /@import/i, +]; + +// audit fix (Round 13 / Sec-M):CSS 属性 denylist —— 阻止攻击者把整窗当画布。 +// 思路:Markdown 的合法 inline style 几乎只用 color / font-size / text-align / +// background:url(https://...) 这类纯视觉属性;任何「能改变布局 / 跳出文档流 / +// 遮挡 UI / 隐藏元素 / 让用户看不清真实界面」的属性都属于攻击面。 +// +// 受保护列表(每个测试过: +// - `` +// —— 全屏透明覆盖层,配合外链跳转 = 整窗 UI 欺骗 / 点击劫持 +// - `` —— 隐藏 chrome(已 +// 靠 FORBID_TAGS:['svg'] 拦死,这条是给「未来如果放宽 svg」做兜底) +// - `.toolbar,.statusbar{visibility:hidden}` —— 视觉欺骗 +// - `body::after{content:"会话过期请重新输入 API Key"}` —— 钓鱼覆盖层 +// - `pointer-events:none` 让 UI 看着可点但透传到下层 +// - `transform: ...` 在 markdown 里几乎不合法使用,且可绕开父级 contain +// - `opacity:0` 让链接看着没东西实际可点 +// +// 注意:\b 词边界要求属性名是独立 token,`font-size` 不会被误命中 `size`。 +const DANGEROUS_STYLE_PROPS = [ + 'position', 'inset', 'top', 'left', 'right', 'bottom', + 'z-index', 'zindex', + 'float', 'clear', + 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height', + 'margin', 'padding', + 'transform', 'translate', + 'visibility', 'opacity', + 'pointer-events', + 'content', + // display:none 是合法用法(折叠冗余段落)但 XSS 高敏,宁可错杀。 + // 想要折叠段落用引用 styled 容器而不是内联 style。 + 'display', +]; + +function isDangerousStyleValue(value) { + if (typeof value !== 'string') return false; + // style 值的注释 / 字符串里也照样扫 —— 攻击者可借 `/* xxx */` 试图绕过; + // 保守策略:任一危险模式出现 → 剥整条 style。 + for (const pat of DANGEROUS_STYLE_PATTERNS) { + if (pat.test(value)) return true; + } + // 属性名扫描 —— 把 value 按 `;` 拆成 declaration,逐个看属性名是否在 + // 黑名单。\b 词边界防 `font-size` 误命中 `size`、防 `text-align` 误命中 + // 不存在的 `align`。兼容 `:property` 与 `: property` 两种写法。 + for (const decl of value.split(';')) { + const m = decl.match(/^\s*([a-zA-Z-]+)\s*:/); + if (m && DANGEROUS_STYLE_PROPS.includes(m[1].toLowerCase())) return true; + } + return false; +} + +/** + * 注册 uponSanitizeAttribute 钩子: + * - <img src> 走 ALLOWED_URI_REGEXP 显式校验(统一策略); + * file:/// 放行(ALLOWED 已含),javascript:/vbscript:/data:text\/html 等 + * 未在白名单的 scheme 一律剥 + * - 其他标签的 href/src/action 见到 javascript:/vbscript:/data:text\/html/file: + * → 主动剥 + * + * 之前 <img src> 是 keepAttr=true 全放行,靠 DOMPurify v3 内置兜底处理 javascript:; + * vbscript: / data:text\/html 在 img 上不会被 DOMPurify 兜底剥掉,靠「img 不渲染 html」 + * 的策略假设——跨浏览器 / 跨版本一致性差。现在所有 src/href/action 都按 ALLOWED 过滤, + * 单一事实源;同时非 IMG 标签的 file: 仍主动剥(导航 / 数据外流风险)。 + * + * 必须在调用 DOMPurifyInstance.sanitize 之前注册一次(v3 的钩子是实例级, + * 重启进程后丢失;不需要 removeHook)。 + * + * @param {object} DOMPurifyInstance - 由 createDOMPurify(window) 创建的实例 + */ +function installHooks(DOMPurifyInstance) { + if (!DOMPurifyInstance || typeof DOMPurifyInstance.addHook !== 'function') return; + DOMPurifyInstance.addHook('uponSanitizeAttribute', (node, data) => { + const isImgSrc = data.attrName === 'src' && node.tagName === 'IMG'; + // 走显式白名单 —— ALLOWED_URI_REGEXP 已含 file:/data:image/,未匹配即剥 + if (isImgSrc) { + if (!ALLOWED_URI_REGEXP.test(data.attrValue)) { + data.keepAttr = false; + return; + } + // audit fix (Round 13 / Sec-H2):file:// 必须严格 file:///... 三斜杠形式 + // —— 拒绝 file://host/... 与 file:////host/...。 + // + // 威胁:Windows 上 Chromium 把 `file://ATTACKER-HOST/share/x.png` 解析为 + // UNC 路径 `\\ATTACKER-HOST\share\x.png` 并交给 SMB 客户端去取 —— 系统 + // 静默做 NTLMv2 认证,攻击者能直接抓受害者的 domain\user + NTLM response + // (CVE-2023-23397 Outlook 那个原语,这里通过 markdown 图片复现)。 + // 三斜杠 = "无 host + 绝对路径",是浏览器渲染本地图片的唯一合法形式。 + // 写法:要求 file: 后恰好 3 个 /,且第 4 个字符不是 /(即 `////` UNC 也拒)。 + if (/^\s*file:/i.test(data.attrValue) && !/^file:\/{3}[^/]/i.test(data.attrValue)) { + data.keepAttr = false; + return; + } + if ( + // audit fix (Sec-M3):data:image 单 URL 上限。恶意 markdown 可嵌 5MB+ + // base64 图(AI 修改场景下尤其:用户 prompt + content + 嵌入图可叠到 + // MB 级),DOMPurify v3 内置不会卡 base64 长度 → OOM 风险。 + // 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。 + // 文件绝对路径/file:/https: 不进此分支,行为不变。 + data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH + && /^data:image\//i.test(data.attrValue) + ) { + data.keepAttr = false; + } + return; + } + // audit fix (Round 9):style 属性走 isDangerousStyleValue 深度扫描。 + // 嵌在 url() 里的 javascript:/expression()/@import 等光靠 attrValue + // 开头扫描抓不到。这里保守:任一危险模式命中 → 整条 style 剥。 + if (data.attrName === 'style' && isDangerousStyleValue(data.attrValue)) { + data.keepAttr = false; + return; + } + // audit fix (Round 4 P1-5):非 IMG 的 URI 属性也走 ALLOWED 校验剥 data:image/svg+xml。 + // 限制为 URI 类属性(href / src / action / formaction / xlink:href / cite / longdesc / + // poster / usemap),避免误剥 DOMPurify 自动注入的安全属性(如 target=_blank 时 + // 自动加的 rel="noopener noreferrer")—— 那些值不是 URI,不该过 ALLOWED 校验。 + const URI_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'cite', 'longdesc', 'poster', 'usemap']); + const isUriAttr = URI_ATTRS.has(data.attrName); + if (isDangerousUriNonImg(data.attrValue)) { + data.keepAttr = false; + return; + } + if (isUriAttr && !ALLOWED_URI_REGEXP.test(data.attrValue)) { + // 非 IMG 标签也调一次 ALLOWED_URI_REGEXP.test,与 IMG 路径策略对称。 + // 之前非 IMG 只走 DANGEROUS(负向预查放过 data:image),不二次 ALLOWED + // —— 完全依赖 DOMPurify v3 内置 ALLOWED_URI_REGEXP 全局应用兜底。若未来 + // DOMPurify 配置改动或行为变更, + // 会落地;点击进入 top-level SVG 上下文,跨浏览器脚本执行行为不一致。 + data.keepAttr = false; + } + }); +} + +module.exports = { ALLOWED_URI_REGEXP, installHooks }; \ No newline at end of file diff --git a/shared/settings-schema.js b/shared/settings-schema.js new file mode 100644 index 0000000..2ac3a0b --- /dev/null +++ b/shared/settings-schema.js @@ -0,0 +1,566 @@ +// 设置 schema —— 单一事实源 +// ============================================================================ +// +// 这是 main / preload / renderer 三处设置定义的唯一权威来源。 +// 之前四处漂移(main.js 的 DEFAULT_CONFIG + save-settings 80 行校验链 + renderer +// DEFAULT_SETTINGS + settings-dialog 的选项表)统一收敛到本文件: +// +// • DEFAULT_SETTINGS — 各键的默认值 +// • SETTINGS_SCHEMA — 每键的 type / enum / min / max / custom validator +// • validateAndSanitize() — 用 schema 校验+clamp+enumeration +// • SETTINGS_UI_OPTIONS — 对话框用的枚举列表(readerFontSize / palette / sort) +// +// 加载方式: +// • main.js / preload.js(CJS):const schema = require('../shared/settings-schema.js') +// • renderer:经 preload contextBridge 过桥(window.api.settingsSchema / coerceLoadedSettings) +// —— renderer 跑在 Chromium 原生 ESM,不能直接 import CJS(无 CJS 互操作), +// .cjs / .js 扩展名都不能解决这个问题,文件 *格式* 决定有没有 named export。 +// 所以 renderer 拿到的始终是经 preload 包装过的纯数据 / 函数,schema 内部 +// 结构不暴露。 +// +// 新增/修改设置项流程: +// 1. 在 DEFAULT_SETTINGS 加默认值 +// 2. 在 SETTINGS_SCHEMA 加类型/范围/枚举/自定义校验 +// 3. 若需要在 UI 显示,在 SETTINGS_UI_OPTIONS 加选项 +// 4. 跑 npm run check 验证 — validateAndSanitize 会用 schema 拒绝非法值 +// ============================================================================ + +'use strict'; + +/** + * 默认值(单一事实源)。所有键都在这里定义,缺一不可。 + * @type {Readonly>} + */ +const DEFAULT_SETTINGS = Object.freeze({ + dataDir: null, // null = 用主进程默认目录(home/Notes) + theme: 'dark', // 'dark' | 'light' + themePalette: 'default', // 调色板 ID(见 SETTINGS_UI_OPTIONS.palettes) + alwaysOnTop: false, + editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏 + readerFontSize: 17, // 阅读字号 (px),范围 12..24 + readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2 + fileListSort: 'name', // 'name' | 'mtime-desc' + autoSaveDebounceMs: 500, // 自动保存防抖延迟(ms);0 = 关闭;默认 500ms = 「停打后立刻存」 + splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8 + sidebarWidth: null, // 侧栏拖拽后的宽度(px);null = 使用 CSS 默认 --w-sidebar + aiWidth: null, // AI 中间面板拖拽后的宽度(px);null = 使用 CSS 默认 --w-ai + focusMode: false, // 聚焦模式:隐藏工具栏/侧栏/状态栏;Ctrl+Shift+F 切换 + // AI 修改功能(用户自填 API Key / BaseURL / Model / System Prompt) + // 详见 main/ai.js。空值时 AI 入口点击会提示去设置。 + aiProvider: 'openai', // 'openai' | 'anthropic' —— 决定 main/ai.js 走哪条协议 + aiBaseUrl: '', // baseURL:OpenAI 含 /v1(如 https://api.openai.com/v1);Anthropic 不含(如 https://api.anthropic.com) + aiApiKey: '', // API Key(敏感数据;只在主进程内存里使用,不写日志) + aiModel: '', // 模型名;OpenAI 如 gpt-4o-mini;Anthropic 如 claude-opus-5 + aiSystemPrompt: '', // 自定义系统提示词;空 = 用 main/ai.js 内置中文 prompt +}); + +/** + * 每个键的校验规则。 + * type: 'string' | 'number' | 'boolean' | 'enum' | 'nullable-string' | 'nullable-path' + * enum?: 仅 enum 类型:允许的字面量数组 + * min?: number / integer / string(长度) + * max?: number / integer / string(长度) + * clamp?: number: 是否 clamp 到 [min,max] + * integer?:boolean number 是否取整 + * round?: number: 保留几位小数 + * choices?:Array<{value, label, hint?}> UI 选项(仅供 dialog 渲染,不参与校验) + * + * 自定义校验(如 dataDir 必须存在)放在 validateAndSanitize() 里集中处理, + * 因为它需要 fs 调用,不能纯声明式表达。 + * + * @type {Readonly>} + */ +const SETTINGS_SCHEMA = Object.freeze({ + dataDir: { + type: 'nullable-path', + description: '数据目录;null = 用默认目录', + }, + theme: { + type: 'enum', + enum: ['dark', 'light'], + description: 'UI 主题', + }, + themePalette: { + type: 'enum', + enum: ['default', 'ocean', 'forest', 'lavender', 'sunset'], + description: '调色板', + }, + alwaysOnTop: { + type: 'boolean', + description: '窗口置顶', + }, + editorMode: { + type: 'enum', + enum: ['preview', 'edit', 'split'], + description: '视图模式', + }, + readerFontSize: { + type: 'number', + min: 12, + max: 24, + integer: true, + clamp: true, + description: '阅读字号 (px)', + }, + readerLineHeight: { + type: 'number', + min: 1.4, + max: 2.2, + // fix(audit 2026-08):round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出 + // 的是 [1.5, 1.7, 1.85, 2.0] 两位小数,round:2 才能保留用户的选择。 + round: 2, + description: '阅读行距', + }, + fileListSort: { + type: 'enum', + enum: ['name', 'mtime-desc'], + description: '侧栏文件列表排序', + }, + autoSaveDebounceMs: { + type: 'number', + min: 0, + max: 60000, + clamp: true, + integer: true, + description: '编辑停止后多少毫秒触发自动保存;0 = 关闭', + }, + splitRatio: { + type: 'number', + min: 0.2, + max: 0.8, + round: 3, + clamp: true, + description: '双栏模式左侧占比', + }, + sidebarWidth: { + type: 'nullable-number', + min: 120, + max: 480, + integer: true, + description: '侧栏宽度 (px);null = 用 CSS 默认', + }, + aiWidth: { + type: 'nullable-number', + min: 220, + max: 720, + integer: true, + description: 'AI 中间面板宽度 (px);null = 用 CSS 默认 --w-ai (360)', + }, + focusMode: { + type: 'boolean', + description: '聚焦模式(隐藏工具栏/侧栏/状态栏)', + }, + aiProvider: { + type: 'enum', + enum: ['openai', 'anthropic'], + description: 'AI 服务提供方:openai = /chat/completions;anthropic = /v1/messages', + }, + aiBaseUrl: { + type: 'string', + // URL 不会超过几 KB,留 4 KB 足够;防止有人塞几 MB 把请求体打爆 + max: 4_096, + // audit fix (#9 shared):格式校验。空串放行(用户主动清空 = 关闭 AI); + // 非空必须是可解析的 http(s) URL,避免「abc / www.foo.com / file:///xxx」 + // 这类带空格 / 漏 scheme / 协议错误的值被静默接受,最后在主进程 fetch 时 + // 才抛 TypeError,错误信息很难定位到 settings。trim 后用 URL 解析, + // 协议限定 http: 或 https:(不接 ftp / file / data 等)。 + format: 'url-https', + description: 'baseURL:OpenAI 含 /v1(如 https://api.openai.com/v1);Anthropic 不含(如 https://api.anthropic.com)', + }, + aiApiKey: { + // 密码字段:renderer 通过 IPC 传给主进程,不在 schema 校验链里打印 + type: 'string', + // API key 通常 50~200 字符;留 4 KB 上限足够 + max: 4_096, + description: 'API Key', + }, + aiModel: { + type: 'string', + max: 256, + description: '模型名', + }, + aiSystemPrompt: { + type: 'string', + // 系统提示词较长是合理的,但单个几十 MB 的 prompt 会拖慢 JSON.stringify + // 且让 AI 计费爆炸 —— 200 KB 对应约 5 万中文字,足够绝大多数场景 + max: 200_000, + description: '自定义系统提示词;空 = 用内置默认', + }, +}); + +/** + * UI 选项表 —— 仅供对话框渲染使用。 + * key 与 SETTINGS_SCHEMA 的 enum 对齐,但额外带 label / hint。 + * @type {Readonly>>} + */ +const SETTINGS_UI_OPTIONS = Object.freeze({ + themePalette: Object.freeze([ + { value: 'default', label: '默认' }, + { value: 'ocean', label: '海洋' }, + { value: 'forest', label: '森林' }, + { value: 'lavender', label: '薰衣草' }, + { value: 'sunset', label: '夕阳' }, + ]), + fileListSort: Object.freeze([ + { value: 'name', label: '按名称', hint: 'A → Z,localeCompare(zh-CN)' }, + { value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' }, + ]), + // 自动保存不再需要枚举选项:debounce 延迟是连续数值(0..60000 ms), +// 由 toolbar 按钮直接 toggle 0 ↔ 500,UI 也不再有「选几秒」的下拉。 + readerFontSize: Object.freeze([14, 15, 17, 19, 22]), + readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]), + // 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商; + // 用户可填任意走该协议的 baseURL(中转、自部署、官方 API 都行)。 + aiProvider: Object.freeze([ + { value: 'openai', label: 'OpenAI 兼容', hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等' }, + { value: 'anthropic', label: 'Anthropic 兼容', hint: '/v1/messages · Claude 系列 · 含第三方中转' }, + ]), +}); + +/** + * 校验 + sanitize 单个键。 + * 返回 { ok:true, value } 或 { ok:false, error }。 + * + * @param {string} key + * @param {*} raw + * @param {object} [opts] - { resolveDir?: async (path) => { ok, error? } } + * 注入目录存在性校验(默认走 fs,调用方可传 mock) + * @returns {Promise<{ok: true, value: *} | {ok: false, error: string}>} + */ +async function validateKey(key, raw, opts = {}) { + const rule = SETTINGS_SCHEMA[key]; + if (!rule) { + // 未知键直接拒绝(防止 renderer 误传) + return { ok: false, error: `未知设置项: ${key}` }; + } + + // nullable 类型:null 一律放行 + if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) { + return { ok: true, value: null }; + } + + // 类型分发 + switch (rule.type) { + case 'nullable-path': { + if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` }; + const trimmed = raw.trim(); + if (!trimmed) return { ok: true, value: null }; + if (opts.resolveDir) { + const r = await opts.resolveDir(trimmed); + if (!r.ok) return { ok: false, error: r.error }; + } + return { ok: true, value: trimmed }; + } + case 'nullable-string': { + if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` }; + if (typeof rule.max === 'number' && raw.length > rule.max) { + return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` }; + } + return { ok: true, value: raw }; + } + case 'string': { + if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串` }; + // P2 fix:trim 首尾空白 —— 用户复制粘贴 AI Key / Model 经常带回车 / 空格, + // 不 trim 会让 main/ai.js 把它当字面量放进 Authorization header / 请求体, + // 服务端校验「key 不匹配」但错误信息没有「多打了空格」的提示,用户无从下手。 + // 用户手动改 settings.json 也经常留尾空格。 + const trimmed = raw.trim(); + // rule.max 限定字符串长度 —— 防止几 MB 的值把请求体撑爆 / 拖慢序列化。 + // 用户看到的错误直接说"过长",避免报"非法 JSON"。 + // (audit fix:之前误写成 opts.max,导致 aiApiKey/aiBaseUrl/aiModel/ + // aiSystemPrompt 声明的上限从未生效。) + if (typeof rule.max === 'number' && trimmed.length > rule.max) { + return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` }; + } + // audit fix (#9 shared):可选 format 校验。空串放行(清空合法); + // 非空按 format 规则走,失败给中文错误。 + if (trimmed && rule.format === 'url-https') { + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return { ok: false, error: `${key} 不是合法的 URL(需以 http:// 或 https:// 开头)` }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: `${key} 必须是 http(s) URL(当前协议 ${parsed.protocol})` }; + } + } + return { ok: true, value: trimmed }; + } + case 'boolean': { + if (typeof raw !== 'boolean') return { ok: false, error: `${key} 必须是布尔` }; + return { ok: true, value: raw }; + } + case 'number': { + // audit C3:拒绝 null / undefined / 空串 / 非数字 —— Number('') === 0 会 + // 让 `readerFontSize: ''` 静默通过 clamp 到 min;这是 bug,不是「合法 0」。 + // 合法输入:实际数字 或 非空数字字符串。 + if (raw === null || raw === undefined) { + return { ok: false, error: `${key} 必须是数字` }; + } + if (typeof raw === 'string' && raw.trim() === '') { + return { ok: false, error: `${key} 必须是数字` }; + } + const n = Number(raw); + if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字` }; + let v = n; + if (rule.clamp && (v < rule.min || v > rule.max)) { + v = Math.min(Math.max(v, rule.min), rule.max); + } + if (rule.integer) v = Math.round(v); + if (typeof rule.round === 'number') { + const k = 10 ** rule.round; + v = Math.round(v * k) / k; + } + if (v < rule.min || v > rule.max) { + return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` }; + } + return { ok: true, value: v }; + } + case 'nullable-number': { + // audit fix (Round 9):与 'number' 分支对齐显式拒绝 null / undefined / + // 空串 / 非数字。Number('') === 0 会让 nullable-number 字段把空串静默 + // 转成 0 再 clamp 到 min —— 与上面 'number' 分支同款 bug。null 是 + // 合法值(保留为 null,让 UI 显示「未设置」),但空串 / 非数字必须拒绝。 + // + // 审计修复 (Round 11 deep-fix P2-3):undefined 在 nullable 字段应等价于 null。 + // 旧版异步路径拒绝 undefined → 同步 sanitizeSync 路径却把 undefined 当作 null; + // 手改 config.json 时如果字段被删(JSON.stringify 会序列化成 undefined → 字段缺失, + // 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。 + // 现在 undefined 走「视为 null」分支。 + if (raw === null || raw === undefined) return { ok: true, value: null }; + if (typeof raw === 'string' && raw.trim() === '') { + return { ok: false, error: `${key} 必须是数字或 null` }; + } + const n = Number(raw); + if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` }; + let v = n; + if (rule.integer) v = Math.round(v); + if (v < rule.min || v > rule.max) { + return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` }; + } + return { ok: true, value: v }; + } + case 'enum': { + // enum:值在列表内即放行(兼容 string / number 字面量;如 0 也要命中) + const allowed = rule.enum.some((e) => e === raw) || + (typeof raw === 'string' && rule.enum.includes(raw)); + if (!allowed) return { ok: false, error: `${key} 取值非法: ${raw}` }; + return { ok: true, value: raw }; + } + default: + return { ok: false, error: `${key} 类型未定义: ${rule.type}` }; + } +} + +/** + * 批量校验 + sanitize。 + * 跳过未在 partial 里出现的键(局部更新)。 + * + * @param {object} partial + * @param {object} [opts] - 同 validateKey + * @returns {Promise<{ok: true, sanitized: object} | {ok: false, error: string}>} + */ +async function validateAndSanitize(partial, opts = {}) { + if (!partial || typeof partial !== 'object' || Array.isArray(partial)) { + return { ok: false, error: '请求体必须是对象' }; + } + /** @type {Record} */ + const sanitized = {}; + for (const key of Object.keys(partial)) { + const r = await validateKey(key, partial[key], opts); + if (!r.ok) return r; + sanitized[key] = r.value; + } + return { ok: true, sanitized }; +} + +/** + * 与默认合并 + 校验完整 settings 对象(启动 / 读取配置文件时用)。 + * + * 同步校验 —— 故意不 await fs 检查 dataDir 存在性(load 阶段不阻塞); + * dataDir 存在性推迟到 settings-store.update 时再走 validateAndSanitize。 + * 但同步可校验的部分(enum / 范围 / 类型)必须现在就做,否则手改的 + * config.json 会把整个 UI 弄坏(audit #8)。 + * + * @param {*} raw + * @returns {Record} + */ +function coerceLoadedSettings(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ...DEFAULT_SETTINGS }; + } + + // 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖) + // 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位 + // (旧 3 秒、10 秒)一律映射到新的 500ms 默认值,避免老用户升级后自动保存 + // 突然变得非常激进(1s 内反复触发)或太迟(10s 才一次)。 + if ('autoSaveIntervalSec' in raw) { + const old = raw.autoSaveIntervalSec; + if (!('autoSaveDebounceMs' in raw)) { + const offish = old === 0 || old === '0' || old === false; + raw.autoSaveDebounceMs = offish ? 0 : 500; + } + delete raw.autoSaveIntervalSec; + } + + /** @type {Record} */ + const out = { ...DEFAULT_SETTINGS }; + for (const key of Object.keys(DEFAULT_SETTINGS)) { + if (!(key in raw)) continue; + const sanitized = sanitizeSync(key, raw[key]); + // sanitizeSync 返回 undefined 表示「未知键 / 不可修复」,跳过即可 + if (sanitized !== undefined) out[key] = sanitized; + } + // Phase N 修复:保留白名单内的 `_`-前缀元数据键(当前只有 `_hasAiKey`)。 + // 之前只迭代 Object.keys(DEFAULT_SETTINGS) 把未声明的键全 drop —— get-settings + // 返回的 `_hasAiKey: !!cfg.aiApiKey` 在 coerce 阶段被吃掉,renderer 永远拿不到 + // "已配置 API Key" 信号,"显示已填 key" + reveal 流程全失效。 + // + // 防御:必须用白名单而不是"所有下划线前缀键都过"。否则攻击者 / 误用方可用 + // `_xxx` 形式把任意字段塞进内存 settings(虽然不会写盘,但能在内存里残留)。 + // 白名单维护成本低(已知元数据键只有少数几个),但放行成本高。 + const METADATA_KEYS = new Set(['_hasAiKey']); + for (const key of Object.keys(raw)) { + if (METADATA_KEYS.has(key) && !(key in out)) { + out[key] = raw[key]; + } + } + return out; +} + +/** + * 同步版本的单键 sanitize —— 只做不依赖 IO 的检查。 + * 与 validateKey 共享规则,但把 fs 检查(nullable-path)推迟到 update 阶段。 + * + * @param {string} key + * @param {*} raw + * @returns {*} 合法值;不可修复时返回 undefined(调用方应忽略这个键) + */ +function sanitizeSync(key, raw) { + const rule = SETTINGS_SCHEMA[key]; + if (!rule) return undefined; // 未知键:丢弃 + + // nullable:null 合法 + if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) { + return null; + } + + switch (rule.type) { + case 'nullable-path': + case 'string': { + if (typeof raw !== 'string') return undefined; + // audit fix (Round 4 P1-1):nullable-path 与 validateKey 异步路径对齐——空串归一为 null。 + // 之前 sanitizeSync 直接返回 '',coerceLoadedSettings 把磁盘上残留的 + // `"dataDir": ""` 保留为 '',但 validateKey 异步路径会归一为 null,两条路径 + // 语义不同步 → 任何依赖 dataDir === null 判断的代码失配(resolveDataDir + // 靠 .trim() 兜底不崩但漏检 null 路径)。 + if (rule.type === 'nullable-path' && raw.trim() === '') return null; + // 同步路径(coerceLoadedSettings / sanitizeSync)也要尊重 max —— 用户 + // 手工改坏 settings.json 时同样不能让几 MB 的字符串进入运行时。 + if (typeof rule.max === 'number' && raw.length > rule.max) return undefined; + // 审计修复 (Round 11 deep-fix P2-3):同步路径也 trim,前后空白不再让 URL 校验 + // 失败。async validateKey 早就 trim(Round 8 fix),同步路径遗漏导致 + // load(): 不 trim 直接 new URL(' https://api.example.com ') + // update(): trim 后校验 + // 两次读同一字段返回不同值,UI 看着值变了(实际上是同一字符串的展示差异)。 + // 注意:trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分; + // 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路, + // 但保留 nullable-path 上面已经拦截过的场景)。 + const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw; + if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined; + // fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKey(async 路径) + // 校验 format,coerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl + // 写成 "not-a-url" / "ftp://xxx" 也会被加载,渲染端拿到的值是无效 URL, + // 真正 fetch 时才报 TypeError: fetch failed,错误链很难定位到 settings。 + if (trimmed.length > 0 && rule.format === 'url-https') { + // 必须前缀严格是 http:// 或 https://,避免 'http:/missing-slash' 这种 + // URL 构造器能解析但实际 fetch 行为不一致的 case。 + if (!/^https?:\/\//i.test(trimmed)) return undefined; + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return undefined; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined; + } + return trimmed; + } + case 'nullable-string': { + if (typeof raw !== 'string') return undefined; + if (typeof rule.max === 'number' && raw.length > rule.max) return undefined; + // fix(audit 2026-08):nullable-string 与 string 一样需要 format 校验, + // 否则未来 schema 加 nullable-string + format 字段会静默失效。 + if (raw.length > 0 && rule.format === 'url-https') { + if (!/^https?:\/\//i.test(raw)) return undefined; + let parsed; + try { + parsed = new URL(raw); + } catch { + return undefined; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined; + } + return raw; + } + case 'boolean': + return typeof raw === 'boolean' ? raw : undefined; + case 'number': { + // fix(audit 2026-08):拒绝 Boolean 输入。Number(true) === 1, + // Number(false) === 0 —— 旧版会让 autoSaveDebounceMs: true 静默变 1ms + // (激进到每个 keystroke 都存),splitRatio: false 变 0(无预览面板)。 + // 合法输入限定为 number 或非空数字字符串。 + if (raw === null || raw === undefined) return undefined; + if (typeof raw !== 'number' && typeof raw !== 'string') return undefined; + if (typeof raw === 'string' && raw.trim() === '') return undefined; + const n = Number(raw); + if (!Number.isFinite(n)) return undefined; + let v = n; + if (rule.clamp && (v < rule.min || v > rule.max)) { + v = Math.min(Math.max(v, rule.min), rule.max); + } + if (rule.integer) v = Math.round(v); + if (typeof rule.round === 'number') { + const k = 10 ** rule.round; + v = Math.round(v * k) / k; + } + // clamp 后还在范围外(例如 raw 是 NaN / Infinity)→ 拒收 + if (v < rule.min || v > rule.max) return undefined; + return v; + } + case 'nullable-number': { + // audit fix (Round 4 P2-3):与 number 分支对称——拒绝 Boolean 输入。 + // 原版无 typeof 守卫,Number(true) === 1 隐式通过 isFinite;目前 schema + // 用 nullable-number 的字段(sidebarWidth / aiWidth)min 检查会拦下 1, + // 但语义上与 number 不一致,且未来加更宽 min 范围的字段会绕过。复制上方 + // number 分支的 typeof 守卫保持两条路径对称。 + if (raw === null || raw === undefined) return null; + if (typeof raw !== 'number' && typeof raw !== 'string') return undefined; + if (typeof raw === 'string' && raw.trim() === '') return undefined; + const n = Number(raw); + if (!Number.isFinite(n)) return undefined; + let v = n; + if (rule.integer) v = Math.round(v); + if (v < rule.min || v > rule.max) return undefined; + return v; + } + case 'enum': { + // 数字 enum 接受 number,字符串 enum 接受 string 字面量 + const ok = rule.enum.some((e) => e === raw) + || (typeof raw === 'string' && rule.enum.includes(raw)); + return ok ? raw : undefined; + } + default: + return undefined; + } +} + +module.exports = { + DEFAULT_SETTINGS, + SETTINGS_SCHEMA, + SETTINGS_UI_OPTIONS, + validateKey, + validateAndSanitize, + coerceLoadedSettings, +}; diff --git a/shared/slug.js b/shared/slug.js new file mode 100644 index 0000000..e668ce4 --- /dev/null +++ b/shared/slug.js @@ -0,0 +1,78 @@ +// Heading slug 的唯一算法(preload + renderer 共用) +// +// 唯一调用点:把 heading 文本转成 [A-Za-z0-9-]+ 形式的 slug id。 +// 因为 preload(marked renderer.heading)和 renderer 端都会 +// 产生「指向同一 DOM 节点」的 id,必须用同一份算法 —— 否则 +// 文内锚点会因 id 不一致定位失败。 +// +// 设计取舍: +// - 保留 \p{L} / \p{N} / \p{M}:CJK(含扩展平面 A/B/...)+ 拉丁扩展字母都能保留 +// - 删除 markdown 行内 HTML 标签 / 反引号 / 星号 / 下划线 / 波浪号: +// 这些「标记符号」不应进 id,否则直接复制渲染出的 id 会得到带引号的字符串 +// (下划线一并剥离,与 marked 旧默认行为一致;GitHub 是保留的, +// 但那会让 `hello_world` / `_em_` 这种 heading 算成同一个 base,撞名重) +// - 空白 → `-`;不裁首尾 `-`:避免 `# --foo--` → `foo` 这种出乎用户意料的别名 +// - 纯符号 / 空白输入返回 `''`:由 `slugifyHeading`(带 `seenSlugs` 的版本) +// 兜底成 `'section'`,保证渲染出的 DOM id 非空可点击 +// +// 这份文件被两类消费者使用: +// - preload.js(CJS,require):marked renderer 决定 DOM 上的真实 id +// - src/outline.js(renderer ESM):源码级镜像一份(renderer 不能 import CJS) +// 改算法 = 同步改两边 + 测试 + 检查 outline.test.js 与 slug.test.js 的期望值。 + +'use strict'; + +/** + * 把 heading 文本转成「基础 slug」(无重复检测、无空值兜底)。 + * + * 返回空串意味着:原文剥完 HTML + 行内标记后什么都不剩(纯符号 heading)。 + * outline.js 用这一点判断是否要跳过这个 heading(导航没意义)。 + * preload.js 的 slugifyHeading 把它当 base,再走 `|| 'section'` 兜底 + 撞名加后缀。 + * + * @param {string} raw + * @returns {string} + */ +function slugifyHeadingBase(raw) { + // audit fix (shared-M5):先 normalize('NFC') 把 NFD 字符串(如 macOS + // 默认文件系统产出的 café 这种「e + 组合 ́」)合并成预组合字符, + // 再做后续 replace。否则 NFD 与 NFC 的同一逻辑 heading 会生成两个不同 + // DOM id,outline 点击就会跳到错误锚点。Windows / WSL / 云盘同步经常会 + // 带来混合 normalization,这个守卫保证 slug 只看逻辑字符。 + return String(raw) + .normalize('NFC') + .replace(/<[^>]*>/g, '') // 行内 HTML + .replace(/[`*_~]/g, '') // 行内标记符号 + .trim() + .toLowerCase() + // \p{L} = 任意 Unicode 字母(含中日韩),\p{N} = 数字,\p{M} = 组合记号 + .replace(/[^\p{L}\p{N}\p{M}\s-]/gu, '') + .replace(/\s+/g, '-'); +} + +/** + * 带重复检测的 slug 生成器。 + * + * 同名 heading 在文档内会得到 `-1`、`-2`、... 后缀(marked 默认行为)。 + * 调用方负责在每次「解析整篇文档」前清空 `seenSlugs`,让计数按文档重置。 + * 空 base 回退 `'section'`,保证 id 始终非空可点击。 + * + * @param {string} raw + * @param {Set} seenSlugs - 已被本次解析用过的 slug 集合 + * @returns {string} + */ +function slugifyHeading(raw, seenSlugs) { + const base = slugifyHeadingBase(raw) || 'section'; + let slug = base; + let i = 1; + while (seenSlugs.has(slug)) { + slug = `${base}-${i}`; + i += 1; + } + seenSlugs.add(slug); + return slug; +} + +module.exports = { + slugifyHeadingBase, + slugifyHeading, +}; \ No newline at end of file diff --git a/src/ai/ai-chat-panel.js b/src/ai/ai-chat-panel.js new file mode 100644 index 0000000..8adeac0 --- /dev/null +++ b/src/ai/ai-chat-panel.js @@ -0,0 +1,453 @@ +// AI 对话面板(底部 dock) +// ============================================================================ +// +// 给一段自然语言修改要求 + 当前文件内容,发给主进程调 AI。 +// 状态机:idle → submitting → (success | error | cancelled) → idle +// 不持有 diff 数据(diff 走 AiDiffPanel / AiController)。 +// +// 职责边界: +// - 状态展示(status / error 通过 onNotify 转发到状态栏 #status-ai chip) +// - 收集用户输入 +// - 防 IME(compositionstart/end 期间不响应 Enter) +// - 失败重试:失败后用户改完提示词直接按 Enter 重新提交,不用关面板 +// +// 状态展示迁移([feedback-ai-tips-into-status-bar]):原本写在自己 +// .ai-chat-status-row 的过程/错误文案,现在通过 onNotify(text, type) 转发 +// 给外部(通常是 statusbar 上的 AI chip)。dock 本身只剩输入行,不再占底部一行。 +// ============================================================================ + +import { AI_ERROR } from './ai-status.js'; + +function createRequestId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `ai-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * 长提示词软警告阈值(按字符计,UTF-8 近似字节;CJK 是 3 字节/字符,这里保守 + * 按 ASCII 1 字节算,实际 CJK 阈值会更大一些,方向是「宁可漏报也不骚扰」)。 + * 32KB 是经验阈值:超过后多数模型服务开始明显变慢,部分服务开始截断。 + */ +const LONG_PROMPT_BYTES = 32 * 1024; + +/** + * 把 controller 的中性状态文案映射到 chip 的视觉类型。 + * panel 这一侧不深推;不识别就回退 'success'(中性 muted)。 + * - 空文本 → 'idle'(chip 回到"无动作"中性态,只显示 "AI" 标签) + * - '正在发送...' → running + * - '正在取消...' → running(用户操作中) + * - '已取消...' → cancelled + * 其它走 success。controller 在三处显式调 onNotify(_, 'diff'),不走这条路径。 + * + * 注:空文本返回 'success' 会让 chip 的 success dot 一直亮着,掩盖「已回到 + * 无动作态」的信号 —— 用户看到 chip 高亮会以为还有事在跑。所以空文本走 'idle'. + * + * @param {string} text + * @returns {'idle'|'running'|'cancelled'|'success'} + */ +function inferStatusType(text) { + if (!text) return 'idle'; + if (text.indexOf('正在发送') === 0 || text.indexOf('正在取消') === 0) return 'running'; + if (text.indexOf('已取消') === 0) return 'cancelled'; + // 'info' 是 panel 主动调的(如长提示词提示),不参与被动推断 + return 'success'; +} + +export class AiChatPanel { + /** + * @param {object} options + * @param {HTMLElement} options.element - 容器 div(已在 index.html 写好骨架) + * @param {(payload: {prompt:string, requestId:string}) => Promise<{ok:boolean, id?:string, content?:string, responseFormat?:string, error?:string, message?:string}>} options.onSubmit + * @param {(requestId:string) => void} options.onCancel + * @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify] + * 把过程/错误文案转发到外部 UI(statusbar 上的 #status-ai chip)。不传则静默丢弃。 + */ + constructor({ element, onSubmit, onCancel, onNotify }) { + this.element = element; + this.onSubmit = onSubmit; + this.onCancel = onCancel; + this.onNotify = typeof onNotify === 'function' ? onNotify : null; + this._open = false; + this._enabled = true; + this._submitting = false; + this._composing = false; + this._currentRequestId = null; + this._cancelled = null; // Set:被用户取消的 requestId 集合;懒创建于 _triggerCancel + this._listeners = []; + this._bind(); + } + + _bind() { + const input = this.element.querySelector('.ai-chat-input'); + const submitBtn = this.element.querySelector('.ai-chat-submit'); + const toggleBtn = this.element.querySelector('.ai-chat-toggle'); + + if (input) { + this._listeners.push([input, 'input', () => { + this._syncSubmitButton(); + this._syncLongPromptHint(); + }]); + this._listeners.push([input, 'compositionstart', () => { this._composing = true; }]); + this._listeners.push([input, 'compositionend', () => { this._composing = false; }]); + // 兜底:IME 异常不发 compositionend 时,_composing 会永远卡 true, + // 导致 Enter 永久失效。blur 时强制重置(用户切走时输入已结束)。 + this._listeners.push([input, 'blur', () => { this._composing = false; }]); + this._listeners.push([input, 'keydown', (e) => { + // Enter = 提交(不 Shift);IME 输入中不发 + if (e.key === 'Enter' && !e.shiftKey && !this._composing) { + e.preventDefault(); + this._triggerSubmit(); + } + }]); + } + // 没有 .ai-chat-form 包装时(index.html 当前结构),按钮点击就是触发的来源 + if (submitBtn) { + this._listeners.push([submitBtn, 'click', (e) => { + // submitting 时:点击 = 取消(需要在 _triggerSubmit 之前 preventDefault) + if (this._submitting) { + e.preventDefault(); + this._triggerCancel(); + return; + } + // 否则:提交 + e.preventDefault(); + this._triggerSubmit(); + }]); + } + if (toggleBtn) { + this._listeners.push([toggleBtn, 'click', () => this.close()]); + } + for (const [el, ev, fn] of this._listeners) { + el.addEventListener(ev, fn); + } + } + + open() { + this._open = true; + this.element.hidden = false; + // C1 fix (audit):close→open 路径上若 _submitting 状态残留(IPCF 还没回包), + // input.disabled 仍为 true,输入框会被永久禁用。这里走 _inputShouldBeDisabled() + // 合并所有禁用条件,_submitting 自身的回包路径仍会通过 finally 再次同步状态。 + const input = this.element.querySelector('.ai-chat-input'); + if (input) input.disabled = this._inputShouldBeDisabled(); + this._syncSubmitButton(); + // 自动聚焦输入框(用户点按钮来开) + requestAnimationFrame(() => { + const el = this.element.querySelector('.ai-chat-input'); + if (el && !el.disabled) el.focus(); + }); + } + + close() { + this._open = false; + this.element.hidden = true; + // 关闭时若还有请求:撤销 + if (this._submitting) this._triggerCancel(); + // P3 fix:hung 请求兜底 —— _triggerCancel 只是把 cancel 发给主进程;如果 AI + // 服务永远不回包,_triggerSubmit 的 await 永远不会 resolve,finally 永远跑不到, + // _submitting 卡在 true。close 不强制复位,下次 open 时用户按提交会被 + // `if (this._submitting) return` 挡住、误以为按钮坏了。 + // audit fix (Phase O-L18):原 `if (this._submitting && this._canceling)` 是 + // 死代码 —— _canceling 从未置 true(line 376-377 注释明确说不设),整个 if + // 分支永远不进。简化:仅看 _submitting;notify 不动(让残留的 in-flight + // 旧请求自己跑完)。输入框 / 输入按钮的 disabled 由下次 open() + setEnabled() 同步。 + if (this._submitting) { + this._submitting = false; + this._currentRequestId = null; + this._syncSubmitButton(); + } + // 关掉 dock 时清掉状态栏 chip —— 否则 chip 会留下上次的"已应用"或"AI 请求失败" + // 残留,让用户误以为是新请求的结果 + this._notify('', 'idle'); + } + + isOpen() { + return this._open; + } + + /** + * 当主进程给出新内容(成功路径),清空输入框。 + * 失败 / 取消保留输入框,方便用户改完直接重试。 + */ + resetInput() { + const input = this.element.querySelector('.ai-chat-input'); + if (input) input.value = ''; + this._syncSubmitButton(); + } + + /** + * 强制重置整个面板状态(status / error / submitting / 输入框 disabled)。 + * controller 在 onFileChanged / closeAll 时调用,避免切文件后残留 + * "正在提交…" 状态 / 错误行。 + * + * Bug-3 fix (audit):同时清掉 _cancelled Set —— 切文件/重置时可能还有 + * "用户取消但 await 还没回" 的 requestId 留在 Set 里。reset 不清的话: + * 1. 用户在新文件重新提交 → 触发 _triggerSubmit 的 cancelled.has(requestId) + * 检查时,可能误命中旧 requestId(UUID 冲突概率虽低,但存在); + * 2. Set 只增不减,长期使用会有轻微内存泄漏。 + * _cancelled 是懒创建(_triggerCancel 第一次调用时),所以这里要 lazy-init。 + */ + reset() { + this._submitting = false; + this._canceling = false; + this._currentRequestId = null; + if (this._cancelled) this._cancelled.clear(); + const input = this.element.querySelector('.ai-chat-input'); + if (input) { + input.disabled = !this._enabled; + input.value = ''; + } + this._notify('', 'idle'); + this._syncSubmitButton(); + } + + /** + * 没有打开文件 / 没有可修改内容时,禁用面板。 + * @param {boolean} enabled + */ + setEnabled(enabled) { + this._enabled = !!enabled; + const input = this.element.querySelector('.ai-chat-input'); + const submitBtn = this.element.querySelector('.ai-chat-submit'); + if (input) { + input.disabled = this._inputShouldBeDisabled(); + input.placeholder = this._enabled ? '描述你想如何修改当前文档...' : '请先打开一个 Markdown 文件'; + } + if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled(); + } + + /** + * 单一来源:输入框 / 提交按钮的 disabled 取值。 + * 把"未启用 + 提交中 + 空输入"三个条件集中在一处 —— open() / setEnabled() / + * _syncSubmitButton() / finally 路径都走这里,避免某条路径漏合并 _submitting 状态 + * 而让用户在 AI 响应期间又能输入新内容(会让新输入框内容与正在响应的内容竞速)。 + * @returns {boolean} + */ + _inputShouldBeDisabled() { + return !this._enabled || !!this._submitting; + } + + /** + * 应用启动后调用:根据"是否有打开的文件"同步 disabled。 + * @param {boolean} hasFile + */ + syncFileState(hasFile) { + this.setEnabled(hasFile); + } + + _syncSubmitButton() { + const input = this.element.querySelector('.ai-chat-input'); + const submitBtn = this.element.querySelector('.ai-chat-submit'); + if (!submitBtn) return; + if (this._submitting) { + submitBtn.textContent = '取消'; + submitBtn.dataset.mode = 'cancel'; + // a11y #1(critical):按钮文字在「生成修改 / 取消」之间切换时, + // aria-label 必须同步 —— 屏幕阅读器只读 aria-label,不读 textContent, + // 否则用户听到的还是 "生成修改",与可见状态完全失配。 + submitBtn.setAttribute('aria-label', '取消 AI 请求'); + } else { + const empty = !input || input.value.trim().length === 0; + submitBtn.textContent = '生成修改'; + submitBtn.dataset.mode = 'submit'; + submitBtn.setAttribute('aria-label', '生成 AI 修改'); + // 非 submitting 时只受「未启用 + 空输入」影响(_inputShouldBeDisabled 已隐含 !_submitting) + submitBtn.disabled = !this._enabled || empty; + } + } + + /** + * audit fix (Phase L3-AI 1):超长提示词的软警告。 + * + * 用户在 AI dock 里粘贴一大段(比如复制整个章节当 prompt),主进程要把全文 + * 拼进 IPC payload 一次性送给模型服务。提示词越长,IPC 序列化 / 网络上传 / + * 模型处理时间都线性涨,且超出模型上下文窗口会被服务端截断甚至 400。 + * + * UX:仅在 input value 跨过 LONG_PROMPT_BYTES(≈32KB)阈值时,向 onNotify + * 推一条短提示到 statusbar 的 #status-ai chip;用户删回阈值下后立刻清回 + * idle(不污染后续 submit/cancel 的 chip 文案)。 + * + * 注意:不算 input.value 实际 trim 后的 prompt 字节——用户粘的是「即将提交的 + * 文本」,input.value 是最接近的真相;trim 后的差异在 32KB 量级上无意义。 + * 不要在这里 throw —— 监听器抛错会让 input 整体崩(绑定时未用 { signal }, + * addEventListener 会把异常往上抛到 dispatch)。 + */ + _syncLongPromptHint() { + const input = this.element.querySelector('.ai-chat-input'); + if (!input) return; + // chip 已被 submit / cancel / error 占用时不抢戏;只在 idle 状态下显示提示 + if (this._submitting) return; + const len = input.value.length; + if (len >= LONG_PROMPT_BYTES) { + const kb = Math.round(len / 1024); + this._notify(`提示词较长(约 ${kb} KB),AI 响应可能变慢`, 'info'); + } else if (len === 0) { + // 空输入让 chip 回到 idle;用户已清空提示词就别再保留长提示 + this._notify('', 'idle'); + } + // 中间区间(>0 且 < 阈值)保持现状:不打扰用户 + } + + async _triggerSubmit() { + if (!this.onSubmit) return; + if (this._submitting) return; + if (!this._enabled) return; + const input = this.element.querySelector('.ai-chat-input'); + const prompt = (input && input.value || '').trim(); + if (!prompt) { + // M7 fix (audit):守卫失败的分支也会被 Enter 触发(例如上一次 success 后用户 + // 直接回车提交空字符串)。早返回前把 chip 清回 idle,避免 chip 残留上一次的 + // success / running 视觉误导用户以为"还有事在跑"或"已经提交过了"。 + this._notify('', 'idle'); + return; + } + + const requestId = createRequestId(); + this._currentRequestId = requestId; + this._submitting = true; + this._canceling = false; + this._notify('正在发送请求…', 'running'); + this._syncSubmitButton(); + if (input) input.disabled = true; + + try { + const result = await this.onSubmit({ prompt, requestId }); + // 已被新请求顶掉(用户点了"取消"或再次提交):静默 + if (this._currentRequestId !== requestId) return; + // P1-3 fix (audit):用户主动点了「取消」→ 忽略该 requestId 的响应, + // 不 resetInput,不展示 AI 结果。 + if (this._cancelled && this._cancelled.has(requestId)) { + this._cancelled.delete(requestId); + this._notify('已取消 AI 请求', 'cancelled'); + return; + } + if (result && result.ok) { + // 成功状态文案由 controller 算(依赖是否有 diff)后调用 setStatus + this.resetInput(); + // 输入框保持 disabled 由 setEnabled 决定 + } else if (result && result.error === AI_ERROR.ERR_CANCELLED) { + this._notify('已取消 AI 请求', 'cancelled'); + } else { + const msg = (result && result.message) ? result.message : 'AI 修改失败'; + this._notify(msg, 'error'); + } + } catch (e) { + if (this._currentRequestId !== requestId) return; + // L17 fix (audit):catch 分支的 e.message 可能来自 IPC 序列化失败 / 主进程 + // 抛错 / controller 内部 bug,里面可能含路径 / stack / 内部常量。与其 + // echo 给用户(泄露内部细节),不如走中性文案 + console 留痕供排查。 + console.error('[ai-chat-panel] onSubmit threw:', e); + this._notify('AI 修改失败,请稍后重试', 'error'); + } finally { + if (this._currentRequestId === requestId) { + this._submitting = false; + this._canceling = false; + this._currentRequestId = null; + const i = this.element.querySelector('.ai-chat-input'); + if (i) i.disabled = !this._enabled; + this._syncSubmitButton(); + } + } + } + + _triggerCancel() { + if (!this._submitting) return; + if (!this._currentRequestId) return; + const oldRequestId = this._currentRequestId; + // P1-3 fix (audit):先标记该 requestId 为 cancelled,让 _triggerSubmit + // 后续 await 返回时直接丢弃(不 resetInput / 不应用结果)。 + if (!this._cancelled) this._cancelled = new Set(); + this._cancelled.add(oldRequestId); + // P2-1 fix (audit):onCancel 抛错不要立即写 error 行 —— 让 _triggerSubmit + // 的 result 分支统一根据 error code 决定显示「已取消」或「失败」。 + try { + if (this.onCancel) this.onCancel(oldRequestId); + } catch (e) { + console.warn('[ai-chat-panel] onCancel threw:', e); + } + // audit fix (Phase L3-RACE 1):cancel 后立刻允许用户提交新 prompt,不必等 + // 主进程响应真正 abort 后 _triggerSubmit 的 finally 才复位 _submitting。 + // 旧 _triggerSubmit 的 finally 因 currentRequestId !== requestId 会跳过状态 + // 重置(看下面那个 `if (this._currentRequestId === requestId)` 守卫), + // 所以提前清是安全的;用户立刻打新 prompt + Enter 不会被 `if (this._submitting) return` 拦住。 + // 真正的取消通知由旧 _triggerSubmit 的 _cancelled.has 分支产生 '已取消 AI 请求'。 + this._submitting = false; + this._currentRequestId = null; + this._notify('已取消 AI 请求', 'cancelled'); + const input = this.element.querySelector('.ai-chat-input'); + if (input) input.disabled = !this._enabled; + this._syncSubmitButton(); + } + + /** + * controller 调:强制取消(用于切文件等场景)—— 即使 chatPanel 自己不知道 + * 这次提交是否还"在 submitting 状态",都把 IPC cancel 发出去。 + * @param {string} requestId + */ + cancelRequest(requestId) { + if (typeof requestId === 'string' && requestId) { + // L6 fix (audit):先把 requestId 加进 _cancelled —— 即使 IPC aiCancel 失败 + // (main 端找不到 pending、abort 没赶上),_triggerSubmit 的 await 返回时 + // 仍会命中 _cancelled.has() 短路,显示「已取消」而不是误导性错误信息。 + if (!this._cancelled) this._cancelled = new Set(); + this._cancelled.add(requestId); + if (this.onCancel) { + try { + this.onCancel(requestId); + } catch (e) { + // audit fix (1.2):controller 在切文件 / 用户强退等关键路径调用本方法, + // 即便 IPC 失败也不应给用户弹红色错误行(瞬时态冲突、误导)。 + // 只在 console 留痕即可。 + console.warn('[ai-chat-panel] cancelRequest 抛错:', e); + } + } + } + } + + /** + * controller 调:更新 chip 状态文案(兼容层 —— 旧 controller 路径会继续用)。 + * 内部按文案前缀推断 chip 视觉类型(running / cancelled / success)。 + * 若 controller 想强制类型(如 'diff'),请直接调 onNotify。 + * @param {string} text + */ + setStatus(text) { + this._notify(text || '', inferStatusType(text)); + } + + /** + * controller 调:把错误文案写到 chip(danger 色)。 + * @param {string} text + */ + setError(text) { + this._notify(text || '', 'error'); + } + + /** + * 内部统一出口:把 (text, type) 转发到 onNotify,未注入则静默。 + * 见 [feedback-ai-tips-into-status-bar]:所有 AI 相关提示都收进底部状态栏 chip, + * 不再在 dock 底部单开一行。 + * @param {string} text + * @param {'idle'|'running'|'cancelled'|'error'|'success'|'diff'} type + */ + _notify(text, type) { + if (this.onNotify) { + try { this.onNotify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ } + } + } + + destroy() { + for (const [el, ev, fn] of this._listeners) { + el.removeEventListener(ev, fn); + } + this._listeners = []; + // P3-5 fix (audit):destroy 也要清内部状态,避免 bindMountPoint 重新 + // 调用时旧实例残留 _composing / _submitting / _canceling / _currentRequestId + this._composing = false; + this._submitting = false; + this._canceling = false; + this._currentRequestId = null; + this._cancelled = null; + } +} diff --git a/src/ai/ai-controller.js b/src/ai/ai-controller.js new file mode 100644 index 0000000..c0286c2 --- /dev/null +++ b/src/ai/ai-controller.js @@ -0,0 +1,864 @@ +// AI 控制器 +// ============================================================================ +// +// 把"AI 对话面板 + AI 差异面板 + 编辑器"串成一个状态机。 +// +// 状态: +// - chatPanelOpen: bool AI 输入面板是否展开 +// - pendingDiff: object|null AI 返回的待应用修改(rows + regions) +// - isSubmitting: bool 正在等主进程回包 +// +// 数据流: +// 用户输入 prompt +// → chatPanel.onSubmit(prompt, requestId) +// → window.api.aiEdit({...}) (主进程调 OpenAI 兼容 API) +// → 拿到 { content, responseFormat } +// → computeFullMarkdownDiff(currentContent, content) 算出 regions +// → setPendingDiff({ rows, regions, baseContent, nextContent }) +// → diffPanel.setDiff(...) 在中间面板渲染 +// 用户点 "应用此处" / "应用全部" +// → editor.applyUserChange(newContent) // 标脏 +// → 重算 diff(基于新内容);regions 全部应用完则关掉 diffPanel +// +// 内容变化时(用户继续编辑、撤销/重做、autosave 落地)也要重算 diff, +// 让"应用一部分后还剩多少"始终正确。 +// ============================================================================ + +import { AiChatPanel } from './ai-chat-panel.js'; +import { AiDiffPanel } from './ai-diff-panel.js'; +import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js'; + +/** + * 给一段行级 lines 构造只读 context rows(用于 AI 普通回复展示)。 + * 行号必须与 shared/markdown-diff.js 的 splitLines 严格一致 —— 否则 viewer + * 行号与 diff 行号错位,撤销 / 光标定位跑偏。 + * @param {string} content + * @returns {Array<{id:string, type:'context', newLineNumber:number, segments:Array<{type:'equal', text:string}>}>} + */ +function linesToContextRows(content) { + // H1 fix (audit):用与 splitLines 一致的「normalize + endsWith 处理」逻辑, + // 替代原来的 `/\r\n?|\n/` split。两者对裸 \r 旧 Mac 风格的行号会差 1: + // - 原 split("\r") = ["", ""] → 2 行 + // - normalize 后 splitLines("\r") = [""] → 1 行 + // renderer 不能 import CJS(shared/* 是 CommonJS),所以内联等价实现。 + const normalized = String(content || '').replace(/\r\n?/g, '\n'); + const lines = normalized.length === 0 + ? [] + : (normalized.endsWith('\n') ? normalized.slice(0, -1).split('\n') : normalized.split('\n')); + return lines.map((line, index) => ({ + id: `readonly-${index}`, + type: 'context', + newLineNumber: index + 1, + segments: [{ type: 'equal', text: line }], + })); +} + +/** + * @param {object} options + * @param {object} options.api - window.api + * @param {object} options.editor - MarkdownEditor 实例 + * @param {object} options.state - app.js 的 state + * @param {(msg:string, type?:string, duration?:number) => void} options.toast + * @param {() => object} options.getSettings - 读最新设置(settingsStore.getAll) + * @param {() => void} [options.onAfterApply] - 成功 apply region / all 后通知外部刷新 viewer。 + * preview 模式下 scheduleLivePreview 只在 split 模式刷新 viewer,AI 改动只能通过 + * 这个回调让 viewer 看到(否则用户感觉"点了应用,文件没反应")。 + * @param {() => void|Promise} [options.onOpenSettings] - 未配置时调:直接打开设置对话框 + * 并把焦点定位到 AI 段。AI 按钮 / 提交时检测到未配置都会触发,比 toast 更直接 —— 让用户 + * 能立刻看到「还差什么」而不是看到一条转瞬即逝的警告。 + * @param {(text:string, type:import('./ai-status.js').AI_STATUS[keyof import('./ai-status.js').AI_STATUS]) => void} [options.notify] + * 把 AI 过程/结果文案转发到外部 UI(statusbar 上的 #status-ai chip)。由 controller + * 主动调:setStatus / setError 在 chatPanel 内部已经转发到 onNotify,但 controller + * 想明确标注类型(如 AI_STATUS.DIFF)时直接调本回调(绕过 chatPanel 的 inferStatusType)。 + * @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时 + * togglePanel 会拒绝打开 AI dock 并 toast(双向互斥,避免 AI dock 透过 modal + * 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。 + */ +export function createAiController({ + api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen, +}) { + if (!api || !api.aiEdit) { + console.error('[ai-controller] api.aiEdit 不可用;preload 没暴露 AI 桥?'); + } + if (!editor) throw new Error('[ai-controller] editor 必填'); + + // 状态 + let pendingDiff = null; + let lastAppliedContent = null; // 防止 content 变化时无限重算自己 + let currentRequestId = null; // 用于切文件时取消 in-flight 请求(见 onFileChanged) + // P0-1 fix (audit):单调 generation 计数器。handleSubmit 入参时捕获, + // await 后比对 —— 文件切换 / 取消 / 重新提交都会 ++generation 把旧请求废弃。 + // 防止 AI 响应覆盖新文件的 pendingDiff(数据丢失风险)。 + let requestGeneration = 0; + // 防抖 timer:onContentChanged 不再每个 keystroke 都跑 sync DP + let recomputeTimer = null; + + // 安全 notify 闭包:notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。 + // controller 在 4 处显式调(AI_STATUS.DIFF / AI_STATUS.IDLE),其余走 chatPanel.setStatus 转发。 + const safeNotify = (text, type) => { + if (typeof notify !== 'function') return; + try { notify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ } + }; + + function isChatOpen() { + return chatPanel && chatPanel.isOpen(); + } + + function isConfigured() { + const s = typeof getSettings === 'function' ? (getSettings() || {}) : {}; + // P1-1 fix (audit):renderer 端 aiApiKey 是空字符串(main.js 已过滤), + // 真值标识走 _hasAiKey 布尔位 —— 也兼容旧测试 / 老 settings 直接传 aiApiKey 的情况 + const hasKey = !!s._hasAiKey || !!String(s.aiApiKey || '').trim(); + return !!(String(s.aiBaseUrl || '').trim() && hasKey && String(s.aiModel || '').trim()); + } + + function recomputeDiffFromBase() { + // 用户在编辑器里继续改了内容:基于最新内容 + AI 的 nextContent 重算 + if (!pendingDiff || pendingDiff.isReadOnly) return; + if (!api.aiDiffCompute) return; + const current = safeGetContent(); + if (current === null) return; // editor 抛错已被 safeGetContent 记录,不重算 + if (current === lastAppliedContent) return; // 没变化 + const opts = pickDiffOptions(current, pendingDiff.nextContent); + let next; + try { + // audit A5:未包 try/catch 会让 sync 抛错冒到 editor.onChange → app.js + // 的 IPC 链断裂。降级为 toast,不影响编辑器继续工作。 + next = api.aiDiffCompute(current, pendingDiff.nextContent, opts); + } catch (e) { + if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error'); + return; + } + if (!next || !next.regions || next.regions.length === 0) { + // 全部应用完了(或没了)→ 关闭 diff + setPendingDiff(null); + } else { + pendingDiff = { ...pendingDiff, baseContent: current, ...next }; + lastAppliedContent = current; + if (diffPanel) diffPanel.setDiff(pendingDiff); + } + syncBodyFlag(); + } + + function pickDiffOptions(base, next) { + const totalLen = (base || '').length + (next || '').length; + const baseLines = (base || '').split(/\r\n?|\n/).length; + const nextLines = (next || '').split(/\r\n?|\n/).length; + // 超过 80k 字符或 12 万行乘积 → 关闭 token 粒度(避免 DP 爆炸) + return { + tokenDiff: totalLen <= 80_000 && baseLines * nextLines <= 120_000, + }; + } + + /** + * 取编辑器当前内容。失败返回 null 而非 '': + * 若失败时 fallback 成 '',applyDiff 链路会把真实编辑器内容当成空文本, + * 后续 editor.applyUserChange(nextContent) 就把用户文档替换为 AI 基于空文本生成的内容 —— 静默销毁。 + * 调用方必须 null-check 并中止 AI 流程。 + */ + function safeGetContent() { + try { return editor.getContent(); } + catch (e) { + // audit fix (Phase O-L20):catch 不直接把 e 整个写 console.warn。 + // 写整个 e 会让 e.message(含 CM6 state doc 摘要 / 光标位置)落到主进程 + // notes-main.log(用户私有 logs/,同机其他用户不可读但仍属日志积压); + // 改成只打错误名 + 行号,不附 message。 + console.warn('[ai-controller] safeGetContent 失败:', e && e.name ? e.name : 'Error'); + return null; + } + } + + function setPendingDiff(diff) { + pendingDiff = diff; + lastAppliedContent = diff ? safeGetContent() : null; + if (diffPanel) diffPanel.setDiff(diff); + syncBodyFlag(); + } + + /** + * 校验 pendingDiff 仍然属于当前打开的文件。 + * audit H162/H163:之前 handleApplyRegion / handleApplyAll 直接读 safeGetContent(), + * 不检查 pendingDiff 是为哪个文件算的 —— 用户提交 AI → 等响应 → 切到另一个文件 → + * 点「应用」,会把 A 的 diff region 算进 B 的内容里(可能命中 / 可能不命中 → + * 即使命中也是把 A 的修改写到 B,数据丢失)。 + * 现在所有 setPendingDiff 都带上 myFile,apply 前再核对一次。 + * @returns {{ ok: true } | { ok: false, reason: string }} + */ + function ensurePendingDiffMatchesCurrentFile() { + if (!pendingDiff) return { ok: false, reason: '没有待应用的 AI 修改' }; + const expected = pendingDiff.forFilePath; + const current = state.currentFile && state.currentFile.path; + if (expected == null || current == null || expected !== current) { + return { + ok: false, + reason: '当前文件已切换,原 AI 修改已失效(请重新提交)', + }; + } + return { ok: true }; + } + + function syncBodyFlag() { + // 中间面板:body.dataset.aiDiffOpen = 'true' 时 CSS 插入一列 + // 注意:isReadOnly 也要让面板打开 —— AI 普通回复(regions=[])也算"有 AI 内容要展示" + const has = !!pendingDiff && ( + pendingDiff.isReadOnly || + (pendingDiff.regions || []).length > 0 + ); + document.body.dataset.aiDiffOpen = has ? 'true' : 'false'; + } + + // 创建面板实例(DOM 必须在 bindMountPoint 之后才能实例化) + let chatPanel = null; + let diffPanel = null; + + function bindMountPoint(chatElement, diffElement, splitterElement = null) { + // 3rd-agent fix:重复 bind 要先销毁旧实例,否则旧 listener 残留、 + // 旧 _submitting 状态无法被新 controller 感知。 + if (chatPanel && typeof chatPanel.destroy === 'function') { + try { chatPanel.destroy(); } catch { /* ignore */ } + } + if (diffPanel && typeof diffPanel.destroy === 'function') { + try { diffPanel.destroy(); } catch { /* ignore */ } + } + + chatPanel = new AiChatPanel({ + element: chatElement, + onSubmit: handleSubmit, + onCancel: handleCancel, + // 让 panel 内部的过程/错误文案(正在发送 / 已取消 / AI 请求失败 等) + // 走 onNotify → app.js 的 updateStatusAi → statusbar 上的 #status-ai chip。 + // 详见 [feedback-ai-tips-into-status-bar]。 + onNotify: notify, + }); + // 一开始禁用(直到用户打开文件) + chatPanel.syncFileState(!!state.currentFile); + + diffPanel = new AiDiffPanel({ + element: diffElement, + onApplyRegion: handleApplyRegion, + onApplyAll: handleApplyAll, + onDismiss: handleDismiss, + onSkipRegion: handleSkipRegion, + splitterElement, + }); + // 默认无 diff:hidden + diffPanel.setDiff(null); + } + + async function handleSubmit({ prompt, requestId }) { + if (!isConfigured()) { + // 未配置:直达设置对话框(焦点定位到 AI 段),让用户能立刻看到缺什么。 + // toast 留给 dialog 自己展示,比一条转瞬即逝的提示更直接。 + if (typeof onOpenSettings === 'function') { + try { + await onOpenSettings(); + } catch { + // dialog 自身挂了不要让用户干等 —— 给个明确的可执行下一步 + if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error'); + } + } + // 给状态栏 chip 一个明确文案,避免触发 _triggerSubmit 的 'AI 修改失败' 兜底。 + return { ok: false, error: AI_ERROR.NOT_CONFIGURED, message: aiErrorMessage(AI_ERROR.NOT_CONFIGURED) }; + } + if (!state.currentFile) { + if (typeof toast === 'function') toast('请先打开一个 Markdown 文件', 'warning', 2500); + return { ok: false, error: AI_ERROR.NO_FILE, message: aiErrorMessage(AI_ERROR.NO_FILE) }; + } + + // P0-1 fix (audit):捕获提交时的文件对象 + 单调 generation。 + // await 之后比对:文件已切换 / generation 已递增 → 视为 stale, + // 丢弃本响应(防止 AI 修改错文件 —— 数据丢失风险)。 + const myGen = ++requestGeneration; + const myFile = state.currentFile; + const filename = myFile.name || ''; + // H4 fix (audit):拿到 prompt 后再读最新内容,避免 stale baseContent。 + const contentSnapshot = safeGetContent(); + if (contentSnapshot === null) { + return { ok: false, error: AI_ERROR.EDITOR_UNAVAILABLE, message: aiErrorMessage(AI_ERROR.EDITOR_UNAVAILABLE) }; + } + // 记录当前请求 id:切文件时调 aiCancel 主动终止,避免 AI 响应 + // 回到一个已经不相关的文档(详见 onFileChanged)。 + currentRequestId = requestId; + + // H2 fix (audit):提交时立刻清掉旧 pendingDiff,避免 awaiting 期间中间面板 + // 显示上一次 AI 的陈旧 diff(用户点了"应用此处"以为对的是新请求)。 + if (pendingDiff) { + setPendingDiff(null); + } + + let result; + try { + result = await api.aiEdit({ prompt, content: contentSnapshot, filename, requestId }); + } catch (e) { + currentRequestId = null; + return { ok: false, error: 'IPC_FAILED', message: e && e.message || 'IPC 异常' }; + } + // P0-1 fix (audit):await 之后做 stale 检查 —— 注意 currentRequestId 只在 + // 我们仍是「最新一次提交」时才清掉,否则会让并发的下一次提交找不到自己 + // 的 requestId(onFileChanged / destroy 看 currentRequestId 决定是否取消 + // in-flight 请求)。audit M1 fix:currentRequestId 必须在 stale 检查通过后 + // 才允许清,且只在我们仍是 currentRequestId 的持有者时清。 + if (myGen !== requestGeneration || state.currentFile?.path !== myFile?.path) { + // L2 fix (audit):路径比较替代对象身份。openFile 会基于 reload/mtime 更新 + // state.currentFile = { ...state.currentFile, mtimeMs, size }(新对象), + // 旧身份比较会让「同一文件路径、仅 mtime 更新」误判为已切文件, + // 把刚回的 AI 响应丢进 stale 分支。改用路径字符串比较。 + if (currentRequestId === requestId) currentRequestId = null; + return { + ok: false, + error: AI_ERROR.GENERATION_STALE, + message: aiErrorMessage(AI_ERROR.GENERATION_STALE), + }; + } + // 通过 stale 检查:清掉我们的 requestId 句柄,让 onFileChanged 不再尝试 + // 取消这个请求(它马上就要 resolve 了)。 + currentRequestId = null; + + if (!result || result.ok !== true) { + return { ok: false, error: (result && result.error) || 'UNKNOWN', message: (result && result.message) || 'AI 修改失败' }; + } + + const aiContent = result.content || ''; + const isRaw = result.responseFormat === 'raw'; + if (isRaw) { + // AI 没改文档,只是回复一段文字 + setPendingDiff({ + id: result.id, + baseContent: contentSnapshot, + nextContent: aiContent, + isReadOnly: true, + rows: linesToContextRows(aiContent), + regions: [], + forFilePath: myFile && myFile.path, // audit H162:标记这个 diff 属于哪个文件 + }); + if (chatPanel) chatPanel.setStatus('AI 已返回普通回复,未生成文档修改'); + return { ok: true, isRawResponse: true }; + } + + // 算 diff + const opts = pickDiffOptions(contentSnapshot, aiContent); + let diffResult; + try { + diffResult = api.aiDiffCompute(contentSnapshot, aiContent, opts); + } catch (e) { + return { ok: false, error: 'DIFF_FAILED', message: '计算差异失败:' + (e && e.message || '未知') }; + } + + if (!diffResult.regions || diffResult.regions.length === 0) { + // AI 返回内容与原文一致(已经过 schema 比较过,但 baseContent 可能在 + // 期间被外部改过,所以再次校验) + setPendingDiff({ + id: result.id, + baseContent: contentSnapshot, + nextContent: aiContent, + isReadOnly: true, + rows: linesToContextRows(aiContent), + regions: [], + forFilePath: myFile && myFile.path, + }); + if (chatPanel) chatPanel.setStatus('AI 返回内容与当前文档一致'); + return { ok: true, isRawResponse: false }; + } + + setPendingDiff({ + id: result.id, + baseContent: contentSnapshot, + nextContent: aiContent, + isReadOnly: false, + rows: diffResult.rows, + regions: diffResult.regions, + forFilePath: myFile && myFile.path, + }); + if (chatPanel) { + const text = `AI 已返回,请在中间面板确认(${diffResult.regions.length} 处修改)`; + // AI_STATUS.DIFF:chip 用 accent-soft 高亮,让用户一眼看到"中间面板有 N 处待确认" + // 显式 notify 而非 setStatus —— 后者会被 panel 内部推断为 success(中性 muted), + // 弱化了关键状态的视觉权重。 + safeNotify(text, AI_STATUS.DIFF); + } + // H3 fix (audit):diff 引擎 fallback 时(行级 cells 超 MAX_LINE_DIFF_CELLS / + // 词级超 MAX_TOKEN_DIFF_CHARS)会附带 warnings 字段。告知用户「diff 退化为 + // 整段替换」或「词级已退化为行级」,避免 UI 上看到「整篇被改」以为是 AI 整篇重写。 + if (Array.isArray(diffResult.warnings) && diffResult.warnings.length > 0 && typeof toast === 'function') { + diffResult.warnings.forEach((msg) => toast(msg, 'warning', 5000)); + } + return { ok: true, isRawResponse: false }; + } + + function handleCancel(requestId) { + // 取消 in-flight 请求;++generation 让 handleSubmit 在 await 返回后 + // 走 stale 分支(即使主进程 IPC 失败没真取消,也阻止后续 setPendingDiff)。 + requestGeneration++; + // L5 fix (audit):清掉 currentRequestId —— 否则 onFileChanged / destroy 看到 + // 非空 currentRequestId 会再发一次 cancel(重复 IPC)+ 误以为还有 in-flight 请求。 + currentRequestId = null; + if (api.aiCancel) api.aiCancel(requestId); + } + + function handleApplyRegion(regionId /*, diff */) { + if (!pendingDiff) return; + // audit fix (Round 13 / Sec-H1):isReadOnly=true 的 diff 没有可应用 region + // (regions:[]),且 nextContent 是 AI 普通文本回复而非文档修改。panel 不会 + // 渲染 apply 按钮,但事件委托若被 AI 控制的 [data-action] 元素覆盖(Sec-H1 + // 已加 closest('.ai-reply-content') 兜底),仍可能在 controller 这里再次走到 + // apply 路径 —— editor.applyUserChange(aiChatReply) 会把打开的笔记替换为 + // AI 的聊天文本。二次守一道:readOnly 时直接拒绝,连 region 查询都不跑。 + if (pendingDiff.isReadOnly) return; + // audit H162:apply 前确认 pendingDiff 还是当前文件的。 + // onFileChanged 在切换文件时会 setPendingDiff(null) —— 但有一种竞态: + // 用户提交 AI → 等响应 → 切到 B 文件(pendingDiff 被清)→ 切回 A 文件 + // (onFileChanged 不会再清,因为已经清过了)→ 此时 pendingDiff 是 null, + // 但如果 controller 在切到 B 之前刚生成 pendingDiff 且切回 A 后没刷新, + // 那 pendingDiff 仍存在但属于 A。下面这层 forFilePath 校验兜底。 + const check = ensurePendingDiffMatchesCurrentFile(); + if (!check.ok) { + if (typeof toast === 'function') toast(check.reason, 'error', 3000); + setPendingDiff(null); + return; + } + const region = pendingDiff.regions.find((r) => r.id === regionId); + if (!region) return; + + const current = safeGetContent(); + if (current === null) { + if (typeof toast === 'function') toast('无法读取编辑器内容,已停止应用', 'error'); + return; + } + + let result; + try { + result = api.aiDiffApply(current, region); + } catch (e) { + if (typeof toast === 'function') toast('应用失败:' + (e && e.message || '未知'), 'error'); + // audit fix (Round 9):chatPanel status 与 toast 同步 —— 否则面板里 + // 还停留在「已应用 N 处修改」/「待确认」的旧状态,用户以为成功了。 + if (chatPanel) chatPanel.setStatus('AI 应用失败,可重试'); + return; + } + if (!result.ok) { + // 在该 region 上标 conflict,UI 灰掉"应用此处"按钮 + pendingDiff = { + ...pendingDiff, + regions: pendingDiff.regions.map((r) => r.id === regionId ? { ...r, conflict: result.reason } : r), + }; + // audit fix (1.3):冲突时**不修改** lastAppliedContent —— 编辑器内容没变, + // 用户先前若有手动编辑,safeGetContent() 返回的是漂移值,把它写回 lastAppliedContent + // 会让 onContentChanged 跳过本应触发的 recompute。 + // pendingDiff.baseContent 是当前 diff 的基线,保持原状即可。 + if (diffPanel) diffPanel.setDiff(pendingDiff); + // audit fix (Round 9):同步 chatPanel status 让用户知道 apply 失败。 + if (chatPanel) chatPanel.setStatus('该处无法应用,请用「跳过」放弃'); + return; + } + + // M5 fix (audit):对称 handleApplyAll 的空内容保护 —— applyDiffRegionSafely + // 在极端场景(region 跨边界 + 上下文匹配失败)可能算出 "" 或纯空白。 + // applyUserChange('') 会把区域对应行静默清空(数据丢失),必须挡住。 + // C4 fix (audit):但 region.oldLines.length > 0 时算「整段删除」是合法语义 + // (AI 明确有内容可删),此时 appliedContent 为空是正确的,不能挡。 + const appliedContent = result.content; + const isLegitimateDelete = region.oldLines.length > 0; + if ( + typeof appliedContent !== 'string' + || (!isLegitimateDelete && appliedContent.replace(/\s+/g, '').length === 0) + ) { + if (typeof toast === 'function') { + toast('AI 返回内容为空,已拒绝应用该处(请用「跳过」放弃该修改)', 'error', 4000); + } + // 把 region 标 conflict 让用户走「跳过」逃生口 + pendingDiff = { + ...pendingDiff, + regions: pendingDiff.regions.map((r) => r.id === regionId ? { ...r, conflict: 'AI 返回内容为空' } : r), + }; + if (diffPanel) diffPanel.setDiff(pendingDiff); + syncBodyFlag(); + // audit fix (Round 9):同步 chatPanel status。 + if (chatPanel) chatPanel.setStatus('AI 返回内容为空,请「跳过」放弃'); + return; + } + + // 应用:替换编辑器内容(标脏) + try { + editor.applyUserChange(appliedContent); + } catch (e) { + // audit fix (Phase O-M1):IME 合成期间抛 EDITOR_COMPOSING 阻止 apply, + // 提示用户稍后再试(不要显示「写入编辑器失败」误导成磁盘错误)。 + if (e && e.message === 'EDITOR_COMPOSING') { + if (typeof toast === 'function') toast('编辑器正在输入中文,请稍后再试', 'warning'); + // audit fix (Round 9):同步 chatPanel status —— chip 仍显示 + // 「待确认 N 处」会让用户以为还在 diff 模式可用。 + if (chatPanel) chatPanel.setStatus('正在输入中文,请稍后再试'); + return; + } + if (typeof toast === 'function') toast('写入编辑器失败:' + (e && e.message || '未知'), 'error'); + // audit fix (Round 9):同步 chatPanel status。 + if (chatPanel) chatPanel.setStatus('写入编辑器失败,可重试'); + return; + } + // M5 fix (audit):apply 成功后立即把 pendingDiff.baseContent 推进到 result.content, + // 不要等到下面的 compute 抛错 / 不更新就早返回。baseContent 必须始终反映 + // 「编辑器的当前实际内容」—— 后续任何重算 / onFileChanged 比对都依赖它。 + // 旧顺序里如果 aiDiffCompute 抛错,编辑器是新内容、pendingDiff.baseContent + // 还是旧内容,用户看到「已应用的修改没体现在剩余 diff 里」的诡异行为。 + pendingDiff = { ...pendingDiff, baseContent: result.content }; + // preview 模式修复:apply 后立即通知外部刷新 viewer。split 模式下 + // scheduleLivePreview 会覆盖(防双触发不致命 —— loadContentLive 幂等)。 + if (typeof onAfterApply === 'function') { + try { onAfterApply(); } catch { /* 不让 viewer 渲染失败阻断 apply */ } + } + + // 用 AI 改后的 base 重算剩余 diff + let remaining; + try { + // audit A5:与 recomputeDiffFromBase 同理,包 try/catch 防 sync 抛错。 + remaining = api.aiDiffCompute(result.content, pendingDiff.nextContent, pickDiffOptions(result.content, pendingDiff.nextContent)); + } catch (e) { + if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error'); + // audit fix (Round 9):同步 chatPanel status。 + if (chatPanel) chatPanel.setStatus('重算差异失败'); + return; + } + if (!remaining || !remaining.regions || remaining.regions.length === 0) { + // 全部应用完 + if (typeof toast === 'function') toast('已应用所有 AI 修改', 'success', 1500); + setPendingDiff(null); + if (chatPanel) chatPanel.setStatus('已应用所有 AI 修改'); + } else { + pendingDiff = { ...pendingDiff, baseContent: result.content, ...remaining }; + lastAppliedContent = result.content; + if (diffPanel) diffPanel.setDiff(pendingDiff); + syncBodyFlag(); + // H3 fix (audit):partial apply 后 chatPanel status 也要更新(regions 数量变了) + if (chatPanel) { + // AI_STATUS.DIFF:剩余待确认数要靠 chip 高亮 —— 见 handleSubmit 的同款注释 + safeNotify(`已应用 1 处修改,还剩 ${remaining.regions.length} 处待确认`, AI_STATUS.DIFF); + } + } + } + + function handleApplyAll(/* diff */) { + if (!pendingDiff) return; + // audit fix (Round 13 / Sec-H1):与 handleApplyRegion 同款 —— isReadOnly + // 状态不可能有合法 apply 路径,nextContent 是 AI 普通聊天文本,覆盖编辑器 + // 即等于「把笔记销毁换成聊天回复」。详见 handleApplyRegion 同处注释。 + if (pendingDiff.isReadOnly) return; + // audit H163:与 handleApplyRegion 同款校验,挡住「跨文件覆盖」数据丢失。 + const check = ensurePendingDiffMatchesCurrentFile(); + if (!check.ok) { + if (typeof toast === 'function') toast(check.reason, 'error', 3000); + setPendingDiff(null); + return; + } + const next = pendingDiff.nextContent; + // P0 fix:拒绝空内容覆盖 —— AI 返回 '' 时(截断 / 服务异常 / 提示词诱导), + // applyDiffRegionSafely 也会算出"全删"region,"应用全部"就会把用户文档 + // 静默替换成空串(这里就是 clearCurrentFile 之外的另一条销毁路径)。 + // 校验放在最后一道关卡前:next 必须有非空白字符。 + if (typeof next !== 'string' || next.replace(/\s+/g, '').length === 0) { + if (typeof toast === 'function') { + toast('AI 返回内容为空,已拒绝应用(请用「跳过」逐条处理,或重新提交)', 'error', 4000); + } + // 把当前 diff 标成 readOnly、关掉 apply 路径,让用户能继续查看 / skip 单条 + pendingDiff = { + ...pendingDiff, + isReadOnly: true, + }; + if (diffPanel) diffPanel.setDiff(pendingDiff); + syncBodyFlag(); + return; + } + // M4 fix (audit):用户在等待 AI 时可能继续编辑文档。pendingDiff.baseContent + // 是提交时的快照,若当前编辑器内容已不同,「应用全部」会用 nextContent + // 直接覆盖整个文档,**丢掉用户在等待期间做的修改**(数据丢失)。 + // + // 审计修复 (Round 11):之前只弹一条 toast 警告但 apply 照常进行 —— 5 秒 + // 警告用户根本来不及反应,apply 已完成、用户的修改被静默覆盖。 + // 现在 drift 检出后把 pendingDiff 标成 isReadOnly=true(diffPanel 会隐藏 + // "应用全部" / "应用此处" 按钮),强制用户「逐条 skip / apply」让决定更精细, + // 或者撤销自己的编辑后重提。最坏情况保留全删路(用户主动重提后 baseContent + // 重新对齐,apply 不再丢失任何东西)。 + const currentForApply = safeGetContent(); + if (currentForApply !== null && currentForApply !== pendingDiff.baseContent) { + if (typeof toast === 'function') { + toast('你在等待 AI 时已修改过文档,「应用全部」会覆盖未在 diff 内的修改,已切换为逐条确认', 'warning', 4000); + } + if (chatPanel) chatPanel.setStatus('已切换逐条确认模式(你等待时编辑过文档)'); + pendingDiff = { + ...pendingDiff, + isReadOnly: true, + }; + if (diffPanel) diffPanel.setDiff(pendingDiff); + syncBodyFlag(); + return; + } + try { + editor.applyUserChange(next); + } catch (e) { + // audit fix (Phase O-M1):同上,IME 合成期间拒绝 apply。 + if (e && e.message === 'EDITOR_COMPOSING') { + if (typeof toast === 'function') toast('编辑器正在输入中文,请稍后再试', 'warning'); + // audit fix (Round 9):同步 chatPanel status(与 handleApplyRegion 对齐)。 + if (chatPanel) chatPanel.setStatus('正在输入中文,请稍后再试'); + return; + } + if (typeof toast === 'function') toast('写入编辑器失败:' + (e && e.message || '未知'), 'error'); + // audit fix (Round 9):同步 chatPanel status。 + if (chatPanel) chatPanel.setStatus('写入编辑器失败,可重试'); + return; + } + // preview 模式修复:apply 后立即通知外部刷新 viewer(见 handleApplyRegion 同处注释) + if (typeof onAfterApply === 'function') { + try { onAfterApply(); } catch { /* 同上 */ } + } + if (typeof toast === 'function') toast('已应用所有 AI 修改', 'success', 1500); + setPendingDiff(null); + if (chatPanel) chatPanel.setStatus('已应用所有 AI 修改'); + } + + /** + * audit fix:从 diff 列表中丢弃一个 region(一般是 conflict 标了之后用户放弃应用)。 + * 旧版本唯一的恢复路径是「dismiss 整个 diff」—— 一旦某个 region 因上下文变更无法 + * 安全定位(conflict),其它 region 也跟着没法继续用。这里给出细粒度逃生口: + * - 从 pendingDiff.rows / regions 中过滤掉该 region; + * - 重算剩余 diff(如果 baseContent 因为后续编辑已经漂移,需要基于当前内容重算); + * - 如果没有 region 剩了,就 dismiss。 + * + * audit fix (H1-settings / H162):补文件匹配守卫 —— 之前 handleApply* 已经有 + * ensurePendingDiffMatchesCurrentFile(),handleSkipRegion 漏了同样的保护: + * 用户提 AI → 等响应 → 切到另一文件 → 旧 diff panel 仍可见(pendingDiff 没被 + * 清掉?实际 onFileChanged 会清,但 render 早于清 / 残留 region 仍可见),点 + * 「跳过」会改写为别的文件的 regions 数组(甚至把整个 diff 关掉)。和 apply + * 同样会造成「A 的修改写到 B」类数据事故。 + */ + function handleSkipRegion(regionId) { + if (!pendingDiff) return; + // 与 handleApply* 一致:先确认 pendingDiff 还属于当前文件 + const guard = ensurePendingDiffMatchesCurrentFile(); + if (!guard.ok) { + if (typeof toast === 'function') toast(guard.reason, 'warning', 2500); + setPendingDiff(null); + return; + } + const filtered = (pendingDiff.regions || []).filter((r) => r.id !== regionId); + const filteredRows = (pendingDiff.rows || []).filter((row) => row.regionId !== regionId); + + // 如果 baseContent 没变,直接用过滤后的 rows;否则重算(保持 diff 与最新编辑器同步) + const current = safeGetContent(); + if (current === null) { + if (typeof toast === 'function') toast('无法读取编辑器内容,已停止操作', 'error'); + return; + } + let nextRows = filteredRows; + let nextRegions = filtered; + let nextBase = pendingDiff.baseContent; + const skippedRegion = (pendingDiff.regions || []).find((r) => r.id === regionId); + if (current !== pendingDiff.baseContent && pendingDiff.nextContent) { + try { + const recomputed = api.aiDiffCompute(current, pendingDiff.nextContent, pickDiffOptions(current, pendingDiff.nextContent)); + if (recomputed && Array.isArray(recomputed.rows) && Array.isArray(recomputed.regions)) { + nextRows = recomputed.rows; + nextRegions = recomputed.regions; + nextBase = current; + // 用户已编辑 → 新 regions 由当前内容 + AI nextContent 重算得出。 + // 但「跳过 regionId」是用户明确的意图 —— 简单丢弃 ID 是不够的: + // recomputed.regions 是新生成的 ID,旧的 regionId 在新集合里压根不存在。 + // best effort:把 oldLines 文本与被跳过的 region 相同的 region 一并剔除。 + // 边界:用户编辑使 skippedRegion.oldLines 在 current 里已经不存在 + // (例如用户直接把那几行删了),recompute 也不会再生成对应 region,filter 是 no-op。 + if (skippedRegion && Array.isArray(skippedRegion.oldLines)) { + const skippedKey = skippedRegion.oldLines.join('\n'); + // 1) 把「oldLines 与被跳过 region 相同」的新 region 剔除 + nextRegions = nextRegions.filter((r) => !(Array.isArray(r.oldLines) && r.oldLines.join('\n') === skippedKey)); + // 2) 把指向被跳过 region 的 row 剔除(如果旧 ID 还残留在 row 数组里) + nextRows = nextRows.filter((row) => row.regionId !== skippedRegion.id); + } + } else { + // 审计修复 (Round 11 P1-4):recomputed 无效(null / 缺 rows/regions)也必须 bail。 + // catch 分支已有 return,但 try 内部"recompute 走通但返回无效 shape"会落到 + // 下面 if (!nextRegions...) 用 stale baseContent + filtered(旧 regions)继续 + // 渲染,diff panel 看着正常但实际没重算 → 用户以为重新算了实际还是旧的。 + // 失败就放弃本次 skip,不让 diff panel 进入误导态。 + if (typeof toast === 'function') toast('重算差异失败:返回结果无效,已跳过本次跳过操作', 'error'); + return; + } + } catch (e) { + if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error'); + return; + } + } + + if (!nextRegions || nextRegions.length === 0) { + // 跳过之后没 region 了,关掉 diff 面板 + if (typeof toast === 'function') toast('已跳过该修改', 'success', 1500); + setPendingDiff(null); + if (chatPanel) chatPanel.setStatus('已跳过该修改,无更多待确认'); + return; + } + + pendingDiff = { + ...pendingDiff, + baseContent: nextBase, + rows: nextRows, + regions: nextRegions, + }; + lastAppliedContent = current; + if (diffPanel) diffPanel.setDiff(pendingDiff); + syncBodyFlag(); + if (chatPanel) { + // AI_STATUS.DIFF:skip 一处后剩余待确认数要靠 chip 高亮 + safeNotify(`已跳过 1 处修改,还剩 ${nextRegions.length} 处待确认`, AI_STATUS.DIFF); + } + } + + function handleDismiss() { + setPendingDiff(null); + // dismiss 把 chip 收回到 idle —— 显式 notify 而非 setStatus(''): + // setStatus('') 会被 panel 内部推断为 'success'(保持 muted 文字), + // 我们要的是真正清空(chip 文字回到 "AI"),用 AI_STATUS.IDLE 类型最准。 + safeNotify('', AI_STATUS.IDLE); + } + + /** + * 工具栏按钮 / 快捷键调:切 AI 输入面板 + * @returns {boolean} true 表示打开了面板 + */ + function togglePanel() { + if (!chatPanel) return false; + if (chatPanel.isOpen()) { + chatPanel.close(); + return false; + } + // audit fix (Phase L3-AI 4):AI dock 与设置对话框互斥。 + // openSettingsDialog 已经在打开时主动 close dock 并 close 后还原,但用户在 + // 设置打开期间手动点 AI 按钮(侧栏 btn-ai / 状态栏 chip / 快捷键)会让 + // chatPanel.open() 把 dock 显示出来,视觉上从 modal 半透明背景后透出,与 + // 设置对话框重叠 → 误点率高。这里守一道:设置打开时拒绝开 dock 并 toast。 + if (typeof isSettingsOpen === 'function' && isSettingsOpen()) { + if (typeof toast === 'function') { + toast('设置对话框打开时无法打开 AI 面板,请先关闭设置', 'info'); + } + return false; + } + if (!isConfigured()) { + // 未配置:直达设置对话框(焦点定位到 AI 段),让用户能立刻补 Base URL / Key / Model。 + if (typeof onOpenSettings === 'function') { + try { + // onOpenSettings 可能是 async:catch 同步 throw,.catch 接住 Promise 拒绝 + const r = onOpenSettings(); + if (r && typeof r.catch === 'function') r.catch(() => { + if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error'); + }); + } catch { + if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error'); + } + } + return false; + } + chatPanel.open(); + return true; + } + + /** + * 全部关闭(chat dock + diff 面板)。 + * diff 跟随 chat 一起关:让"AI 修改"按钮成为唯一的开关入口。 + */ + function closeAll() { + if (chatPanel) chatPanel.close(); + // 关 dock 时也清掉防抖 timer,避免 pendingDiff=null 之后又触发 recompute + if (recomputeTimer) { clearTimeout(recomputeTimer); recomputeTimer = null; } + requestGeneration++; + // L5 fix (audit):关 dock 也清掉 currentRequestId + 发一次 cancel。 + // 否则再 open 时 controller 以为自己还有 in-flight 请求,新提交会被 + // onFileChanged/destroy 看到 currentRequestId 还在,重复发 cancel。 + if (currentRequestId && chatPanel && typeof chatPanel.cancelRequest === 'function') { + try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ } + } + currentRequestId = null; + setPendingDiff(null); + if (chatPanel && typeof chatPanel.reset === 'function') { + // 3rd-agent fix:清掉 _submitting / 输入框残留,避免下次 open 时 input 永久 disabled + chatPanel.reset(); + } + } + + /** + * 文件切换时同步:清掉 pendingDiff、把新内容当基线 + * 同时取消 in-flight 的 AI 请求 —— 否则响应回来时 contentSnapshot 还是 + * 上一个文件的内容,会让面板把不相关的 diff 渲染出来。 + */ + function onFileChanged(newFile) { + // 取消进行中的 AI 请求(chatPanel 知道自己的 _currentRequestId) + if (chatPanel && currentRequestId) { + chatPanel.cancelRequest(currentRequestId); + currentRequestId = null; + } + // P0-1 fix (audit):递增 generation 让 in-flight handleSubmit 失败, + // 并清掉任何挂起的 debounced recompute(新文件的内容基线不再准确) + if (recomputeTimer) { clearTimeout(recomputeTimer); recomputeTimer = null; } + requestGeneration++; + // 切换文件时放弃当前 diff(避免误应用) + setPendingDiff(null); + if (chatPanel) { + chatPanel.syncFileState(!!newFile); + // 3rd-agent fix:reset 比单独清 status/error 更彻底 —— 也会把 + // _submitting=false + input.disabled=false,避免切文件后输入框永久禁用 + chatPanel.reset(); + } + } + + /** + * 编辑器内容变化时:重算 diff(让用户看到"已应用部分还剩多少") + * P1-3 fix (audit):100ms 防抖 —— 连续 keystroke 只跑一次 DP + * (pickDiffOptions 把 tokenDiff 限制在 ≤80k / ≤120k 行乘积,但每个 + * keystroke 仍触发同步 DP 会卡顿)。 + */ + function onContentChanged() { + if (!pendingDiff) return; + if (recomputeTimer) clearTimeout(recomputeTimer); + recomputeTimer = setTimeout(() => { + recomputeTimer = null; + recomputeDiffFromBase(); + }, 100); + } + + /** + * 销毁 controller:撤销所有 panel listener、清掉防抖 timer、取消 in-flight 请求。 + * 用于页面卸载 / HMR 重置 —— 避免旧实例的 click 监听器 + debounced setTimeout + * 在新 controller 实例运行时继续触发(MutationObserver 在 src/app.js 的 pagehide + * 已用类似清理路径)。 + */ + function destroy() { + if (recomputeTimer) { + clearTimeout(recomputeTimer); + recomputeTimer = null; + } + // 递增 generation 让 in-flight handleSubmit 自然废弃(不再写 pendingDiff) + requestGeneration++; + if (chatPanel && typeof chatPanel.cancelRequest === 'function' && currentRequestId) { + try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ } + currentRequestId = null; + } + if (chatPanel && typeof chatPanel.destroy === 'function') { + try { chatPanel.destroy(); } catch { /* ignore */ } + } + if (diffPanel && typeof diffPanel.destroy === 'function') { + try { diffPanel.destroy(); } catch { /* ignore */ } + } + chatPanel = null; + diffPanel = null; + pendingDiff = null; + lastAppliedContent = null; + setPendingDiff(null); + } + + return { + bindMountPoint, + togglePanel, + closeAll, + destroy, + isChatOpen: () => isChatOpen(), + hasPendingDiff: () => !!pendingDiff, + onFileChanged, + onContentChanged, + }; +} diff --git a/src/ai/ai-diff-panel.js b/src/ai/ai-diff-panel.js new file mode 100644 index 0000000..9f0aeb2 --- /dev/null +++ b/src/ai/ai-diff-panel.js @@ -0,0 +1,268 @@ +// AI 修改差异预览面板 +// ============================================================================ +// +// 渲染 AI 改稿前后的行级 + 词级 diff。用户可以: +// - 点 "应用全部修改":把整篇 nextContent 替换进编辑器 +// - 点每个 region 的 "应用此处":把单个 region 的 newLines 替换进编辑器 +// - 点 "×":丢弃 AI 修改,关掉中间面板 +// +// 与 ai-controller 协作: +// 组件本身只负责渲染 + 转发按钮事件;所有"修改编辑器"的操作都通过 +// 传进来的 onApply* 回调,由 controller 决定怎么改编辑器、重算 diff。 +// +// 数据流: +// diff = { rows, regions, isReadOnly, baseContent, nextContent } +// - rows 已经是扁平化的行序列(含 context / removed / added),按 regionId 分组 +// - regions 是 [{ id, oldLines, newLines, conflict? }],每个对应一个"修改块" +// - isReadOnly = true 表示 AI 给了纯文本回复(不是文档修改),整块只读展示 +// ============================================================================ + +import { escapeHtml, safeRenderMarkdown } from '../utils.js'; + +/** + * 把扁平 rows 拆成 context 行 + region 块,渲染时按这个结构走。 + * @param {Array<{id:string, regionId?:string, type:string}>} rows + * @returns {Array<{type:'context', row:any} | {type:'region', regionId:string, rows:any[]}>} + */ +function buildBlocks(rows) { + const blocks = []; + let index = 0; + while (index < rows.length) { + const row = rows[index]; + if (!row.regionId) { + blocks.push({ type: 'context', row }); + index += 1; + continue; + } + const regionId = row.regionId; + const regionRows = []; + while (index < rows.length && rows[index].regionId === regionId) { + regionRows.push(rows[index]); + index += 1; + } + blocks.push({ type: 'region', regionId, rows: regionRows }); + } + return blocks; +} + +/** + * 渲染一行 diff。 + * - removed 行用红色 - 前缀 + * - added 行用绿色 + 前缀 + * - context 行用空格前缀(与 git diff 一致) + * - token 粒度的 inline diff(equal/removed/added)会进一步切分 + */ +function renderDiffRow(row, hideLineNumbers) { + const lineNumber = row.type === 'removed' ? row.oldLineNumber : row.newLineNumber; + const prefix = row.type === 'added' ? '+' : row.type === 'removed' ? '-' : ' '; + const segmentsHtml = (row.segments || []) + .map((seg) => { + const text = seg.text || ' '; + return `${escapeHtml(text)}`; + }) + .join(''); + + const numberHtml = hideLineNumbers + ? '' + : `` + + ``; + + return ` +
+ ${numberHtml} + ${segmentsHtml} +
+ `; +} + +export class AiDiffPanel { + /** + * @param {object} options + * @param {HTMLElement} options.element - 容器 div + * @param {(diff:any) => void} options.onApplyRegion - 单 region 应用 + * @param {(diff:any) => void} options.onApplyAll - 全部应用 + * @param {(diff:any) => void} options.onDismiss - 关闭预览 + * @param {(regionId:string, diff:any) => void} [options.onSkipRegion] - 跳过单个 region + * (一般用于 conflict 区域 —— 给用户提供细粒度逃生口,避免一次冲突就要放弃整篇 AI 修改) + */ + constructor({ element, onApplyRegion, onApplyAll, onDismiss, onSkipRegion, splitterElement = null }) { + this.element = element; + // AI 拖动 splitter 元素(与 .app-ai 联动显隐)。 + // 设了 splitterElement 后,setDiff 会自动同步它的 hidden。 + this.splitterElement = splitterElement; + this.onApplyRegion = onApplyRegion || (() => {}); + this.onApplyAll = onApplyAll || (() => {}); + this.onDismiss = onDismiss || (() => {}); + this.onSkipRegion = onSkipRegion || null; + this._diff = null; + } + + /** + * 渲染/更新 diff。diff = null → 显示空状态。 + * @param {object|null} diff + */ + setDiff(diff) { + this._diff = diff; + // 每次 setDiff 之前先清掉上一轮的 click 监听器,否则重复 setDiff 会泄漏。 + if (this._cleanup) this._cleanup(); + this._cleanup = null; + if (!diff) { + this.element.hidden = true; + // 同步把 AI 拖动 splitter 隐藏:避免面板关后 splitter 仍可见占用 grid 列 + if (this.splitterElement) this.splitterElement.hidden = true; + this.element.replaceChildren(); + return; + } + this.element.hidden = false; + if (this.splitterElement) this.splitterElement.hidden = false; + // C4+H6 fix (audit):_renderHtml / innerHTML 抛错会让 setDiff 中断, + // element 处于 hidden=false 但内部为空 / 半渲染状态,用户看到一个 + // 灰板。这里包 try/catch,失败时降级为错误提示 + 保留 dismiss 按钮。 + try { + this.element.innerHTML = this._renderHtml(diff); + this._bindEvents(diff); + } catch (e) { + console.error('[ai-diff-panel] setDiff failed:', e); + // M4 fix (audit):兜底再显式 unhide —— 理论上 line 115-116 已设置, + // 但若上一次的 setDiff 走的是 null 分支把 element + splitterElement 都 hidden 了, + // 本轮进入 try 前 line 115-116 一定执行;但若 _renderHtml 内部有同步逻辑 + // 改了 dataset / 触发了 reflow(极端情况),catch 进来时面板可能仍处于 hidden。 + // 显式再设一次保证失败降级 UI 一定可见、用户能点 dismiss 退出。 + this.element.hidden = false; + if (this.splitterElement) this.splitterElement.hidden = false; + this.element.innerHTML = ` +
+
+

AI 修改预览

+

渲染失败:${escapeHtml(e && e.message || '未知错误')}

+
+
+ +
+
+
Diff 渲染异常,请关闭后重试
+ `; + // 只绑定 dismiss —— 让用户能关掉这个面板 + const onClick = (ev) => { + const t = ev.target.closest('[data-action]'); + if (t && t.dataset.action === 'dismiss') this.onDismiss(diff); + }; + this.element.addEventListener('click', onClick); + this._cleanup = () => this.element.removeEventListener('click', onClick); + } + } + + _renderHtml(diff) { + const isReadOnly = !!diff.isReadOnly; + const regions = Array.isArray(diff.regions) ? diff.regions : []; + const regionsById = new Map(regions.map((r) => [r.id, r])); + const hasApplicableDiff = !isReadOnly && regions.length > 0; + const summary = isReadOnly + ? 'AI 普通回复,仅供查看' + : hasApplicableDiff ? `${regions.length} 处待确认修改` : 'AI 返回内容与当前文档一致'; + + const headerActions = hasApplicableDiff + ? `` + : ''; + const header = ` +
+
+

AI 修改预览

+

${escapeHtml(summary)}

+
+
+ ${headerActions} + +
+
+ `; + + const body = this._renderBody(diff, regionsById); + return header + body; + } + + _renderBody(diff, regionsById) { + if (diff.isReadOnly) { + // 普通文本回复:整块当 markdown 渲染或纯文本展示。 + // 这里走 DOMPurify(preload 已暴露 window.api.renderMarkdown)保持与 viewer 一致。 + // 没设依赖时退化为
 纯文本(safeRenderMarkdown 集中处理)。
+      const html = safeRenderMarkdown(diff.nextContent || '');
+      return `
${html}
`; + } + + const blocks = buildBlocks(diff.rows || []); + if (blocks.length === 0) { + return `
AI 返回了空内容
`; + } + const parts = blocks.map((block) => { + if (block.type === 'context') { + return renderDiffRow(block.row, false); + } + const region = regionsById.get(block.regionId); + const hasSkip = this.onSkipRegion && region && region.conflict; + const conflict = region && region.conflict + ? `
${escapeHtml(region.conflict)}
` + : ''; + // conflict 时除了 disabled "应用此处",再多给一个 "跳过此处" 按钮: + // 用户放弃这一处但保留其它 region 的应用机会(之前只能 dismiss 整篇 diff)。 + const applyBtn = !region + ? '' + : ``; + const skipBtn = hasSkip + ? `` + : ''; + const regionHeader = !region + ? `
无法定位该修改区域
` + : `
${applyBtn}${skipBtn}
`; + const rowsHtml = block.rows.map((r) => renderDiffRow(r, false)).join(''); + return ` +
+
${regionHeader}${conflict}
+
${rowsHtml}
+
+ `; + }).join(''); + + return `
${parts}
`; + } + + _bindEvents(diff) { + const onClick = (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + // audit fix (Round 13 / Sec-H1):拒绝 AI 控制内容里的 [data-action]。 + // 威胁:isReadOnly=true(AI 普通文本回复)时,整块内容由 AI 控制的 markdown + // 渲染得到,DOMPurify 默认不过滤 data-* / /
`; + }).join(''); + + // 图标区域:预置名 → 取内置 SVG;自定义 SVG 字符串 → 直接用;无 → 不渲染。 + // icon 颜色由 CSS 的 .confirm-icon-wrap 变体控制(warning 走 --warning, danger 走 --danger)。 + const iconSvg = icon ? (CONFIRM_ICONS[icon] || icon) : ''; + // iconVariant 用于 CSS 着色:预置名直接传,自定义 SVG 默认走 'info'。 + const iconVariant = icon ? (CONFIRM_ICONS[icon] ? icon : 'info') : ''; + const iconHtml = iconSvg + ? `
${iconSvg}
` + : ''; + + const bodyHtml = `${iconHtml}

${escapeHtml(message)}

`; + + const modal = mountModal({ + title, + body: bodyHtml, + footer: footerHtml, + onAction: () => { + // 任一按钮按下:返回 'close' 由 mountModal 统一负责关闭 + // —— 自己不调 modal.close,避免与 modal.js 的 close(action) 重复触发 + return 'close'; + }, + onBackdropClose: 'cancel', + }); + + // 给 confirm 类型 modal 加专属 class —— CSS 用来收窄宽度(confirm 文案短, + // 不需要 settings dialog 的 560px)、调整 body padding 与 icon 布局。 + if (modal) modal.modal.classList.add('modal--confirm'); + + if (!modal) { + // P1-3:modal 已被另一个对话框占用(罕见但会发生,比如用户在设置对话框 + // 开着时按 Ctrl+S)。旧实现降级到 window.confirm —— 但 confirm 是浏览器 + // 原生模态,会与现有 custom modal 同屏出现:用户先关掉原生框,custom + // modal 还浮在上面,体验割裂。 + // 这里改为「静默 cancel」:把决定权交还给调用方,由它决定是排队重试还是 + // 干脆放弃(settings dialog 的 preUpdateHook 就是这么用的)。 + // + // audit fix (Phase L2 #7):但「重要冲突」场景(未保存改动、外部修改冲突) + // 静默 cancel 等于丢数据 —— 用户看到「啥都没发生」,可能误以为已保存。 + // 给一个 toast 提示用户「先关闭占用方」,与 settings-dialog 的行为对齐。 + // + // fix(audit 2026-08):原版与「用户主动取消」都返回 'cancel',导致调用方 + // (app.js 外部修改冲突)把 modal-occupied 误识别为用户主动选择「暂不处理」 + // → 静默推进 lastSavedMtimeMs → 后续外部修改被吞。改为返回 null,让调用方 + // 能区分「对话框没出来」与「用户主动取消」。 + showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning'); + resolve(null); + return; + } + + modal.setOnClose(resolve); + }); +} diff --git a/src/context-menu.js b/src/context-menu.js new file mode 100644 index 0000000..22bc660 --- /dev/null +++ b/src/context-menu.js @@ -0,0 +1,285 @@ +// 自定义右键菜单 +// +// 用于 markdown 阅读视图和编辑器,统一支持:复制 / 剪切 / 粘贴 / 全选。 +// +// 设计要点: +// - 单一浮动菜单实例,按需定位显示(同一时刻只可能有一个菜单) +// - 选项根据上下文(是否可编辑 / 是否有选区)动态启用禁用 +// - 完全基于 navigator.clipboard,不依赖浏览器默认菜单 +// - 显示位置靠近视口边缘时自动向内收缩 +// - 在 mousedown / 第二个 contextmenu / ESC / scroll / resize 时自动关闭 + +const MENU_ID = 'app-context-menu'; + +/** + * 右键菜单项定义 + * @typedef {object} ContextMenuItem + * @property {string} label - 显示文本 + * @property {string} value - 点击时回调收到的值 + * @property {boolean} [disabled] + * @property {boolean} [separator] - 若为 true 则渲染为分隔条(忽略其它字段) + */ + +/** + * 菜单配置 + * @typedef {object} ContextMenuShowOptions + * @property {number} x - 视口 x 坐标 + * @property {number} y - 视口 y 坐标 + * @property {ContextMenuItem[]} items + * @property {(value: string) => void} onSelect + */ + +export class ContextMenu { + /** + * @param {object} [options] + * @param {HTMLElement} [options.container] - 菜单挂载容器(默认 document.body) + */ + constructor({ container } = {}) { + this.container = container || document.body; + this.element = null; + this.onSelectCallback = null; + // audit fix (Settings P3 / context-menu focus restore): + // 记录 show 之前 document.activeElement —— hide() 时尝试把焦点还回去。 + // 原版没记录,菜单关闭后焦点散落到 ,键盘用户失位: + // - 侧栏 file-list 右键 → 选「重命名」后 dialog 打开,关闭后焦点 + // 跑回 body 而非原先选中的 file-list item,键盘用户必须再 Tab + // 一遍才能继续上下选。 + // - 编辑器右键 → 选「全选」或「粘贴」后,焦点丢在 body。 + // 守卫:忽略 / ,避免把焦点「还」到根(与 focus-trap.js + // dispose 路径保持同样的 prevFocus 过滤)。 + this._prevFocus = null; + this._blurFrame = null; + + // 提前 bind,方便 add/removeEventListener 引用同一函数 + this._onDocMouseDown = this._onDocMouseDown.bind(this); + this._onDocContextMenu = this._onDocContextMenu.bind(this); + this._onWindowScroll = this._onWindowScroll.bind(this); + this._onWindowResize = this._onWindowResize.bind(this); + this._onKeydown = this._onKeydown.bind(this); + this._onMenuFocusOut = this._onMenuFocusOut.bind(this); + } + + /** + * 显示菜单 + * @param {ContextMenuShowOptions} options + */ + show({ x, y, items, onSelect }) { + this.hide(); + + if (!Array.isArray(items) || items.length === 0) return; + + this.onSelectCallback = onSelect || null; + // 捕获 prevFocus:在菜单创建之前 activeElement 已经是「用户操作过的元素」, + // hide() 还要把焦点还回它。守卫 body/rootElement(与 focus-trap.js 同套)。 + { + const ae = document.activeElement; + this._prevFocus = (ae && ae !== document.body && ae !== document.documentElement + && typeof ae.focus === 'function') ? ae : null; + } + + const menu = document.createElement('div'); + menu.id = MENU_ID; + menu.className = 'context-menu'; + menu.setAttribute('role', 'menu'); + // audit L (a11y):menu 隐含 vertical orientation,但显式声明可减少 + // 屏幕阅读器误判(NVDA / JAWS 习惯按 aria-orientation 决定是 ←/→ 还是 ↑/↓) + menu.setAttribute('aria-orientation', 'vertical'); + + /** @type {HTMLButtonElement[]} */ + const menuItems = []; + for (const item of items) { + if (item && item.separator) { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + sep.setAttribute('role', 'separator'); + menu.appendChild(sep); + continue; + } + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'context-menu-item'; + btn.setAttribute('role', 'menuitem'); + btn.textContent = item.label; + if (item.disabled) { + btn.disabled = true; + } + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (btn.disabled) return; + const value = item.value; + // audit fix:先保存回调再 hide() —— hide() 内部会把 this.onSelectCallback + // 清成 null(旧顺序:hide() 在前 → 后续 if 检查永远失败 → 右键菜单点击 + // 全部静默失效)。这影响所有右键入口(侧栏 重命名/在文件夹中显示/删除、 + // 编辑器 复制/剪切/粘贴/全选)。 + const cb = this.onSelectCallback; + this.hide(); + if (cb) cb(value); + }); + menu.appendChild(btn); + menuItems.push(btn); + } + + this.container.appendChild(menu); + this.element = menu; + // 保留菜单项引用,供方向键导航复用。 + this._items = menuItems; + + // 定位:菜单尺寸必须先知道,所以先 append 再算坐标。 + // 视口边缘自适应收缩(避免菜单跑出屏幕外)。 + const rect = menu.getBoundingClientRect(); + const margin = 4; + const maxX = window.innerWidth - rect.width - margin; + const maxY = window.innerHeight - rect.height - margin; + const left = Math.max(margin, Math.min(x, Math.max(margin, maxX))); + const top = Math.max(margin, Math.min(y, Math.max(margin, maxY))); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + // 关闭监听必须在本次 contextmenu 事件结束之后再绑定, + // 否则本次的 mousedown 不会触发(右键按下时也会派发 mousedown), + // 但其他位置再点右键时本次菜单应被关闭,所以下一次 contextmenu 仍要监听。 + + // audit #C3 fix (memory leak critical):保存 rAF handle。 + // 旧实现:hide() 同步把 this.element = null,但 rAF 回调里依然跑 + // addEventListener — 如果用户在同一帧内连续 show→hide→show(旧菜单点选项 + // 触发 onSelect 立即 hide,新菜单又 show),第一次的 rAF 仍会 fire, + // 给 document / window 挂上 5 个 listener,且永远不会被 removeEventListener + // 回收(hide 看 this.element === null 直接 early-return)。每个周期泄漏 5 个 + // listener,伴随绑定函数对 this 的强引用 → GC 不掉 → 右键速度随时间线性变慢。 + // 现在:hide() 先 cancelAnimationFrame,下次 show 再请求;rAF 内若 this.element + // 已不是刚绑定的菜单,则不挂监听(防御性二次校验)。 + this._attachFrame = requestAnimationFrame(() => { + this._attachFrame = null; + // 二次校验:rAF fire 时如果 hide 已跑(this.element 已 null),跳过监听绑定。 + if (!this.element || this.element !== menu) return; + document.addEventListener('mousedown', this._onDocMouseDown, true); + document.addEventListener('contextmenu', this._onDocContextMenu, true); + window.addEventListener('scroll', this._onWindowScroll, true); + window.addEventListener('resize', this._onWindowResize, true); + document.addEventListener('keydown', this._onKeydown, true); + // audit fix (Settings P3 / context-menu blur close):菜单自身的 focusout。 + // 旧版只盯 mousedown / contextmenu / scroll / resize / Esc —— 但用户用 + // Tab 把焦点移出菜单再点别处时不会触发 mousedown(focus 已经离开菜单), + // 菜单留在屏幕上"卡死"。挂 focusout 在 capture 阶段,检查 relatedTarget + // 是否仍在菜单内 —— 不在则收起。 + menu.addEventListener('focusout', this._onMenuFocusOut, true); + // audit H (a11y):菜单打开后立即把焦点放在第一个非 disabled 项, + // 否则键盘用户没法用菜单(Tab 会跳到下个页面元素)。WAI-ARIA menu 模式 + // 要求"打开后焦点进入菜单"。 + const first = menuItems.find((b) => !b.disabled); + if (first) first.focus(); + }); + } + + /** + * 主动关闭菜单 + */ + hide() { + // 取消未 fire 的 rAF,避免它在我们 detach 之后再去挂监听(见 show 注释)。 + if (this._attachFrame != null) { + cancelAnimationFrame(this._attachFrame); + this._attachFrame = null; + } + if (!this.element) return; + const menu = this.element; + document.removeEventListener('mousedown', this._onDocMouseDown, true); + document.removeEventListener('contextmenu', this._onDocContextMenu, true); + window.removeEventListener('scroll', this._onWindowScroll, true); + window.removeEventListener('resize', this._onWindowResize, true); + document.removeEventListener('keydown', this._onKeydown, true); + menu.removeEventListener('focusout', this._onMenuFocusOut, true); + if (menu.parentElement) { + menu.parentElement.removeChild(menu); + } + this.element = null; + this._items = null; + this.onSelectCallback = null; + + // audit fix (Settings P3 / context-menu focus restore):hide 完成后 + // 把焦点还回 prevFocus。rAF 推一拍 —— 此刻 menu 刚 removeChild,焦点 + // 已经因 focusout / mousedown 落到 body;rAF 让出这一帧让用户操作稳定, + // 再把焦点给回 prevFocus,避免被后续的 focus() 事件覆盖。 + if (this._prevFocus) { + const target = this._prevFocus; + this._prevFocus = null; + // 二次校验:节点可能被外部重渲染撕下 DOM(file-list 在 files:changed + // 重建),与 focus-trap.js dispose 的 isConnected 守卫保持一致。 + if (target.isConnected) { + requestAnimationFrame(() => { + try { target.focus({ preventScroll: true }); } catch { /* ignore */ } + }); + } + } + } + + /** 当前是否处于显示状态 */ + isVisible() { + return !!this.element; + } + + _onDocMouseDown(e) { + if (this.element && this.element.contains(e.target)) return; + this.hide(); + } + + _onDocContextMenu(e) { + // 同一菜单内的右键:忽略,让菜单保持显示 + if (this.element && this.element.contains(e.target)) return; + // 其它位置的右键:先关掉自己,由后续事件处理器决定是否打开新菜单 + this.hide(); + } + + _onWindowScroll() { + this.hide(); + } + + _onWindowResize() { + this.hide(); + } + + _onKeydown(e) { + if (e.key === 'Escape') { + this.hide(); + return; + } + // audit H (a11y):menu 模式的方向键导航 —— ↑/↓ 移动焦点(跳过 disabled + // 与 separator),Home/End 跳首尾。WAI-ARIA Authoring Practices 推荐。 + if (!this._items || this._items.length === 0) return; + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End') { + // 只在焦点仍在菜单内时拦截 —— 防止外部其它快捷键(如 editor 里的 + // 方向键移动光标)被这里吞掉。 + const active = document.activeElement; + const inMenu = active && this.element && this.element.contains(active); + if (!inMenu) return; + e.preventDefault(); + const enabled = this._items.filter((b) => !b.disabled); + if (enabled.length === 0) return; + let idx = enabled.indexOf(active); + if (idx === -1) idx = e.key === 'ArrowUp' ? enabled.length - 1 : 0; + else if (e.key === 'ArrowDown') idx = (idx + 1) % enabled.length; + else if (e.key === 'ArrowUp') idx = (idx - 1 + enabled.length) % enabled.length; + else if (e.key === 'Home') idx = 0; + else if (e.key === 'End') idx = enabled.length - 1; + enabled[idx].focus(); + } + } + + // audit fix (Settings P3 / context-menu blur close): + // 焦点离开菜单时收起。 + // - capture 阶段抓 —— 让外层 focusout handler 先跑完(例如文件列表 + // 自己的 focus 高亮),我们再决定是否要 hide。 + // - relatedTarget 为 null(窗口失焦 / DevTools 抓走焦点)也收起,避免 + // 菜单"卡在屏幕上谁都看不见"。 + // - rAF 推一拍:focusout 与 mousedown 同帧时,rAF 让 mousedown 的 + // _onDocMouseDown 路径有机会先关菜单、避免双触发。 + _onMenuFocusOut(e) { + if (!this.element) return; + const next = e.relatedTarget; + if (next && this.element.contains(next)) return; + if (this._blurFrame != null) return; + this._blurFrame = requestAnimationFrame(() => { + this._blurFrame = null; + this.hide(); + }); + } +} \ No newline at end of file diff --git a/src/dirty-sync.js b/src/dirty-sync.js new file mode 100644 index 0000000..5534890 --- /dev/null +++ b/src/dirty-sync.js @@ -0,0 +1,29 @@ +// state.isDirty ↔ editor.isDirty() 单向提升同步 helper(Round 3 bug 修复) +// +// 抽离动机:app.js 的 syncDirtyFromEditor 是模块级闭包函数,无法 import 单测。 +// 把「单向上提」的纯逻辑挪到本文件,app.js 里再薄包一层负责 UI 同步 +//(syncSaveButton)。这样单测可以纯函数式验证「editor 有未保存字符时 state +// 必须被提升」契约,不需要 mock 一堆 app.js 模块级闭包。 +// +// 关键不变量(详见 app.js#syncDirtyFromEditor 注释块): +// - state.isDirty 是 onChange 维护的派生状态;markdown-editor.js updateListener +// 在 IME 合成期间跳过 onChange,state.isDirty 可能 stale。 +// - 半步保存场景:state.isDirty=true + editor.isDirty()=false —— 这是有意保留 +// 的「飞行期新字符」标记,本函数**绝不**把它盖成 false。 +// - 因此本函数只做 false → true 单向提升,永远不做 true → false。 + +/** + * 把 state.isDirty 同步到 editor.isDirty() 的实时真相(仅提升)。 + * + * @param {{ isDirty: boolean }} state - 应用状态对象(仅读 + 写 isDirty) + * @param {{ isDirty: () => boolean }} editor - 编辑器实例(提供 isDirty() 真相) + * @returns {boolean} true 表示发生了 false→true 的提升;false 表示无需提升 + * (已是 true / editor 也说没改动 / 半步保存场景保持 true) + */ +export function syncDirtyFromEditor(state, editor) { + if (!state.isDirty && editor.isDirty()) { + state.isDirty = true; + return true; + } + return false; +} \ No newline at end of file diff --git a/src/editor-theme.js b/src/editor-theme.js new file mode 100644 index 0000000..b772160 --- /dev/null +++ b/src/editor-theme.js @@ -0,0 +1,135 @@ +// CodeMirror 6 主题 —— 单一来源 +// +// 包含: +// - editorThemeSpec CSS 规则 spec(用我们的设计令牌覆盖 oneDark) +// - editorTheme 暗色主题(dark: true → 让 oneDark 接管配色) +// - editorThemeLight 亮色主题(不挂 dark,由 styles.css 的 [data-theme=light] 覆盖) +// - pickEditorTheme 按 body[data-theme] 选择对应 extension 数组 +// +// 选择高亮用 `!important`:oneDark 的 (0,5,0) 选择器特异性会盖住我们的规则, +// 否则用户在暗色主题下看到的是 oneDark 偏灰的 selection(看不清)。 +// +// 亮色 active line / active gutter / panels / cursor 等属性也用 !important: +// @codemirror/view 的 base theme 用 `&light` / `&dark` 选择器(特异性 (0,2,0)), +// 我们的 `.cm-xxx` 是 (0,1,0),会让 base theme 的硬编码色(亮 #e2f2ff / 暗 #222227) +// 覆盖我们的 var(--bg-hover)。亮色下「活动行 gutter 变浅蓝色块」就是这个原因。 + +import { EditorView } from '@codemirror/view'; +import { oneDark } from '@codemirror/theme-one-dark'; + +const editorThemeSpec = { + '&': { + // 显式用 var(--bg-viewer),跟 .editor-pane 同色 —— 视觉上仍是同一张画布, + // 但必须显式写:oneDark 内部会给 .cm-editor 涂 #282c34(硬编码), + // 不写会被它盖掉,导致 dark default 下编辑器比预览明显偏灰。 + // 用 !important 是因为 oneDark 的扩展在我们之后注入,CSS 源顺序让它赢。 + backgroundColor: 'var(--bg-viewer) !important', + color: 'var(--fg-primary) !important', + height: '100%', + fontFamily: 'var(--font-mono)', + fontSize: 'var(--fs-base)', + }, + '.cm-content': { + caretColor: 'var(--accent) !important', + // 水平内边距: + // - 左 0:让文字紧贴 gutter 右侧开始,与 .markdown-body 文字起点对齐 + // (gutter 自身宽度由 CodeMirror 自适应行号位数,约 30-40px, + // 与 viewer 的 padding-left 32px 偏差 < 10px,可接受) + // - 右 --space-7:与 viewer 的右侧内边距对齐 + // 垂直保留 --space-5:编辑器比 viewer 顶部留白小一半,写的时候更紧凑 + padding: 'var(--space-5) var(--space-7) var(--space-5) 0', + }, + '.cm-cursor, .cm-dropCursor': { + // base theme 给 .cm-cursor 设了黑色,&dark 下设 #528bff(oneDark 的 cursor 蓝)。 + // 我们的 accent 颜色被覆盖,需要 !important 盖掉。 + borderLeftColor: 'var(--accent) !important', + }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': { + backgroundColor: 'var(--accent-selection) !important', + }, + '::selection': { + backgroundColor: 'var(--accent-selection) !important', + }, + '.cm-activeLine': { + // 同样要盖掉 oneDark 的 #6699ff0b(蓝色调)+ base theme 的 &dark #99eeff33 / + // &light #cceeff44,改用我们中性的 bg-hover + backgroundColor: 'var(--bg-hover) !important', + }, + '.cm-activeLineGutter': { + // base theme 用 `&light .cm-activeLineGutter { background-color: #e2f2ff }` / + // `&dark { background-color: #222227 }`,特异性 (0,2,0) 盖住我们的 (0,1,0)。 + // 亮色下 active gutter 会变成浅蓝色块;暗色下变成深灰色块 —— 都是 base theme 赢。 + // 加 !important 后我们的 var(--bg-hover) 才能稳定生效。 + backgroundColor: 'var(--bg-hover) !important', + color: 'var(--fg-secondary) !important', + }, + '.cm-gutters': { + // 显式 bg-viewer 盖掉 oneDark 给 .cm-gutters 涂的 #282c34 + // —— 否则 gutter 会比 content 明显偏灰,看上去像"编辑区有个奇怪灰条" + backgroundColor: 'var(--bg-viewer) !important', + color: 'var(--fg-tertiary) !important', + border: 'none', + // 钉死宽度 = viewer 的 padding-left(--space-7 = 32px): + // 让 gutter 右侧紧贴的 .cm-content 文字起点 = viewer 文字起点, + // 双栏对比时「编辑」和「预览」同一行的 # 与 一 字真正对齐。 + // CodeMirror 默认 gutter 按行号位数自适应(约 30-35px), + // 钉死后 2 位数以内行号仍能放下;超长文件(千行+)再扩。 + width: 'var(--space-7)', + minWidth: 'var(--space-7)', + maxWidth: 'var(--space-7)', + }, + // 行号单元格:去掉默认 padding,让行号在 32px gutter 内右对齐贴边 + '.cm-gutter': { + padding: '0 6px 0 0', + }, + '.cm-scroller': { + fontFamily: 'var(--font-mono)', + lineHeight: '1.7', + }, + '.cm-panels': { + // base theme `&light .cm-panels { background-color: #f5f5f5; color: black }` / + // `&dark { background-color: #333338; color: white }` 会盖住我们的 (0,1,0) 选择器。 + // 搜索面板 / autocomplete 等等都用 .cm-panels,亮色下颜色不对。 + backgroundColor: 'var(--bg-elevated) !important', + color: 'var(--fg-primary) !important', + }, +}; + +/** 暗色主题:dark 标记让 oneDark 自动覆盖我们的 CSS 变量 */ +export const editorTheme = EditorView.theme(editorThemeSpec, { dark: true }); + +/** 亮色主题:不带 dark,由 styles.css [data-theme=light] 覆盖色板 */ +export const editorThemeLight = EditorView.theme(editorThemeSpec); + +// ---- 测试钩 --------------------------------------------------------------- +// editorThemeSpec 不直接 export(避免外部拿到 spec 后乱改)。 +// 但 vitest 没法直接断言 EditorView.theme() 的内部 CSSOM 表现 —— 它是私有结构, +// 只能断言我们可控的部分:backgroundColor / color 声明里带了 !important, +// 才能盖住 oneDark 后注入的同 specificity 规则(oneDark 的 .cm-editor { background-color: #282c34 }), +// 以及 @codemirror/view base theme 的 `&light` / `&dark` 规则(特异性 (0,2,0))。 +// +// 这些 getter 只在测试用,prod 路径不会触达。 +export const __editorThemeSpecForTests = { + get backgroundColor() { return editorThemeSpec['&'].backgroundColor; }, + get color() { return editorThemeSpec['&'].color; }, + get gutterBackgroundColor() { return editorThemeSpec['.cm-gutters'].backgroundColor; }, + get gutterColor() { return editorThemeSpec['.cm-gutters'].color; }, + get activeLineBackgroundColor() { return editorThemeSpec['.cm-activeLine'].backgroundColor; }, + get activeLineGutterBackgroundColor() { return editorThemeSpec['.cm-activeLineGutter'].backgroundColor; }, + get activeLineGutterColor() { return editorThemeSpec['.cm-activeLineGutter'].color; }, + get cursorBorderLeftColor() { return editorThemeSpec['.cm-cursor, .cm-dropCursor'].borderLeftColor; }, + get caretColor() { return editorThemeSpec['.cm-content'].caretColor; }, + get panelsBackgroundColor() { return editorThemeSpec['.cm-panels'].backgroundColor; }, + get panelsColor() { return editorThemeSpec['.cm-panels'].color; }, +}; + +/** + * 按 body[data-theme] 选择当前主题的 extensions 数组。 + * 暗色叠加 oneDark(语法高亮);亮色只用自己的覆盖。 + * + * @param {'dark' | 'light' | undefined} theme + * @returns {Array} + */ +export function pickEditorTheme(theme) { + return theme === 'light' ? [editorThemeLight] : [oneDark, editorTheme]; +} diff --git a/src/editor-toolbar.js b/src/editor-toolbar.js new file mode 100644 index 0000000..a8670b8 --- /dev/null +++ b/src/editor-toolbar.js @@ -0,0 +1,82 @@ +// 视图模式切换控件 +// 三态:preview(默认)/ edit / split +// 状态写 document.body.dataset.editorMode,CSS 据此切布局。 + +export const EDITOR_MODES = ['preview', 'edit', 'split']; + +export class EditorToolbar { + /** + * @param {object} options + * @param {object} options.elements - 三个按钮元素引用 { btnPreview, btnEdit, btnSplit } + * @param {(mode:'preview'|'edit'|'split') => void} options.onChange - 模式变化回调 + */ + constructor({ elements, onChange }) { + this.elements = elements; + this.onChange = onChange; + + if (elements.btnPreview) { + elements.btnPreview.addEventListener('click', () => this.selectMode('preview')); + } + if (elements.btnEdit) { + elements.btnEdit.addEventListener('click', () => this.selectMode('edit')); + } + if (elements.btnSplit) { + elements.btnSplit.addEventListener('click', () => this.selectMode('split')); + } + } + + /** + * 由用户点击按钮或外部 API 主动选择模式。触发 onChange(用于持久化)。 + * @param {'preview'|'edit'|'split'} mode + */ + selectMode(mode) { + if (!EDITOR_MODES.includes(mode)) return; + if (this.getMode() === mode) return; // 同模式 → 不重复触发 + this._setDOM(mode); + if (this.onChange) this.onChange(mode); + } + + /** + * 同步 DOM 与按钮高亮,但**不**触发 onChange。 + * 用于「外部(持久化设置)改变了模式」的场景,避免循环。 + * @param {'preview'|'edit'|'split'} mode + */ + setMode(mode) { + if (!EDITOR_MODES.includes(mode)) return; + this._setDOM(mode); + } + + /** + * 取当前模式(从 dataset 读,保证跟 DOM 一致) + */ + getMode() { + return document.body.dataset.editorMode || 'preview'; + } + + /** + * 三态循环:preview → edit → split → preview + * 触发 onChange 回调。 + */ + cycle() { + const cur = this.getMode(); + const idx = EDITOR_MODES.indexOf(cur); + const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length]; + this.selectMode(next); + } + + _setDOM(mode) { + document.body.dataset.editorMode = mode; + const map = { + preview: this.elements.btnPreview, + edit: this.elements.btnEdit, + split: this.elements.btnSplit, + }; + Object.entries(map).forEach(([k, btn]) => { + if (!btn) return; + const active = k === mode; + btn.classList.toggle('is-active', active); + // 让屏幕阅读器/键盘用户也能感知当前模式;icon-only 按钮尤其依赖此属性 + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + } +} diff --git a/src/event-emitter.js b/src/event-emitter.js new file mode 100644 index 0000000..6cedeee --- /dev/null +++ b/src/event-emitter.js @@ -0,0 +1,50 @@ +// 极简 EventEmitter +// SettingsStore 等组件需要的事件订阅基础设施。 +// 与 Todo List 的实现保持完全一致 —— 不引入任何外部依赖。 + +export class EventEmitter { + constructor() { + this._handlers = new Map(); + } + + on(event, handler) { + if (!this._handlers.has(event)) { + this._handlers.set(event, new Set()); + } + this._handlers.get(event).add(handler); + return () => this.off(event, handler); + } + + off(event, handler) { + const set = this._handlers.get(event); + if (set) set.delete(handler); + } + + emit(event, payload) { + const set = this._handlers.get(event); + if (!set || set.size === 0) return; + // audit fix (Settings P3 / event-emitter snapshot):派发时先对 Set + // 拍一份快照。Set 在迭代期间的修改语义: + // - 新增的 handler:会被访问(ECMA-262 Set 规范)。 + // - 删除的 handler(如果已访问过):不影响本次迭代。 + // - 删除当前正在访问的 handler:行为未定义 / 跨引擎不一致。 + // 一个 handler 内部再去 on/off 同名事件就会触发上面第三条: + // - 副作用"重订阅"导致本次 emit 又派发给新 handler(新 handler 收到 + // 上一轮事件,常见 bug)。 + // - 副作用"自 off"导致部分 handler 被跳过。 + // - handler 抛错后被 catch 但后续 handler 仍需正常执行 —— 这条原来 OK。 + // 拍快照后所有路径确定性,跨 V8 / JSC / SpiderMonkey 行为一致。 + // 性能:O(n) copy,对 settingsStore 等小规模事件总线无可观测开销。 + const snapshot = Array.from(set); + for (const h of snapshot) { + // 二次校验:handler 在前面的执行里可能 off 自己(snapshot 拿到时还在 + // set 里,但下面那次循环已 off),跳过避免重复派发。 + if (!set.has(h)) continue; + try { + h(payload); + } catch (e) { + console.error(`[EventEmitter] handler for "${event}" threw:`, e); + } + } + } +} diff --git a/src/feedback.js b/src/feedback.js new file mode 100644 index 0000000..03bf476 --- /dev/null +++ b/src/feedback.js @@ -0,0 +1,68 @@ +// 轻量反馈组件 +// 仅保留 toast —— Notes 是只读阅读器,不需要 confirmDialog / inputDialog / contextMenu。 + +const TOAST_DEFAULT_DURATION = 3000; +// 同时存在的 toast 上限 —— 超出时关闭最早创建的,避免极端情况下无限堆积占满 DOM。 +const MAX_VISIBLE_TOASTS = 5; + +/** + * 显示一个 toast 消息 + * @param {string} message - 消息内容 + * @param {'info'|'success'|'warning'|'error'} [type='info'] + * @param {number} [duration=3000] - 自动关闭毫秒数;0 表示不自动关闭 + */ +export function showToast(message, type = 'info', duration = TOAST_DEFAULT_DURATION) { + const container = document.getElementById('toast-container'); + if (!container) { + // 无 toast container 是早期启动或测试环境(#toast-container 还没挂上), + // 静默丢弃 —— 这里原本的 console.log 会在生产控制台产生噪声,没人在读。 + return; + } + + // 超出上限时立刻同步移除最早的 toast(不等淡出动画)—— 顶部满的时候底部瞬间 + // 消失一条是可接受的,比排队等淡出再插队让用户以为 toast 卡住了要直观。 + // 同时打上 dismissed 标记,避免它自身的自动关闭 timer / click handler + // 在 toast 已脱离 DOM 后还跑回调(虽然 parentElement 检查能拦住,但 + // dataset 检查短路更稳,少一次 console 输出)。 + while (container.children.length >= MAX_VISIBLE_TOASTS) { + const oldest = container.firstElementChild; + if (!oldest) break; + if (oldest.parentElement === container) { + oldest.dataset.dismissed = '1'; + container.removeChild(oldest); + } + } + + const toast = document.createElement('div'); + toast.className = `toast is-${type}`; + toast.setAttribute('role', 'status'); + toast.textContent = message; + + container.appendChild(toast); + + let timer = null; + if (duration > 0) { + timer = setTimeout(() => dismiss(), duration); + } + + // 点击立即关闭 + toast.addEventListener('click', () => dismiss()); + + function dismiss() { + if (timer) { + clearTimeout(timer); + timer = null; + } + // 防止 transition 重入:若已经在淡出中,直接返回 + if (toast.dataset.dismissed === '1') return; + toast.dataset.dismissed = '1'; + toast.style.opacity = '0'; + toast.style.transform = 'translateY(-4px)'; + toast.style.transition = 'opacity 150ms ease, transform 150ms ease'; + setTimeout(() => { + if (toast.parentElement === container) { + container.removeChild(toast); + } + }, 150); + } +} diff --git a/src/file-list.js b/src/file-list.js new file mode 100644 index 0000000..a3eecd1 --- /dev/null +++ b/src/file-list.js @@ -0,0 +1,476 @@ +// 侧边栏文件列表组件 +// 职责:渲染文件列表 + 处理点击 + 处理搜索过滤 + 排序 +// +// 与 Todo List 的 sidebar.js 区别:移除分类、子分类、右键菜单、拖拽、智能列表。 +// 只保留 "扁平文件列表 + 点击打开 + 搜索过滤 + 排序" 这四件事。 +// +// Stage 8(Folder Browser): +// 列表里可以同时出现三种条目: +// - entryType === 'folder' : 进入子目录 → onSelect(entry) 中 entryType === 'folder' +// - entryType === 'editable': 打开编辑 → onSelect(entry) 中 entryType === 'editable' +// - entryType === 'binary' : 不支持打开,点击弹 toast → onSelect(entry) 中 entryType === 'binary' +// 渲染端 (app.js) 在 onSelect 里按 entryType 分发;本组件只负责按类型画图标 + 类名。 +// data-path 仍用绝对路径(文件夹也有 path = 绝对目录),保持事件委托反查统一。 + +import { formatSize, formatRelativeTime, pathsEqual } from './utils.js'; +import { createDisposable } from './component.js'; + +/** + * 文件名 fuzzy 匹配(子序列 + 评分) + * + * 算法:query 的每个字符按顺序出现在 name 里(不要求相邻)。 + * - 大写 / 小写不敏感(两端都 lowercase 后比较) + * - 越靠前的命中越得分高(开头匹配 > 末尾匹配) + * - 单词边界命中再加分(`n` 出现在「_」/「.」/「-」后立刻得分 boost) + * - 完全包含子串 → 等同 fuzzy 得分 +50(避免「完全匹配反而排后」的反直觉) + * + * 返回 0 表示不匹配(filter 直接丢弃)。 + * + * 约定:query 必须是小写。FileList 在 input handler 处统一做了 lowercase, + * 这里不再重复 —— N 个文件就少 N 次 String#toLowerCase 调用。 + * + * @param {string} name - 文件名(含扩展名) + * @param {string} lowerQuery - 已 lowercase 的搜索词 + * @returns {number} 匹配分数;0 = 不匹配 + */ +function fuzzyScore(name, lowerQuery) { + if (!lowerQuery) return 1; // 空 query = 不过滤 + const n = name.toLowerCase(); + + // 子串包含 → 直接高分(fuzzy 子序列可能反而排后面) + let idx = n.indexOf(lowerQuery); + if (idx >= 0) { + // 靠前 + 单词边界 + 命中整词 → 更高分 + let bonus = 0; + if (idx === 0) bonus += 30; + else if (idx > 0 && /[_\-.\s]/.test(n[idx - 1])) bonus += 20; + return 100 + bonus - idx; + } + + // 子序列匹配(fuzzy) + let qi = 0; + let score = 0; + let lastMatchIdx = -1; + for (let ni = 0; ni < n.length && qi < lowerQuery.length; ni++) { + if (n[ni] === lowerQuery[qi]) { + // 相邻匹配加分(连续命中),否则只加基础分 + if (lastMatchIdx === ni - 1) score += 8; + else score += 5; + // 单词边界 bonus + if (ni === 0 || /[_\-.\s]/.test(n[ni - 1])) score += 4; + // 位置越靠前 bonus 越多 + score += Math.max(0, 10 - Math.floor(ni / 3)); + lastMatchIdx = ni; + qi++; + } + } + if (qi < lowerQuery.length) return 0; // 没匹配完所有字符 + return score; +} + +/** + * 文件列表组件 + */ +export class FileList { + /** + * @param {object} options + * @param {HTMLUListElement} options.element -
    容器 + * @param {HTMLInputElement} options.searchInput - 搜索输入框 + * @param {(file: {path:string,name:string}) => void} options.onSelect - 点击文件回调 + * @param {string} [options.sortMode='name'] - 排序模式:'name' | 'mtime-desc' + */ + constructor({ element, searchInput, onSelect, onContextMenu, sortMode = 'name' }) { + this.element = element; + this.searchInput = searchInput; + this.onSelect = onSelect; + this.onContextMenu = onContextMenu; + this.files = []; + this.filteredFiles = []; + this.activePath = null; + this.searchTerm = ''; + this.sortMode = sortMode === 'mtime-desc' ? 'mtime-desc' : 'name'; + /** path → file,供事件委托时反查(避免每个
  • 挂一个闭包) */ + this._byPath = new Map(); + + // 统一 lifecycle:dispose() 一次性 unbind 所有 listener。 + createDisposable(this); + + // 事件委托:整个列表只挂一对 click / keydown 监听。 + // 原来是每次 render 给每个
  • 各挂两个闭包 —— 列表一重建就要重新分配 2N 个函数, + // 搜索时每敲一个字符都重建一次。委托后 render 只管建 DOM。 + if (element) { + element.addEventListener('click', (e) => { + const file = this._fileFromEvent(e); + if (file && this.onSelect) this.onSelect(file); + }, { signal: this.signal }); + + // 键盘激活与导航:Enter/Space 等价于点击;↑/↓/Home/End 走 moveActive + // (moveActive 只挪高亮,不会打开文件 —— 打开动作由 Enter/Space 触发, + // 走 _fileFromEvent(e) 从当前 focus 所在 LI 反查文件)。 + element.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); this.moveActive(1); return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); this.moveActive(-1); return; + } + if (e.key === 'Home') { + e.preventDefault(); this.moveActive('first'); return; + } + if (e.key === 'End') { + e.preventDefault(); this.moveActive('last'); return; + } + if (e.key === 'Enter' || e.key === ' ') { + const file = this._fileFromEvent(e); + if (!file) return; + e.preventDefault(); + if (this.onSelect) this.onSelect(file); + } + }, { signal: this.signal }); + + // 右键菜单:弹给调用方处理(context-menu.js) + element.addEventListener('contextmenu', (e) => { + const file = this._fileFromEvent(e); + if (!file || !this.onContextMenu) return; + e.preventDefault(); + this.onContextMenu(file, e.clientX, e.clientY); + }, { signal: this.signal }); + } + + if (searchInput) { + // 100ms trailing debounce —— 文件多时(1000+)每按一键全量重算 + 重渲染 + // 是 O(N) 的工作,连续输入会卡;debounce 让连续打字期间 CPU 闲下来。 + // { signal: this.signal } 走 AbortController 路径,dispose 时自动解绑。 + let searchTimer = null; + const flushSearch = () => { + searchTimer = null; + this.searchTerm = searchInput.value.trim().toLowerCase(); + this.applyFilter(); + this.render(); + }; + // dispose 时把挂起的 debounce 定时器清掉 —— 否则它会在组件已销毁后 + // 触发 flushSearch,访问已被释放的 this.searchTerm / applyFilter / render。 + // 见 createDisposable.addAbort 的「abort 时一次性执行清理」语义。 + this.addAbort(() => { + if (searchTimer) { + clearTimeout(searchTimer); + searchTimer = null; + } + }); + searchInput.addEventListener('input', () => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(flushSearch, 100); + }, { signal: this.signal }); + // 失焦时立即落盘当前值,不等 debounce —— 用户离开搜索框后还看到旧结果会困惑 + searchInput.addEventListener('blur', () => { + if (searchTimer) { + clearTimeout(searchTimer); + searchTimer = null; + flushSearch(); + } + }, { signal: this.signal }); + } + } + + /** + * 从委托事件里反查对应的 file 对象。 + * @param {Event} e + * @returns {object|null} + */ + _fileFromEvent(e) { + const li = e.target.closest && e.target.closest('.file-item'); + if (!li || !this.element.contains(li)) return null; + return this._byPath.get(li.dataset.path) || null; + } + + /** + * 设置排序模式(供设置对话框等调用方热切换) + * @param {'name' | 'mtime-desc'} mode + */ + setSort(mode) { + const next = mode === 'mtime-desc' ? 'mtime-desc' : 'name'; + if (next === this.sortMode) return; + this.sortMode = next; + this.applyFilter(); + this.render(); + } + + /** + * 用新的条目列表替换当前列表 + * + * Stage 8:接受混合条目(文件夹 + editable + binary)。每条至少含 + * { name, path, entryType: 'folder'|'editable'|'binary', isFolder?: boolean, size?, mtimeMs? } + * 向后兼容:旧调用传 { name, path, size, mtimeMs }(无 entryType)→ 视为 'editable'。 + * + * @param {Array} entries + */ + setFiles(entries) { + const previousActive = this.activePath; + this.files = Array.isArray(entries) + ? entries.map((e) => ({ + ...e, + entryType: e.entryType || (e.isFolder ? 'folder' : 'editable'), + })) + : []; + + // M-series fix (audit Phase M-H2):移除 setFiles 内的 onFileRemoved 触发。 + // 旧实现仅凭「previousActive 不在新 entries 里」就通知上层清空编辑器—— + // 这把「用户导航到子目录」(Folder Browser 语义:浏览期间不自动关文件) + // 与「文件被外部删除」混淆为同一信号,导致切子目录时编辑器被错误清空。 + // + // 改成 setFiles 只负责渲染,外部清理判定上移到 app.js#applyEntries: + // 用「文件目录 === 当前浏览目录」区分「文件被删」与「导航到别处」。 + // signature:`activePath` 与新 entries 不再自动清零 —— 由调用方决定要不要 + // 通过 setActive(null) 主动清,否则高亮会跨目录污染。 + // fix(audit 2026-08):Windows 路径大小写不敏感,=== 会让外部重命名(小 + // 小写差异)后侧栏失去高亮。统一走 pathsEqual。 + // + // 审计修复 (Round 11 P1-1):用 fs 返回的真实 case 覆盖 previousActive。 + // 旧逻辑保留 previousActive(用户输入时的大小写),state.currentFile.path + // 与侧栏 data-path 出现大小写漂移,IPC / 日志 / 错误消息里看着像两个文件。 + // 现在回迁时优先取 this.files 里的真值(fs 返回的磁盘 case),IPC 跨进程 + // 看到的路径字符串统一。 + const canonicalForActive = (active) => { + if (!active) return active; + const hit = this.files.find((f) => pathsEqual(f.path, active) && f.entryType !== 'folder'); + return hit ? hit.path : active; + }; + if (previousActive + && !this.files.some(f => pathsEqual(f.path, previousActive) && f.entryType !== 'folder')) { + // audit fix (Round 9):保留「activePath 跨目录导航」回迁语义。 + // 之前这里直接 activePath=null,用户切走再切回原目录时 entries 又含 + // previousActive,但 activePath 已被清,侧栏不自动恢复高亮。 + // 改成:当前目录不含 previousActive 时不主动清,存到 lastSeenActivePath + // 等下次 setFiles 时回迁;如果上层调 setActive(null) 显式清则一切如旧。 + this.lastSeenActivePath = previousActive; + this.activePath = null; + } else if (previousActive + && this.files.some(f => pathsEqual(f.path, previousActive) && f.entryType !== 'folder')) { + // 回迁到原目录(entries 又含 previousActive)→ 恢复 activePath, + // 同时把大小写统一到 fs 真实 case(见上 canonicalForActive 注释)。 + this.activePath = canonicalForActive(previousActive); + this.lastSeenActivePath = null; + } else if (this.activePath) { + // 兜底:当前 activePath 还在 setFiles 命中的新 entries 里时,统一一次大小写。 + // 例如外部 openFile 后接着 setFiles 没经过回迁分支,activePath 仍可能是旧 case。 + this.activePath = canonicalForActive(this.activePath); + } + + this.applyFilter(); + this.render(); + } + + /** + * 设置当前激活的文件(侧边栏高亮 + 调用方同步状态栏) + * + * 只挪 is-active 类,不重建列表 —— 这是最高频的渲染触发点(每次打开文件都走), + * 而列表内容并没有变化,重建一遍纯属浪费。 + * @param {string|null} filePath + */ + setActive(filePath) { + const next = filePath || null; + if (next === this.activePath) return; + this.activePath = next; + if (!this.element) return; + // audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入 + // 的 case,fs 返回的是磁盘真实 case。直接 === 比对会让侧栏永远找不到 active + // 条目、不显示高亮。统一走 pathsEqual(含 Windows case-fold)。 + for (const li of this.element.children) { + const active = pathsEqual(li.dataset.path, this.activePath); + li.classList.toggle('is-active', active); + li.setAttribute('aria-selected', active ? 'true' : 'false'); + } + } + + /** + * 列表获焦时由键盘导航调用:移动激活态到相邻项。只挪高亮 + 焦点, + * 不触发 onSelect —— 打开文件由 Enter / Space 键负责(走 _fileFromEvent)。 + * 旧版本在这里直接 onSelect,导致每按一次方向键就打开一个文件。 + * @param {number} delta -1 上一项 / +1 下一项 / 'first' / 'last' + */ + moveActive(delta) { + const items = Array.from(this.element.querySelectorAll('.file-item')); + if (items.length === 0) return; + // audit fix:与 setActive 保持一致 —— Windows 上 case-fold 比对。 + let idx = items.findIndex(li => pathsEqual(li.dataset.path, this.activePath)); + if (idx < 0) idx = 0; + if (delta === 'first') idx = 0; + else if (delta === 'last') idx = items.length - 1; + else idx = Math.max(0, Math.min(items.length - 1, idx + delta)); + const target = items[idx]; + // 直接给目标项加高亮,避免 setActive→render 整树重画把焦点带走 + items.forEach(li => { + const active = li === target; + li.classList.toggle('is-active', active); + li.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + this.activePath = target.dataset.path; + // audit fix (2.4):focus 默认会触发 scrollIntoView → 侧栏布局可能跳动。 + // 改用 preventScroll,再用 scrollIntoView('nearest') 把项拉到可视区但不强制居中。 + try { target.focus({ preventScroll: true }); } catch { /* ignore */ } + try { target.scrollIntoView({ block: 'nearest' }); } catch { /* ignore */ } + } + + /** + * 应用搜索过滤 + 排序 + * + * 搜索:先按 fuzzy 评分过滤 + 排序(高分在前), + * 再按 sortMode 排序(name 或 mtime) —— 排序优先级: + * 1) searchTerm 存在 → fuzzy 排序覆盖 sortMode(用户明确在搜) + * 2) searchTerm 为空 → 走 sortMode + * + * 这样 "wel" 仍能在 mtime-desc 模式下把 welcome.md 排前,而不是按修改时间硬排。 + */ + applyFilter() { + const term = this.searchTerm; + let list; + if (!term) { + list = this.files.slice(); + } else { + // fuzzy:打分并过滤 + const scored = []; + for (const f of this.files) { + const score = fuzzyScore(f.name, term); + if (score > 0) scored.push({ file: f, score }); + } + scored.sort((a, b) => b.score - a.score); + list = scored.map(s => s.file); + } + // 仅在无搜索时使用 sortMode;有搜索时 fuzzy 排序已覆盖 + if (!term) { + if (this.sortMode === 'mtime-desc') { + list.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0)); + } else { + list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')); + } + } + this.filteredFiles = list; + } + + /** + * 渲染 DOM + */ + render() { + if (!this.element) return; + this.element.replaceChildren(); + + // 重建 path → file 索引,供委托的 click / keydown 反查 + this._byPath.clear(); + for (const file of this.filteredFiles) { + this._byPath.set(file.path, file); + } + + if (this.filteredFiles.length === 0) { + // 空状态分两种:搜索无结果 vs 目录真为空。 + // - 搜索有词 → "没找到匹配项"(让用户清空搜索或换关键词) + // - 搜索为空 → "此文件夹为空"(index.html 的 data-empty 默认值; + // setFiles 调用时 element 还没渲染数据时 element.dataset.empty 还是这个) + // 这样 UI 不会把"搜不到"误读成"目录空",也避免反过来。 + const emptyMsg = this.searchTerm + ? `没有匹配 “${this.searchTerm}” 的文件` + : '此文件夹为空'; + this.element.dataset.empty = emptyMsg; + return; + } + + // 有内容时清掉 data-empty,避免渲染期间被 :empty::after 短暂闪一下 + this.element.removeAttribute('data-empty'); + + const term = this.searchTerm; + + const frag = document.createDocumentFragment(); + for (const file of this.filteredFiles) { + const li = document.createElement('li'); + li.className = 'file-item'; + li.dataset.path = file.path; + // entryType / isFolder 是渲染端做图标 / 灰态分流的依据, + // 也让右键菜单 / onSelect 分发有统一锚点。 + li.dataset.entryType = file.entryType; + li.classList.add(`is-${file.entryType}`); + // 列表项自身是按钮语义:通过 role/tabindex/aria-selected 把键盘可达性补齐 + // (容器 ul 已经声明 role="listbox",单项用 role="option" 是规范对应) + li.setAttribute('role', 'option'); + li.tabIndex = 0; + // audit fix:Windows 路径大小写不敏感;fs 返回的真实 case 不一定等于 + // state.currentFile.path 上记录的 case,统一走 pathsEqual。 + const isActive = pathsEqual(file.path, this.activePath); + if (isActive) { + li.classList.add('is-active'); + li.setAttribute('aria-selected', 'true'); + } else { + li.setAttribute('aria-selected', 'false'); + } + + const name = document.createElement('div'); + name.className = 'file-name'; + // 搜索高亮:纯 textContent + createElement('mark'),避免 innerHTML 引入 XSS + if (term) { + const lowerName = file.name.toLowerCase(); + const idx = lowerName.indexOf(term); + if (idx >= 0) { + const before = file.name.slice(0, idx); + const hit = file.name.slice(idx, idx + term.length); + const after = file.name.slice(idx + term.length); + if (before) name.appendChild(document.createTextNode(before)); + const mark = document.createElement('mark'); + mark.textContent = hit; + name.appendChild(mark); + if (after) name.appendChild(document.createTextNode(after)); + } else { + name.textContent = file.name; + } + } else { + name.textContent = file.name; + } + + const meta = document.createElement('div'); + meta.className = 'file-meta'; + // Stage 8:folder / binary 没有 size+mtime,只有 editable 显示完整 meta。 + // folder 显示「文件夹」;binary 显示大小 + 「不可编辑」提示。 + if (file.entryType === 'folder') { + meta.textContent = '文件夹'; + } else if (file.entryType === 'binary') { + meta.textContent = `${formatSize(file.size)} · 不可编辑`; + } else { + meta.textContent = `${formatSize(file.size)} · ${formatRelativeTime(file.mtimeMs)}`; + } + + // 文件图标:左侧装饰,让 list 项视觉更有「文件感」。 + // aria-hidden,因为纯视觉;语义信息已经在 file-name 里。 + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('class', 'file-icon'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('width', '14'); + icon.setAttribute('height', '14'); + icon.setAttribute('fill', 'none'); + icon.setAttribute('stroke', 'currentColor'); + icon.setAttribute('stroke-width', '1.8'); + icon.setAttribute('stroke-linecap', 'round'); + icon.setAttribute('stroke-linejoin', 'round'); + icon.setAttribute('aria-hidden', 'true'); + // 不同类型用不同 SVG path:folder / file / binary(带斜杠禁止感) + if (file.entryType === 'folder') { + icon.innerHTML = ''; + } else if (file.entryType === 'binary') { + // 文件 + 斜杠:表达「不可编辑」 + icon.innerHTML = ''; + } else { + icon.innerHTML = ''; + } + + // 文本容器:把 name + meta 包成一列,让整体用 grid 布局对齐 icon。 + // 直接 append name/meta 到 li 会让它们和 icon 一起进入 grid 的同一列, + // 排版混乱;所以包一层 .file-text。 + const text = document.createElement('div'); + text.className = 'file-text'; + text.appendChild(name); + text.appendChild(meta); + + li.appendChild(icon); + li.appendChild(text); + // 点击 / 键盘由构造函数里的委托监听统一处理,这里不再逐项绑定 + frag.appendChild(li); + } + this.element.appendChild(frag); + } +} diff --git a/src/file-ops.js b/src/file-ops.js new file mode 100644 index 0000000..fdbcc2c --- /dev/null +++ b/src/file-ops.js @@ -0,0 +1,481 @@ +// 文件操作层(Stage 4c 抽离) +// +// 渲染端文件 CRUD:创建、重命名、删除、显示。 +// 全部走 IPC 调用主进程,结果落到 toast / state / 当前视图上。 +// +// 设计: +// - 接收上下文对象(state / controls / api / callbacks),不直接 import app.js +// - 通过 createFileOps(ctx) factory 暴露,避免任何模块级副作用 +// - 公开 API 仅 showFileListContextMenu / createNewFile;其他函数仅内部互调 +// +// 边界: +// - 不读 currentFile / isDirty 等 state 字段之外的全局 +// - 不调 updateWindowTitle / syncSaveButton 等 UI 同步函数以外的回调 +// (完整名单见 ctx.callbacks) + +// 文件系统 errno → 中文友好提示:Round 4 收尾后抽离到 shared/friendly-fs-error.js, +// preload 暴露成 window.api.friendlyFsError。本文件内部 + 测试都走这一份, +// 不再各自维护 mapping —— 避免 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 文案 +// 漂移。本文件调用点直接用 window.api.friendlyFsError(...)。 + +import { showToast } from './feedback.js'; +import { showPromptDialog } from './prompt-dialog.js'; +import { showConfirmDialog } from './confirm-dialog.js'; +import { isMarkdownFile } from './utils.js'; +// 审计修复 (Round 11 deep-fix P1-1):create / delete / rename 三个回调的入口 +// 在读 state.isDirty 之前必须先 syncDirtyFromEditor —— 否则 CJK 用户在 IME +// 合成期调「新建 / 删除 / 重命名」时,state.isDirty 是 stale false,跳过 +// dirty 确认 → 直接销毁用户当前编辑的内容。app.js save() / openFile() / +// 关窗已经走这个路径(详见 src/dirty-sync.js 注释);file-ops 这一层之前漏了。 +import { syncDirtyFromEditor } from './dirty-sync.js'; + +// 审计修复 (Round 11 vol.2):客户端文件名预校验。 +// +// 镜像 main/file-ops.js resolveRenameName / resolveFileName 的「输入合法性」检查 +// (路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符 / 非空)。 +// 不镜像 main 的「存在性 + 自动避让」逻辑 —— 那是 IPC 端的事,rename/create 都 +// 走 main 协商,不在 renderer 重复实现。 +// +// 为什么不共享同一份代码:main 是 CJS(require('fs') + path.join 路径协商), +// renderer 是 ESM 且不能 require Node 模块。共享会带来上下文桥 / bundle 复杂度。 +// 退而求其次:把这条规则文档化在 main + 本注释,变更时手工同步 —— 改动频次极低 +// (Windows 保留设备名清单基本不变),双拷贝维护成本可控。 +const WIN_RESERVED_DEVICE_NAMES = new Set([ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', +]); + +/** + * 校验输入是否可作为文件名(含扩展名或无)。返回 null = 通过;返回字符串 = 错误消息。 + * 用于 prompt-dialog 的 validate 选项,在用户点确认时立即拦截,不让非法值走到 IPC。 + * + * @param {string} raw + * @returns {string|null} + */ +function validateFileName(raw) { + if (typeof raw !== 'string' || !raw.trim()) return '文件名不能为空'; + const name = raw.trim(); + if (/[/\\]/.test(name) || name.includes('..')) return '文件名不能包含路径分隔符或 ..'; + if (/[<>:"|?*]/.test(name)) return '文件名包含 Windows 保留字符(< > : " | ? *)'; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(name)) return '文件名包含非法字符'; + // Windows 保留设备名(取最后一个 . 之前的部分判断;与 main resolveRenameName 保持一致) + const lastDot = name.lastIndexOf('.'); + const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase(); + if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) { + return `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名`; + } + return null; +} + +/** + * @typedef {object} FileOpsCtx + * @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs) + * @property {object} controls - { fileList, viewer, editor, contextMenu } + * @property {object} api - preload contextBridge 暴露的 window.api + * @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? } + */ + +/** + * @param {FileOpsCtx} ctx + */ +export function createFileOps(ctx) { + const { state, controls, api, callbacks } = ctx; + if (!state || !controls || !api || !callbacks) { + throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks'); + } + const { contextMenu, viewer, fileList, editor } = controls; + const { openFile, updateWindowTitle, clearCurrentFile } = callbacks; + + /** + * 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名)。 + */ + function formatStampForName(d) { + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; + } + + /** + * 在系统文件管理器中显示条目(文件 / 文件夹 分支)。 + * - 文件(editable / binary):走 `shell:show-item-in-folder`,通过 + * shell.showItemInFolder 实现「打开父目录并选中文件」语义 + * (macOS = Reveal,Win/Linux = 资源管理器选中)。 + * - 文件夹:走 `shell:open-dir`,通过 shell.openPath 直接打开目录本身 + * —— showItemInFolder 在主进程会 stat.isFile() 校验,目录会被拒 + * (NOT_A_FILE),用户点「在文件夹中显示」反而弹错误 toast。 + * + * Stage 8:folder 条目仍保留该入口,期望是「用系统文件管理器打开这个目录」 + * (参见 showFileListContextMenu 注释)。 + */ + async function revealFile(file) { + if (!file || !file.path) return; + // 文件夹条目不能用 showItemInFolder(主进程会 stat.isFile() 校验, + // 目录被拒 → 弹「不是一个文件」)。改走 openDir,让 shell.openPath + // 打开目录本身(Win/linux 进 Explorer,macOS 进 Finder)。 + const isFolder = file.entryType === 'folder'; + const result = isFolder + ? await api.openDir(file.path) + : await api.showItemInFolder(file.path); + if (!result || !result.ok) { + showToast(`显示失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error'); + } + } + + /** + * 删除文件:弹 confirm → 走 shell.trashItem → 如果是当前打开的文件则清空视图。 + * 即便 IPC 返回 FILE_NOT_FOUND,也照样清空视图(说明文件已不在数据目录里)。 + * + * Phase 2 audit 修复 (P1 BUG #4):删除飞行期 fs-watcher 可能先推 files:changed + * (A 不在 entries),onFilesChanged 的 rescue 分支会把当前 dirty 内容救到 + * `-rescued-.md`(与被删的 A 内容相同),然后 clearCurrentFile — + * 这是「删除」操作的孤儿副作用。修复:进 deleteFile 前设 state.deletingPath = + * file.path;onFilesChanged rescue 分支遇此标记跳过 rescue。finally 清标记 + * —— 不论 IPC 成功/失败/discarded。 + */ + async function deleteFile(file) { + if (!file || !file.path) return; + // 审计修复 (Round 11 deep-fix P1-1):syncDirtyFromEditor 把 editor.isDirty() + // 真相同步到 state.isDirty,避免 IME 合成期删除当前文件时跳过 dirty 确认 + // 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。 + syncDirtyFromEditor(state, editor); + const isCurrent = state.currentFile && state.currentFile.path === file.path; + // 删除当前文件 + 有未保存改动 → 二次确认 + if (isCurrent && state.isDirty) { + const choice = await showConfirmDialog({ + title: '删除未保存的文件', + message: `「${file.name}」有未保存改动,确定要删除(移至回收站)吗?`, + icon: 'danger', + buttons: [ + { value: 'cancel', label: '取消', variant: 'secondary' }, + { value: 'confirm', label: '删除', variant: 'danger' }, + ], + }); + if (choice !== 'confirm') return; + } + // Phase 2 audit (P1 BUG #4):设删除守卫,挡住 fs-watcher rescue 误触发 + state.deletingPath = file.path; + try { + const result = await api.deleteFile(file.path); + if (!result || !result.ok) { + if (result && result.error === 'FILE_NOT_FOUND') { + // 文件已不在了(用户手动删过、外部工具删过)—— 仿照 renameCurrentOrFile + // 的 FILE_NOT_FOUND 路径:dirty 状态下不能直接 clearCurrentFile 丢内容。 + // 先取 editor.getContent() 救到 -rescued--.md,再清视图。 + // audit fix (Round 4 F1):之前 deleteFile 的 FILE_NOT_FOUND 直接 clearCurrentFile + // → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged + // rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。 + if (state.isDirty && state.currentFile && state.currentFile.path === file.path) { + const originalName = state.currentFile.name || 'untitled.md'; + const base = originalName.replace(/\.md$/i, ''); + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); + const suffix = Math.random().toString(36).slice(2, 6); + const rescueName = `${base}-rescued-${stamp}-${suffix}.md`; + const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : ''; + // 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。 + api.createFile(rescueName, rescueContent).then((r) => { + if (r && r.ok) { + showToast(`文件已不在;未保存内容已救回到「${rescueName}」`, 'warning', 4500); + } else { + showToast(`文件已不在;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500); + } + }).catch((e) => { + console.error('[file-ops] delete FILE_NOT_FOUND rescue IPC 异常:', e); + showToast('文件已不在;未保存内容丢失', 'error', 4500); + }); + } else { + showToast('文件已不存在', 'warning'); + } + } else { + showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error'); + return; + } + } + showToast(`已移至回收站:${file.name}`, 'success', 1500); + if (isCurrent) { + // 走集中清理:状态归零 + 清空 viewer/editor + 解除文件列表高亮。 + // 注意不再单独写 syncSaveButton / updateWindowTitle / updateStatusStats, + // clearCurrentFile 内部已统一处理(并把 lastSavedMtimeMs 重置为 null, + // 而不是这里的 0 —— 与文件被删、目录切换等其它路径行为一致)。 + clearCurrentFile(); + } + } finally { + // 不论成败都清守卫 —— 否则下次 fs-watcher 事件还会被误判为「正在删除」 + state.deletingPath = null; + } + } + + /** + * 重命名文件:弹 prompt 让用户输入新文件名。 + * 若是当前打开的文件,同步 state.currentFile 的 path/name 以让 UI 反映。 + * + * 用户反馈:「重命名不要自动补后缀」—— 这里显示完整文件名(含 .md)让用户 + * 自由编辑:可以改成 bar.md / bar.txt / 干脆去掉扩展名。新建文件场景也 + * 走同一思路(createNewFile 注释):默认值直接带 .md,主进程 + * resolveFileName / resolveRenameName 都「用户输入什么就用什么」。 + */ + async function renameCurrentOrFile(file) { + if (!file || !file.path) return; + // 审计修复 (Round 11 deep-fix P1-1):sync editor.isDirty() → state.isDirty。 + // rename 的 dirty 检查在 FILE_NOT_FOUND 路径下出现,IME 期间跳过会丢 IME buffer。 + syncDirtyFromEditor(state, editor); + const raw = await showPromptDialog({ + title: '重命名', + message: '重命名为', + defaultValue: file.name, + confirmLabel: '重命名', + validate: validateFileName, + }); + if (raw == null) return; + const newName = raw.trim(); + if (!newName) { + showToast('文件名不能为空', 'warning'); + return; + } + if (newName === file.name) return; + // Phase 2 audit 修复 (P1 BUG #3):设重命名守卫挡住 fs-watcher race 误触发 rescue。 + // 主进程 renameWithRetry 累计 50+100+200=350ms 可能超过 fs-watcher 300ms 防抖 + // 窗口,rename IPC 还没 resolve 时 fs-watcher 先把「A 已不在 + B 已出现」推到 + // renderer,onFilesChanged 的 rescue 分支会把当前 dirty 内容救到 + // `-rescued-.md`(内容 = editor 内容 = 与重命名后的 B 完全相同)→ + // 孤儿文件。守卫:进 renameFile 前设 state.renamingPath = file.path,onFilesChanged + // rescue 分支遇此标记 + state.currentFile.path 一致 → 跳过 rescue。 + // finally 中:先更新 state.currentFile.path,再清守卫(顺序很重要 —— 否则守卫清 + // 在 path 更新之前,fs-watcher 又来一次看到旧 path 仍会被误判为删除)。 + state.renamingPath = file.path; + try { + const result = await api.renameFile(file.path, newName); + if (!result || !result.ok) { + // 与 deleteFile 对齐:原文件已不在(被外部删过 / 同步盘撤回)→ 静默清视图, + // 否则编辑器里仍显示"刚被改名的旧路径文件",但磁盘上已无对应文件。 + // 这里不抛"重命名失败"的红色 toast —— 因为失败的根本原因是文件没了,不是用户 + // 输入的 newName 不合法(那种情况主进程会回 EEXIST / PATH_NOT_ALLOWED)。 + if (result && result.error === 'FILE_NOT_FOUND') { + // Phase 2 audit 修复 (P1 BUG #5):rename 飞行期原文件被外部删除 + // → dirty 内容不能直接 clearCurrentFile 丢。先取 editor.getContent() + // 救到 rescue 文件,再清。复用 onFilesChanged rescue 的 createFile 模式。 + if (state.isDirty && state.currentFile && state.currentFile.path === file.path) { + const originalName = state.currentFile.name || 'untitled.md'; + const base = originalName.replace(/\.md$/i, ''); + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); + const suffix = Math.random().toString(36).slice(2, 6); + const rescueName = `${base}-rescued-${stamp}-${suffix}.md`; + const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : ''; + // audit fix (Round 4 F6):与文件其它 IPC 调用对齐用 ctx.api(createFile / + // renameFile / deleteFile 都从 ctx 解构),不再直连 window.api。若未来 + // IPC 桥被换 mock / 拦截器,rescue 路径与主路径走同一来源,行为一致。 + api.createFile(rescueName, rescueContent).then((r) => { + if (r && r.ok) { + showToast(`原文件已被删除,未保存内容已救回到「${rescueName}」`, 'warning', 4500); + } else { + showToast(`原文件已被删除;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500); + } + }).catch((e) => { + console.error('[file-ops] rename FILE_NOT_FOUND rescue IPC 异常:', e); + showToast('原文件已被删除;未保存内容丢失', 'error', 4500); + }); + } else { + showToast('文件已不存在', 'warning'); + } + if (state.currentFile && state.currentFile.path === file.path) { + clearCurrentFile(); + } + return; + } + showToast(`重命名失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error'); + return; + } + if (state.currentFile && state.currentFile.path === file.path) { + const newMeta = { + path: result.path, + name: result.name, + mtimeMs: result.mtimeMs, + size: result.size, // P2-6:状态栏 size 也需要更新 + }; + // Phase N 修复:扩展名变化(foo.md → foo.txt)必须重算 currentFileIsMarkdown。 + // 旧实现只更新 path/name,state.currentFileIsMarkdown 保留旧值 → viewer 仍 + // 走 loadContent (markdown 渲染)、applyEditorMode 不重跑 → 工具栏 preview/split + // 按钮仍 enabled、用户可能在 .txt 文件里看到假 markdown 渲染。 + const wasMd = state.currentFileIsMarkdown; + const isMd = isMarkdownFile(result.path); + state.currentFile = { ...state.currentFile, ...newMeta }; + state.currentFileIsMarkdown = isMd; + // P2 fix:同步刷新 lastSavedMtimeMs —— fs-watcher 会因 rename 触发 + // files-changed,setFiles 之后 onFilesChanged 会比对 state.files 的 + // mtimeMs 与 lastSavedMtimeMs;如果不写后者,会被误判为「文件被外部修改」弹 rescue 提示。 + state.lastSavedMtimeMs = result.mtimeMs; + // P2 fix:fs-watcher 触发 setFiles 时,新文件 path 还没出现在列表里 + // (旧 path 已不在),file-list 会把 activePath 清空(见 setFiles 内 onFileRemoved 分支)。 + // 主动 setActive 把高亮迁到新 path,避免「编辑器里有内容但侧栏看不到高亮」。 + if (fileList && typeof fileList.setActive === 'function') { + try { fileList.setActive(result.path); } catch { /* 不让 setActive 异常阻断 rename 流程 */ } + } + // P1-5:viewer 也要同步 —— 笔记里的 是相对路径, + // viewer 用 viewer.currentFile.path 当 baseDir,路径不变就 404。 + // setFileMeta 在 path 变化时自动重跑 resolveRelativeImages; + // path 没变则只刷状态栏。 + viewer.setFileMeta(newMeta); + // 扩展名在 md ↔ 非 md 之间切换:viewer 内部 DOM 仍渲染旧内容。 + // setFileMeta 只更新 metadata + 重跑相对图解析,**不**重渲染主体。 + // 需要主动调 loadContent / showUnsupportedFile 切换 viewer 主区域; + // 再调 applyEditorMode 让工具栏按钮可用性 跟着对。 + if (wasMd !== isMd) { + try { + const currentContent = editor && typeof editor.getContent === 'function' + ? editor.getContent() + : ''; + if (isMd) { + viewer.loadContent(state.currentFile, currentContent); + } else { + viewer.showUnsupportedFile(state.currentFile); + } + } catch (e) { + console.warn('[file-ops] rename viewer 切换失败:', e); + } + // applyEditorMode 会按 state.currentFileIsMarkdown 重置预览按钮 disabled、 + // setReadOnly、updateStatusMode/Stats/Scroll。 + if (typeof callbacks.applyEditorMode === 'function') { + callbacks.applyEditorMode(state.editorMode); + } + } + updateWindowTitle(); + // audit fix (Round 9):rename 后通知 AI controller。理由同 discard_local: + // path 变了(content 也可能因扩展名切换而被 viewer 重渲染)—— AI 的 + // pendingDiff 持有的 baseContent / forFilePath 与磁盘现状已经脱节, + // 不通知就会触发「AI 算 diff → 用户点应用 → setExternalContent 覆盖 + // 刚保存的 rename 后内容」这条 stale apply 路径。AI controller 内部 + // 自己负责 cancel + clearPendingDiff + requestGeneration++。 + if (typeof callbacks.onFileChanged === 'function') { + try { callbacks.onFileChanged(state.currentFile); } catch (e) { + console.warn('[file-ops] rename 通知 AI 失败:', e); + } + } + } + showToast(`已重命名为:${result.name}`, 'success', 1500); + } finally { + // 顺序:先在上面把 state.currentFile.path 更新到 result.path,再清守卫。 + // 如果在这里调 state.renamingPath = null 而 state.currentFile.path 还是旧值, + // 此时 fs-watcher 再来一次事件(很常见的二次触发)会看到 state.currentFile.path + // (旧) !== state.renamingPath (旧) → 不命中守卫 → 走 rescue 路径。 + // 注意:本 finally 跑到这里,state.currentFile.path 已经被赋值成 result.path + // (上面 line 197 的 state.currentFile = { ...state.currentFile, ...newMeta }), + // 所以现在清守卫是安全的。 + state.renamingPath = null; + } + } + + /** + * 顶部「+」新建按钮:弹 prompt 取名 → 走 createFile → 自动打开新文件。 + * + * Stage 8:把 state.currentDir 透传给 api.createFile,让新文件落在当前浏览的 + * 子目录里(而不是永远落到数据根)。 + * + * 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动 + * 加上」—— 默认值已带 .md,用户可见 / 可改(直接改 .txt 等)。主进程 + * resolveFileName 也对齐 resolveRenameName:用户输入什么就用什么, + * 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json + * 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。 + */ + async function createNewFile() { + // 审计修复 (Round 11 deep-fix P1-1):entry sync 把 editor.isDirty() 真相同步给 + // state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认, + // 用户的 CJK buffer 静默丢失。 + syncDirtyFromEditor(state, editor); + if (state.isDirty) { + // 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动 + const choice = await showConfirmDialog({ + title: '新建文件', + message: '当前文件有未保存改动,新建文件将丢弃这些改动。继续?', + icon: 'warning', + buttons: [ + { value: 'cancel', label: '取消', variant: 'secondary' }, + { value: 'confirm', label: '继续新建', variant: 'danger' }, + ], + }); + if (choice !== 'confirm') return; + // 用户已经确认丢弃当前改动。openFile 内部还会按 isDirty 再问一次「放弃改动 / 保存并打开」, + // 但用户在第一步已经表态「继续新建(丢弃)」,不应该再问一遍。 + // audit fix:把 clearCurrentFile() 推迟到名字 prompt 成功 + 创建成功之后。 + // 旧顺序:先 clearCurrentFile → 弹 prompt → 用户取消 → 当前文件已经清空了, + // 即便用户只是「改个名字」,也已经把刚才确认要丢弃的改动实际丢了,但还没真新建。 + // 新顺序:保持 isDirty / currentFile 不变直到名字 OK + createFile 成功, + // 然后再清。如果用户在 prompt 阶段取消,编辑器内容原样保留。 + } + const defaultName = `未命名-${formatStampForName(new Date())}.md`; + // Stage 8:在子目录里新建时,prompt 文案补一段「当前文件夹:xxx」让用户清楚新文件落哪。 + // 根目录时维持原样。 + // 用户反馈(2026-08-28):默认名直接带 .md 后缀(与重命名一致),用户想换成 + // .txt / .json / 其他扩展名可直接编辑;不再「自动补」。 + const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0) + ? state.breadcrumb.map((s) => s.name).join('/') + : ''; + const message = breadcrumbTail + ? `新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)\n当前文件夹:${breadcrumbTail}` + : '新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)'; + const raw = await showPromptDialog({ + title: '新建笔记', + message, + defaultValue: defaultName, + confirmLabel: '创建', + validate: validateFileName, + }); + if (raw == null) return; + const name = raw.trim(); + if (!name) { + showToast('文件名不能为空', 'warning'); + return; + } + // Stage 8:把当前目录绝对路径传给主进程;主进程负责 auto-mkdir(用户主动在该目录下新建)。 + const result = await api.createFile(name, { dir: state.currentDir || '' }); + if (!result || !result.ok) { + showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error'); + return; + } + // 名字 prompt + IPC 都成功了才清空当前文件(覆盖上面推迟的 clearCurrentFile) + if (state.isDirty) clearCurrentFile(); + showToast(`已创建:${result.name}`, 'success', 1500); + // 主动打开新文件(不等 files:changed) + await openFile({ + path: result.path, + name: result.name, + size: result.size, + mtimeMs: result.mtimeMs, + }); + } + + /** + * 文件列表右键菜单:重命名 / 在文件夹中显示 / 删除 + * + * Stage 8:folder 条目上隐藏「重命名」—— file:rename 主进程逻辑强制 .md 后缀, + * 对目录并不合适;文件夹的批量整理通过 shell 文件管理器(系统级)操作更直接, + * 这里至少保留删除 + 在文件夹中显示。 + */ + function showFileListContextMenu(entry, x, y) { + const isFolder = entry && entry.entryType === 'folder'; + const items = []; + if (!isFolder) { + items.push({ label: '重命名', value: 'rename' }); + } + items.push({ label: '在文件夹中显示', value: 'reveal' }); + items.push({ separator: true, value: '' }); + items.push({ label: '删除', value: 'delete' }); + contextMenu.show({ + x, y, items, + onSelect: (value) => { + if (value === 'rename') renameCurrentOrFile(entry); + else if (value === 'reveal') revealFile(entry); + else if (value === 'delete') deleteFile(entry); + }, + }); + } + + return { + showFileListContextMenu, + createNewFile, + // 暴露给测试 / 调试用 + renameCurrentOrFile, + deleteFile, + revealFile, + formatStampForName, + }; +} \ No newline at end of file diff --git a/src/focus-trap.js b/src/focus-trap.js new file mode 100644 index 0000000..ac7d085 --- /dev/null +++ b/src/focus-trap.js @@ -0,0 +1,173 @@ +// 焦点陷阱 —— 通用最小可工作版 +// +// 解决两件事: +// 1. Tab / Shift+Tab 在容器内循环,不外溢到背景 DOM +// 2. 关闭时把焦点还给打开前的元素 +// +// 不做(避免过度工程): +// - 自动找"第一个 focusable"作为初始焦点 —— 由调用方传入 initialFocus 更明确 +// - 屏幕阅读器特殊处理 —— aria-modal + aria-hidden 已交给调用方 +// - 焦点恢复时滚屏校正 —— focus({ preventScroll: true }) 即可 +// +// 调用约定: +// const trap = createFocusTrap(overlay, { +// initialFocus: overlay.querySelector('input, button, [tabindex]:not([tabindex="-1"])'), +// signal: abortController.signal, // 可选:signal abort 时自动 dispose +// }); +// ... +// trap.dispose(); // 把焦点还给 prevFocus + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export function createFocusTrap(container, { initialFocus = null, signal = null, fallback = null } = {}) { + if (!container) throw new Error('createFocusTrap: container 必填'); + + // audit fix:原条件只排除 body,但 (documentElement)也是 + // document.activeElement 的常见值(焦点漂移到根节点 —— 例如布局 reflow + // 期间、点击非可聚焦区域后)。.focus() 是合法但无视觉焦点的 + // no-op,导致 dispose 后屏幕阅读器 / 键盘用户的虚拟焦点被困在已关闭 + // 的模态内。这里把 documentElement / body 都视为「无效 prevFocus」, + // 返回 null;dispose 会直接跳过 focus 调用,焦点自然落到 body 上。 + const ae = document.activeElement; + const prevFocus = (ae && ae !== document.body && ae !== document.documentElement + && typeof ae.focus === 'function') ? ae : null; + + // 容器自身需要能接收焦点,否则 Tab 从末尾跳到第一个时容器不会被激活 + const hadTabindex = container.hasAttribute('tabindex'); + if (!hadTabindex) { + container.setAttribute('tabindex', '-1'); + } + + /** @returns {HTMLElement[]} */ + function getFocusable() { + // H1 fix (audit):原 filter 用 `el.offsetParent !== null` 判断可见性, + // 但 `position: fixed` 元素的 offsetParent === null,会被误判成「不可见」 + // 从 Tab 序列里剔除。模态里有 sticky 按钮 / 浮动操作按钮(如未来加的 + // 「在此打开文件夹」),Tab 会跳过它们到末尾后跳回首项,无法到达。 + // 用 `getClientRects().length > 0` 兜住 fixed / sticky,且对 display:none + // 仍然返回空(hidden 元素没有 layout box)。已聚焦的 fixed 元素保留。 + // + // 审计修复 (Round 11):还要排除以下三类元素: + // - visibility: hidden —— 仍有 client rects(filter 里已放过),但 .focus() + // 是 silent no-op;Tab 序列走到这里 → first.focus() 落在 no-op 元素上 → + // 「Tab 死掉」的假象 + // - inert 子树 —— W3C 新标准,把子树标记为不可交互;closest('[inert]') + // 命中即剔除 + // - aria-hidden=true 子树 —— 屏幕阅读器跳过;但键盘 Tab 仍可能命中, + // 排除防止 Tab 看着「消失」 + // contenteditable 显式加入选择器,CM 编辑器嵌入 modal 时能正常 Tab 进。 + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR + ',[contenteditable]:not([contenteditable="false"])')) + .filter((el) => { + if (el.getClientRects().length === 0 && el !== document.activeElement) return false; + if (el.closest && el.closest('[inert],[aria-hidden="true"]')) return false; + // visibility:hidden 元素的 clientWidth/Height 仍可能 > 0,但 getComputedStyle + // 拿到 visibility === 'hidden'。已经聚焦的元素(activeElement)保留——可能 + // 是「用户主动聚焦后外部样式改了 visibility」的边角,dispose 时回退路径不应 + // 把已聚焦元素从序列里踢出。 + if (el !== document.activeElement) { + const cs = (typeof window !== 'undefined' && window.getComputedStyle) ? window.getComputedStyle(el) : null; + if (cs && cs.visibility === 'hidden') return false; + } + return true; + }); + } + + function onKeydown(e) { + // audit fix (Phase O-L14):Tab 也守 IME 合成。 + // 与 shortcuts.js:84 / markdown-editor.js 风格一致:CJK 拼音输入中途按 Tab + // 选候选词(Windows IME 习惯),focus-trap 不能把焦点甩到下一个按钮。 + // 当前 modal 没有 IME 重输入控件(settings / confirm / prompt 都是普通 + // input),潜在风险。但保持一致性:所有 document-level keydown listener + // 都守 isComposing / keyCode=229。 + if (e.isComposing || e.keyCode === 229) return; + if (e.key !== 'Tab') return; + const items = getFocusable(); + if (items.length === 0) { + // 没有可聚焦元素:把焦点留在容器上,避免跳出去 + e.preventDefault(); + container.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + if (e.shiftKey) { + if (active === first || !container.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !container.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + } + + container.addEventListener('keydown', onKeydown); + + let disposed = false; + function dispose() { + if (disposed) return; + disposed = true; + container.removeEventListener('keydown', onKeydown); + // audit fix (renderer-M10):只在是我们自己注入的 tabindex 时移除, + // 宿主原有值(如 HMR 复用场景下节点本身带 tabindex)原样保留。 + if (!hadTabindex) { + container.removeAttribute('tabindex'); + } + if (prevFocus && typeof prevFocus.focus === 'function') { + // 审计修复 (Round 11):dispose 时重验 isConnected。 + // prevFocus 在 capture 阶段(第 38-40 行)只检查 !== body/rootElement, + // 但 modal 期间 prevFocus 可能被外部重渲染 / 撕下 DOM —— 例如: + // - file-list 右键菜单 → 「重命名」 → 弹 prompt-dialog; + // 菜单 close 时整块被 unmount,prevFocus 已 detached, + // .focus() 是 silent no-op,焦点落到 ,键盘用户失位。 + // - 文件列表 items 在 files:changed 事件里重建,prevFocus 同样 detached。 + // 重新检查 isConnected;连接不上时回退到稳定锚点(caller 提供 + // 的 fallback)—— 而不是让焦点散落到 。 + // + // audit fix (Settings P3 / focus-trap fallback lift):原来这里 + // 硬编码 `document.getElementById('file-list')` 作为兜底 —— 把 + // 「renderer 侧具体 DOM 节点」写进通用 focus-trap,跨上下文错位: + // - settings-dialog 关掉 → 焦点跳到 #file-list,语义错位 + // (用户在设置里改东西,期望焦点回设置前的位置) + // - confirm / prompt / 未来 AI 弹窗同样问题 + // - 通用包里写死具体 id,一旦 #file-list 改名/删名静默退化到 + // 现在让 caller 通过 fallback 选项传一个语义匹配的节点;未传则按 + // fallback 节点 → document.body 的顺序找第一个仍 connected 的。 + // 之前 modal.js 调用点没传 fallback,下面 mountModal 那侧会传入 + // 一个 caller 级别的稳定锚点(modal.options.fallbackFocus)。 + let restoreTarget = prevFocus; + if (!prevFocus.isConnected) { + const candidates = [fallback, document.body].filter(Boolean); + restoreTarget = candidates.find((el) => { + try { return el && typeof el.isConnected === 'boolean' && el.isConnected; } + catch { return false; } + }) || document.body; + } + try { restoreTarget.focus({ preventScroll: true }); } catch { /* ignore */ } + } + } + + if (signal) { + if (signal.aborted) dispose(); + else signal.addEventListener('abort', dispose, { once: true }); + } + + // 初始焦点:异步让出渲染时间,避免被外层 setTimeout(0) 抢走 + queueMicrotask(() => { + if (disposed) return; + const target = initialFocus || getFocusable()[0] || container; + try { target.focus({ preventScroll: true }); } catch { /* ignore */ } + }); + + return { dispose }; +} \ No newline at end of file diff --git a/src/markdown-editor.js b/src/markdown-editor.js new file mode 100644 index 0000000..fd520b5 --- /dev/null +++ b/src/markdown-editor.js @@ -0,0 +1,445 @@ +// Markdown 编辑器(CodeMirror 6 包装) +// 职责:装载文件、跟踪脏状态、暴露 save 回调。 +// +// 不负责:文件 IO(走 app.js)、确认对话框(走 confirm-dialog.js)。 +// +// 与 markdown-viewer.js 的关系:viewer 只关心「渲染这一段字符串」, +// editor 只关心「让用户编辑这一段字符串」;二者由 app.js 协调。 + +import { EditorState, Prec, Compartment } from '@codemirror/state'; +import { EditorView, keymap } from '@codemirror/view'; +import { basicSetup } from 'codemirror'; +import { markdown } from '@codemirror/lang-markdown'; +import { getTextStats } from './stats.js'; +import { pickEditorTheme } from './editor-theme.js'; +import { createDisposable } from './component.js'; + +/** + * Markdown 编辑器(CodeMirror 6 包装) + * 职责:装载文件、跟踪脏状态、暴露 save 回调。 + * + * 不负责:文件 IO(走 app.js)、确认对话框(走 confirm-dialog.js)、 + * 主题定义(走 editor-theme.js)。 + * + * 与 markdown-viewer.js 的关系:viewer 只关心「渲染这一段字符串」, + * editor 只关心「让用户编辑这一段字符串」;二者由 app.js 协调。 + */ +export class MarkdownEditor { + /** + * @param {object} options + * @param {HTMLElement} options.element - 容器 div(需有 height: 100%) + * @param {() => void} options.onChange - 内容变化回调(用于脏状态 UI) + * @param {() => void} options.onSave - Ctrl/Cmd+S 触发(也供 app.js 直接调用) + * @param {(progress:number) => void} [options.onScroll] - 滚动/光标变化回调 (0..1),用于双栏同步滚动 + */ + constructor({ element, onChange, onSave, onScroll }) { + this.element = element; + this.onChangeCallback = onChange; + this.onSaveCallback = onSave; + this.onScrollCallback = onScroll || null; + this.view = null; + this.lastSavedDoc = ''; + this.editableCompartment = new Compartment(); + this.themeCompartment = new Compartment(); + // audit fix (renderer-M4):滚动/光标 geometryChanged 在 split 模式下 + // 会被 app.js 同步调到 viewer.setScrollProgress;getScrollProgress 读 + // scrollHeight/clientHeight 强制 layout flush,setScrollProgress 又写 + // scrollTop 触发再次 layout —— 每个 keystroke + 移动光标都走一遍 N 次 + // layout 计算,长文档能感知到 jank。用 rAF 把同帧内的多次触发合并到 + // 一次,rAF 回调里只做一次 getScrollProgress(reader 与 writer 之间也 + // 是同一帧,避免交错布局)。 + this._scrollFrame = null; + // H1 fix (audit P0):统一 dispose 入口。MarkdownEditor 不直接挂 DOM listener + // (CodeMirror 自己处理),但需要暴露 dispose() 让 app.js 在切数据目录 / 重启 + // 窗口 / HMR 时显式释放。createDisposable 也提供 addAbort 注册 _destroyView + // 之外的清理(如 rAF 取消、Compartment 引用置空)。dispose() 幂等,可重复调用。 + createDisposable(this); + // 兜底清理:即便外部忘记调 dispose,rAF 取消 + view.destroy 仍会执行。 + // 注意:createDisposable 会在实例上挂 this.dispose —— 不能在 class 原型上 + // 另写同名的 dispose() 覆盖它。所有清理逻辑都通过 addAbort 注册,dispose() + // 触发 abort 时统一执行。 + this.addAbort(() => { + this._destroyView(); + this.lastSavedDoc = ''; + // 解除 callback 闭包对 viewer / 业务对象的引用,便于 GC。 + this.onChangeCallback = null; + this.onSaveCallback = null; + this.onScrollCallback = null; + // audit fix (Round 7 LOW):补齐 dispose 引用清理,与 onChangeCallback 等 + // 对称。_composing 标志若残留 true,下次 new MarkdownEditor 拿旧实例时会 + // 误判 IME(虽然 module-scope 的 editor 实例永不释放,但显式置 false + // 让 _destroyView 路径上的状态机更易预测)。compartment 显式置 null 释放 + // 引用。_onComposition* 函数引用故意保留(顶部 view null 守卫兜底)—— + // dispose 后极端时序再 fire 这些 handler 仍然 noop,不会抛 TypeError。 + this._composing = false; + this.editableCompartment = null; + this.themeCompartment = null; + }); + } + + /** + * 当前主题(dark / light)—— 决定是否套用 oneDark + */ + get currentTheme() { + return document.body.dataset.theme === 'light' ? 'light' : 'dark'; + } + + /** + * 是否处于 IME 合成状态。CM6 不暴露 composing API;通过 contentDOM + * compositionstart/end listener 跟踪。app.js 在 IME 期间要跳过: + * - auto-save(save() 写入 IME-buffer 中的未确认字符) + * - AI 应用(applyUserChange 在合成期会破坏 IME buffer) + * 暴露 getter 而不是让外部读 _composing 下划线字段。 + * @returns {boolean} + */ + isComposing() { + return this._composing === true; + } + + /** + * 加载文件内容到编辑器。多次调用会销毁旧 view。 + * @param {{path:string,name:string}} file + * @param {string} content + */ + loadFile(file, content) { + // audit fix (Round 7 MED):element 早失败。CodeMirror 内部 parent.appendChild + // 会因 null/undefined 抛 TypeError,把整个 bootstrap 拖到 unhandled rejection。 + // 显式校验:调用方若传错,loadFile 直接抛可读错误,app.js 顶层 catch 能定位。 + if (!this.element || typeof this.element.appendChild !== 'function') { + throw new Error('MarkdownEditor: element required (must be a DOM node)'); + } + this._destroyView(); + + this.lastSavedDoc = content || ''; + + const state = EditorState.create({ + doc: this.lastSavedDoc, + extensions: [ + EditorView.lineWrapping, + basicSetup, + markdown(), + this.themeCompartment.of(pickEditorTheme(this.currentTheme)), + this.editableCompartment.of(EditorView.editable.of(true)), + Prec.highest(keymap.of([ + { + key: 'Mod-s', + preventDefault: true, + run: () => { + if (this.onSaveCallback) this.onSaveCallback(); + return true; + }, + }, + ])), + EditorView.updateListener.of((update) => { + // audit fix (K2-H2):CJK IME 合成期间,CM6 把每个临时字符 commit 成 + // 独立 transaction(userEvent: 'input.type.compose')—— 一个 20 字的 + // 中文段落会触发 20 次 updateListener,每次 docChanged=true 都跑 + // onChange 全链路:state.isDirty 立即翻 true(用户还没打完就显示 + // 脏态)、save 按钮进 is-dirty 提示态、scheduleAutoSave 反复 clearTimeout、 + // scheduleLivePreview 200ms 后 viewer.loadContentLive 整页 innerHTML + // 重建。合成中的 IME 字符不会真的写盘,对外只 commit 一次(compositionend) + // —— 跳过中间过程,让脏态 / live preview / auto-save 在 compositionend + // 时一次性 flush。 + // scroll(geometryChanged)不依赖 docChanged,IME 期间用户也可能滚动 + // 列表,跟之前一样保留。 + const isComposing = update.transactions.some((t) => t.isUserEvent('input.type.compose')); + if (update.docChanged && !isComposing && this.onChangeCallback) { + this.onChangeCallback(); + } + // P1-10:只把「真实滚动」转发给 onScroll。 + // selectionSet(光标移动)也走到了这里 —— 但 app.js 的 onScroll 只是 + // viewer.setScrollProgress(...) 同步右侧预览位置,光标移动跟预览无关, + // 不应该触发。getScrollProgress 会读 scrollHeight/clientHeight,强制 + // layout flush,10K 行文档下每个 keystroke + 移动光标都做几次 N 次 + // layout 计算,体感明显。 + if (update.geometryChanged && this.onScrollCallback) { + // audit fix (renderer-M4):geometryChanged 在 split 模式下会被高 + // 频触发,rAF 合并到一帧一次。rAF 在浏览器不可见标签页里会自然暂停, + // 也避免背景 tab 里持续 layout flush。 + if (this._scrollFrame !== null) { + cancelAnimationFrame(this._scrollFrame); + } + this._scrollFrame = requestAnimationFrame(() => { + this._scrollFrame = null; + // view 已经在 _destroyView 走完时被置 null,rAF 回调里再读会抛 + // getScrollProgress 已经做 null 检查,但显式守一遍更稳。 + if (!this.view || !this.onScrollCallback) return; + this.onScrollCallback(this.getScrollProgress()); + }); + } + }), + ], + }); + + this.view = new EditorView({ + state, + parent: this.element, + }); + // audit fix (Phase O-M1):跟踪 IME 合成状态。 + // CM6 没有公开 composing API(这是 contenteditable 的概念),最准确的方式 + // 是直接在 contentDOM 上挂 compositionstart/compositionend listener。 + // applyUserChange(AI 应用 diff)合成期间调用会摧毁 IME buffer,让 AI 控 + // 制器在 catch 里给用户「请稍后再试」提示。 + // listener 用 addAbort 注册,dispose 时统一解绑。 + // + // audit fix (IME-dirty-flush):compositionend 后补发 onChange。 + // updateListener 里 `!isComposing` 守卫在 IME 合成期间屏蔽 onChange + // (避免中文每字触发一次 scheduleLivePreview / scheduleAutoSave)。 + // 但 compositionend 后 CM6 不一定再产生新 transaction —— 此时 onChange + // 永远不触发,state.isDirty 保持 false,save() 走 !isDirty 早退, + // 用户看到的中文已上屏但磁盘没写入。修复:compositionend 时主动比较 + // doc 与 lastSavedDoc,不同则补发 onChange 让 app 层正确翻脏态。 + this._composing = false; + // audit fix (Round 7):三个 IME handler 都先守 view 存在,dispose 后 + // _destroyView 把 view 置 null,再 fire 这些 handler(极端时序)不会抛 + // TypeError 而是 noop。原 onChange 路径里也有 `this.view &&` 守卫,这次 + // 提到函数顶部,让整个 dispose-after-fire 路径完全幂等。 + this._onCompositionStart = () => { + if (!this.view) return; + this._composing = true; + }; + this._onCompositionEnd = () => { + if (!this.view) return; + this._composing = false; + // IME 提交后补发 onChange:doc 已变但 updateListener 在合成期没调过 + // onChange → state.isDirty 还是 false。这里主动调一次让 app 翻脏。 + // 防御性校验:view 可能已被 dispose、callback 可能已解绑。 + if (this.onChangeCallback) { + const currentDoc = this.view.state.doc.toString(); + if (currentDoc !== this.lastSavedDoc) { + this.onChangeCallback(); + } + } + }; + // audit fix (Round 7 HIGH):compositioncancel listener。 + // 部分 OS / IME 实现(如 macOS 日语 IME 切换、Windows 部分第三方输入法) + // 在用户按 Esc 取消 / 外部抢焦点 / IME 引擎切换时只发 compositioncancel + // 不发 compositionend —— _composing 会永远停在 true,所有 dispatch + // (applyUserChange / setExternalContent)抛 EDITOR_COMPOSING,编辑器 + // 永久锁死直到用户 reload view。处理同 compositionend:清标记 + 补 onChange。 + this._onCompositionCancel = () => { + if (!this.view) return; + this._composing = false; + if (this.onChangeCallback) { + const currentDoc = this.view.state.doc.toString(); + if (currentDoc !== this.lastSavedDoc) { + this.onChangeCallback(); + } + } + }; + this.view.contentDOM.addEventListener('compositionstart', this._onCompositionStart); + this.view.contentDOM.addEventListener('compositionend', this._onCompositionEnd); + this.view.contentDOM.addEventListener('compositioncancel', this._onCompositionCancel); + this.addAbort(() => { + if (!this.view || !this.view.contentDOM) return; + this.view.contentDOM.removeEventListener('compositionstart', this._onCompositionStart); + this.view.contentDOM.removeEventListener('compositionend', this._onCompositionEnd); + this.view.contentDOM.removeEventListener('compositioncancel', this._onCompositionCancel); + }); + } + + /** + * 切换编辑器主题(不重建 view) + * @param {'dark'|'light'} theme + */ + setTheme(theme) { + if (!this.view) return; + const ext = pickEditorTheme(theme); + this.view.dispatch({ + effects: this.themeCompartment.reconfigure(ext), + }); + } + + /** + * 卸载当前编辑器(不写盘) + */ + unload() { + this._destroyView(); + this.lastSavedDoc = ''; + // Phase N-2 修复(关键 bug · 保存按钮失灵): + // K2-L10 的旧实现会在这里清掉 onChange/onSave/onScroll 三个 callback。 + // 后果:clearCurrentFile() → unload() 把 callback 置空 → 下次 openFile + // 走 editor.loadFile() 时只重建 view,**不重建 callback** → 用户的 + // 编辑不再触发 state.isDirty 翻转 → 保存按钮永远不亮 → 用户以为保存 + // 按钮坏了。 + // + // 触发场景:当前打开的文件被外部删除 → applyEntries 调 clearCurrentFile + // → 卸载旧 view 并清 callback → 用户再开新文件 → loadFile 只重建 view + // → callback 仍是 null → 编辑器从此对所有用户输入静默忽略。 + // + // K2-L10 的原意(HMR/切数据目录/Ctrl+R 不挂旧闭包):这一层不该由 + // unload() 兜底,应该由 dispose() 兜底(pagehide 清理在 app.js:3497 + // 已正确调 editor.dispose()),dispose() 走 addAbort 注册的清理路径 + // 会把 callback 置 null。unload() 只清 view 状态,不动 callback。 + // + // 关于 callback 闭包反向持有 viewer/state/ai 的"内存延长"顾虑 —— editor + // 实例本身是 module-scope 的(app.js:475 const editor = new MarkdownEditor(...)), + // 永不释放;callback 持有那几个引用等价于 editor 持有那几个引用,不存在 + // "延长 lifecycle"的差异。让 callback 跟 editor 一起活到 dispose()。 + } + + /** + * 取当前编辑器内容(字符串) + */ + getContent() { + if (!this.view) return ''; + return this.view.state.doc.toString(); + } + + /** + * 是否脏(未保存改动) + */ + isDirty() { + if (!this.view) return false; + return this.view.state.doc.toString() !== this.lastSavedDoc; + } + + /** + * 标记当前内容为「已保存」基线。下次 isDirty() 比较的就是这次保存的版本。 + */ + markSaved() { + if (!this.view) return; + this.lastSavedDoc = this.view.state.doc.toString(); + } + + /** + * 用外部内容替换当前编辑器内容(例如外部修改覆盖本地)。 + * 同时更新 lastSavedDoc —— 因为这是从磁盘来的「最新权威」。 + * + * 注意:必须先更新 lastSavedDoc 再 dispatch,否则 updateListener 会同步触发 onChange, + * 此时 lastSavedDoc 仍是旧值,isDirty() 会错误返回 true。 + * + * audit fix (K2-M4):dispatch 前快照当前 selection,dispatch 时回填到 + * clamp 后的位置 —— CM6 默认把光标落到新 doc末尾,若用户正读到第 50 行做 + * diff 标注,外部一次保存就把编辑器滚到底部,等于打断阅读。 + */ + setExternalContent(content) { + if (!this.view) return; + // audit fix (Round 4 F2):IME 合成期拒绝 setExternalContent。 + // fs-watcher 在 _composing=true 期间推「外部修改」→ view.dispatch 全量替换 + // doc 会摧毁 IME 合成 buffer,已输入的拼音字符消失。applyUserChange 已有同样 + // 守卫,setExternalContent 同性质入口行为必须对齐。抛 EDITOR_COMPOSING 让 + // 调用方 catch 后给用户「请稍后再试」提示或推迟到 compositionend 后再 dispatch。 + if (this._composing) { + throw new Error('EDITOR_COMPOSING'); + } + const sel = this.view.state.selection.main; + this.lastSavedDoc = content || ''; + const insert = content || ''; + const newLen = insert.length; + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert }, + selection: { + anchor: Math.min(sel.anchor, newLen), + head: Math.min(sel.head, newLen), + }, + }); + } + + /** + * 用新内容替换当前编辑器内容,并把这次替换标记为"未保存改动"。 + * 与 setExternalContent 的区别:lastSavedDoc 不跟着更新, + * 替换后 isDirty() 仍返回 true(自动保存 / Ctrl+S 能正确触发)。 + * + * 用于 AI 修改应用:用户能 Ctrl+Z 撤销 AI 的改动、自动保存会落盘、 + * 保存按钮进入 is-dirty 提醒态。 + * + * userEvent: 'input.fromAI' 让 CodeMirror 把它当作一次用户输入(影响 undo history)。 + * 单次 dispatch 完整替换(而不是 splice),所以 undo 一步就回到旧内容。 + */ + applyUserChange(content) { + if (!this.view) return; + // audit fix (Phase O-M1):IME 合成期间拒绝 applyUserChange。 + // AI 控制器(chat dock 自动 apply / 用户鼠标误点 chat)调用本方法时, + // 若用户正在 CJK 拼音输入,view.dispatch 全量替换 doc 会摧毁 IME 合成 + // buffer,已输入的拼音字符消失。抛特定 message 让调用方 catch 后给用户 + // 「请稍后再试」提示(ai-controller.js 已 try/catch + 按 message 区分)。 + if (this._composing) { + throw new Error('EDITOR_COMPOSING'); + } + // audit fix (K2-M3):dispatch 前快照 selection.main(AI 应用 region/all + // 整页替换后,CM6 默认把光标落到新 doc 末尾)。clamp 到新 doc 内,至少 + // 保留用户下次编辑起点(接受 AI 改动后想继续写,不至于被甩到文末)。 + const sel = this.view.state.selection.main; + const insert = content || ''; + const newLen = insert.length; + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert }, + userEvent: 'input.fromAI', + selection: { + anchor: Math.min(sel.anchor, newLen), + head: Math.min(sel.head, newLen), + }, + }); + } + + // L11 fix (audit):原参数名 `enabled` 是双重否定陷阱 —— 调用方 + // setReadOnly(true) 实际是「启用只读」,跟「enable writing」直觉反着。 + // 改名 `readOnly` 后 setReadOnly(true) = 只读,setReadOnly(false) = 可写。 + // CM6 内部 EditorView.editable.of(true) 表示「可编辑」,所以仍然要取反。 + setReadOnly(readOnly) { + if (!this.view) return; + this.view.dispatch({ + effects: this.editableCompartment.reconfigure(EditorView.editable.of(!readOnly)), + }); + } + + focus() { + if (this.view) this.view.focus(); + } + + /** + * 编辑器的滚动进度(0..1) + * + * 用滚动视口顶部在文档中的位置(即「已滚过的距离 / 总可滚距离」)作为比例, + * 与 viewer 的 getScrollProgress() 口径一致,双栏同步时直接套用即可。 + * + * 如果文档没溢出(max<=0),返回 0 —— 避免 NaN。 + * @returns {number} + */ + getScrollProgress() { + if (!this.view) return 0; + const scroller = this.view.scrollDOM; + const max = scroller.scrollHeight - scroller.clientHeight; + if (max <= 0) return 0; + return Math.min(1, Math.max(0, scroller.scrollTop / max)); + } + + /** + * 把编辑器滚动到指定比例(0..1)。供双栏反向同步(viewer → editor)。 + * 与 viewer.setScrollProgress() 是一对。 + * @param {number} progress + */ + setScrollProgress(progress) { + if (!this.view) return; + const scroller = this.view.scrollDOM; + const max = scroller.scrollHeight - scroller.clientHeight; + if (max <= 0) return; + const target = Math.min(1, Math.max(0, progress)) * max; + scroller.scrollTop = target; + } + + /** + * 当前编辑器的字数统计(CJK 友好,统一走 stats.js) + * 行数基于源文本非空行数,与 viewer 一致 —— 切换预览/编辑时不会跳变。 + * @returns {{chars:number, words:number, lines:number}} + */ + getStats() { + if (!this.view) return { chars: 0, words: 0, lines: 1 }; + return getTextStats(this.view.state.doc.toString()); + } + + _destroyView() { + // audit fix (renderer-M4):先取消可能挂着的 rAF —— 否则 destroy 后 rAF + // 回调里读 this.view / this.onScrollCallback 还能命中内存中尚未 GC 的 + // 引用,触发到旧组件实例的回调链。 + if (this._scrollFrame !== null) { + cancelAnimationFrame(this._scrollFrame); + this._scrollFrame = null; + } + if (this.view) { + this.view.destroy(); + this.view = null; + } + } +} diff --git a/src/markdown-viewer.js b/src/markdown-viewer.js new file mode 100644 index 0000000..a677389 --- /dev/null +++ b/src/markdown-viewer.js @@ -0,0 +1,736 @@ +// Markdown 阅读视图组件 +// 职责:把 markdown 字符串渲染成 HTML、更新状态栏、拦截链接点击。 +// +// 与 markdown-editor.js 的关系:viewer 只关心「显示」。 +// 加载文件的 dirty 检查、IO 协调全部交给 app.js。 + +import { formatSize, formatDate, dirnameOf, resolvePath, toFileUrl, safeRenderMarkdown, isPathInside } from './utils.js'; +import { getTextStats } from './stats.js'; +import { createDisposable } from './component.js'; + +/** + * 转义要放进 [id="..."] 属性选择器里的值。 + * 中文 / 数字开头的 id 直接拼 '#' 会是非法选择器,属性选择器最省事, + * 只需要处理反斜杠和双引号。 + */ +function cssAttrEscape(value) { + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +/** + * 渲染 markdown 字符串为安全的 HTML + * - 委托给 utils.safeRenderMarkdown:marked + DOMPurify 实际初始化在 preload.js 里完成 + * - 失败 / 不可用时降级为转义后的
    (永远不返回未转义的原文)
    + */
    +function renderMarkdown(markdown) {
    +  if (!markdown || typeof markdown !== 'string') return '';
    +  return safeRenderMarkdown(markdown);
    +}
    +
    +export class MarkdownViewer {
    +  /**
    +   * @param {object} options
    +   * @param {HTMLElement} options.bodyElement - 
    渲染容器 + * @param {HTMLElement} options.emptyElement - 空状态元素 + * @param {HTMLElement} options.statusPath - 状态栏路径 + * @param {HTMLElement} options.statusMeta - 状态栏元信息 + * @param {(url:string) => void|Promise} options.onExternalLink - 外部链接点击回调 + * @param {() => string|Promise} [options.getDataDir] - 返回当前数据目录绝对路径 + * 异步 callback;resolveRelativeImages 用它做安全边界检查(audit C3)。 + * 不传则跳过越界检查,回退到「只挡 ../.. 越界字符串」的原行为。 + * @param {() => string|null} [options.getCurrentDir] - 返回当前浏览目录的绝对路径 + * 没打开文件时状态栏用它替代「—」显示当前文件夹位置(用户从路径就能定位到根/子目录)。 + * app.js 在 scanDir / loadFileList 完成后调 refreshCurrentDir() 让 chip 跟随刷新。 + */ + constructor({ bodyElement, emptyElement, statusPath, statusMeta, onExternalLink, getDataDir, getCurrentDir }) { + this.bodyElement = bodyElement; + this.emptyElement = emptyElement; + this.statusPath = statusPath; + this.statusMeta = statusMeta; + this.onExternalLink = onExternalLink; + // 数据目录提供者 —— 用 callback 而非缓存值,因为 dataDir 会随用户切换设置而变。 + this.getDataDir = typeof getDataDir === 'function' ? getDataDir : null; + // 当前浏览目录提供者 —— 仅用于无文件时状态栏路径展示。 + this.getCurrentDir = typeof getCurrentDir === 'function' ? getCurrentDir : null; + this.currentFile = null; + this.currentContent = ''; + this.currentHeadingId = null; // 当前在视口里最高可见的 heading id + this.onHeadingChangeCallbacks = new Set(); + // 当前文档所有 heading 的快速查表(id → {level, text, id}), + // 由 _rebuildHeadingObserver 同步构建;状态栏 heading chip 用它把 + // currentHeadingId 还原成可读文本。 + this._headingMap = new Map(); + // IntersectionObserver:跟踪"当前在视口顶部的 heading" + this._headingObserver = null; + // 每个打开过的文件保留一份滚动位置。 + // 上限 SCROLL_POSITION_CACHE_LIMIT = 200:超出后按插入顺序淘汰最旧未访问的条目, + // 防止打开几千个文件后 Map 无限增长(每个 entry ≈ 8 字节 key + 8 字节 number)。 + // Map 保留插入顺序;重新访问某文件时先 delete 再 set,等价于移到队尾。 + this.scrollPositions = new Map(); + this.SCROLL_POSITION_CACHE_LIMIT = 200; + + // 跨文件竞态守卫:resolveRelativeImages 是 fire-and-forget 的 async, + // A→B 快速切换文件时,A 的回调可能在 B 已经接管 DOM 后才 resume。 + // 用单调递增的 generation token,每次 resolveRelativeImages 入口自增, + // 关键 await 之后再比一次 —— 不等就 return,避免 A 的解析污染 B 的图片。 + this._resolveGen = 0; + + // 缓存空态元素初始 innerHTML —— showUnsupportedFile 会临时改写它, + // 下一次 loadContent/clear 再还原回 index.html 里的默认文案(避免 + // 「上次打开 .txt 残留的'不支持预览'文字」污染下次空态)。 + this._defaultEmptyHTML = emptyElement ? emptyElement.innerHTML : ''; + + // 统一 lifecycle:dispose() 一次性 unbind 所有 listener。 + createDisposable(this); + + // audit fix (Phase L1-M1):onHeadingChange 回调 Set + // 不在 listener 信号路径上 —— 持有 app.js / state / editor / ai 等大闭包, + // dispose 时不显式 clear 就会一直挂到 pagehide。HMR / 调试路径下 viewer + // 替换后旧回调还能触发,让新组件的 state 被污染。abort 时一次性清空。 + this.addAbort(() => { + this.onHeadingChangeCallbacks.clear(); + // audit fix (Round 12 P3):_headingObserver 是 IntersectionObserver, + // _rebuildHeadingObserver / clear / showUnsupportedFile 三处各自 + // disconnect,但 dispose 路径没有兜底。detach 后的 heading 节点 + // 被 IntersectionObserver 持有 → Detached HTMLHeadingElement 不被 GC + // 直到 observer disconnect。 + if (this._headingObserver) { + try { this._headingObserver.disconnect(); } catch { /* ignore */ } + this._headingObserver = null; + } + }); + + // 拦截链接点击:所有 .markdown-body 内的 都通过 onExternalLink 走系统默认浏览器 + if (this.bodyElement) { + this.bodyElement.addEventListener('click', (e) => { + const anchor = e.target.closest('a[href]'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + // 统一先 preventDefault:空 href ('') / 锚点 / 任意相对路径, + // 都不让浏览器按默认行为跑(空 href 不阻止会让 Electron 重载页面)。 + e.preventDefault(); + if (!href) return; // 空 href:什么都不做(合法占位,不触发系统行为) + // 文内锚点(#xxx):自己滚动,不改地址栏 hash。 + // marked 会把 href 里的中文百分号编码,而 id 是原文,必须先解码再匹配。 + if (href.startsWith('#')) { + this.scrollToAnchor(href.slice(1)); + return; + } + if (this.onExternalLink) { + // audit fix (Round 12 P2):onExternalLink 在调用方是 async 函数 + // (src/app.js:419 走 openFile + openExternal IPC),同步调不会等 + // promise,IPC 抛 throw(非 ok=false)会变成 unhandledRejection。 + // 用户点击外链没反应、只能去主进程 console 翻日志。在 viewer + // 这一层 catch 住所有异常 + 打 console + 不影响 click 后续。 + // 调用方已用 {ok,message} envelope 走友好提示,这里只兜 + // 「真 throw」这一条路径。 + try { + const ret = this.onExternalLink(href); + if (ret && typeof ret.catch === 'function') { + ret.catch((err) => { + console.error('[markdown-viewer] onExternalLink promise rejected:', err); + }); + } + } catch (err) { + console.error('[markdown-viewer] onExternalLink threw synchronously:', err); + } + } + }, { signal: this.signal }); + } + } + + /** + * 滚动到文内锚点。 + * @param {string} rawHash - 不含 '#' 的片段,可能是百分号编码的 + * @returns {boolean} 是否找到目标 + */ + scrollToAnchor(rawHash) { + if (!rawHash || !this.bodyElement) return false; + let id = rawHash; + try { + id = decodeURIComponent(rawHash); + } catch { + // 非法编码就按原样匹配 + } + // 用属性选择器而不是 '#'+id:中文/数字开头的 id 在 CSS 选择器里需要转义 + const target = this.bodyElement.querySelector(`[id="${cssAttrEscape(id)}"]`) + || this.bodyElement.querySelector(`[id="${cssAttrEscape(rawHash)}"]`); + if (!target) return false; + target.scrollIntoView({ block: 'start' }); + return true; + } + + /** + * 渲染 markdown 字符串到视图。 + * @param {{path:string,name:string,size?:number,mtimeMs?:number}} file + * @param {string} content - markdown 内容 + */ + async loadContent(file, content) { + if (!file || !this.bodyElement) return; + + // 恢复空态为 index.html 里的默认文案(showUnsupportedFile 可能改写过了) + this._resetEmptyState(); + + // 保存当前文件的滚动位置(LRU:删除再 set 移到末尾) + if (this.currentFile && this.bodyElement.parentElement) { + const path = this.currentFile.path; + const top = this.bodyElement.parentElement.scrollTop; + if (this.scrollPositions.has(path)) this.scrollPositions.delete(path); + this.scrollPositions.set(path, top); + // 超出上限:删最早插入的(Map 头部) + while (this.scrollPositions.size > this.SCROLL_POSITION_CACHE_LIMIT) { + const oldest = this.scrollPositions.keys().next().value; + this.scrollPositions.delete(oldest); + } + } + + this.currentFile = file; + this.currentContent = typeof content === 'string' ? content : ''; + // Phase N M-1 fix:空文件走「(空文件)」占位文案,而不是渲染空 body。 + // 旧实现 renderMarkdown('') 返回 '' → bodyElement.innerHTML='' → 用户看到 + // 一片空白面板,没有任何提示文件是「真的空」还是「渲染挂了」。 + // 与「未打开文件」空态(emptyElement 显示)区分:当前打开了文件,emptyElement 必须 + // 继续隐藏,否则用户以为文件没打开。 + if (!this.currentContent || this.currentContent.trim() === '') { + this.bodyElement.innerHTML = '
    (空文件)
    '; + this.bodyElement.hidden = false; + if (this.emptyElement) this.emptyElement.hidden = true; + this._rebuildHeadingObserver(); + this.updateStatusBar(file); + return; + } + const html = renderMarkdown(content); + // 自增 generation token,让旧文件的 in-flight resolveRelativeImages 在 + // await getDataDir 之后读 _resolveGen 时失配,自行放弃(A→B 切文件场景)。 + this._resolveGen += 1; + this.bodyElement.innerHTML = html; + // resolveRelativeImages 内部 await getDataDir() —— loadContent 是 async 不阻塞调用方 + // (viewer 不暴露 promise;调用方 fire-and-forget 即可,与原同步行为对齐) + this.resolveRelativeImages(file.path).catch((e) => { + // 数据目录暂时取不到时(preload 还未注入等极端情况)→ 跳过图片解析, + // 但不阻断主渲染流程。日志走 console.warn 让用户能看到。 + console.warn('[viewer] resolveRelativeImages 失败:', e && e.message); + }); + this.bodyElement.hidden = false; + if (this.emptyElement) this.emptyElement.hidden = true; + + // 重绑 heading observer(_rebuildHeadingObserver 内部重建 _headingMap) + this._rebuildHeadingObserver(); + + // 更新状态栏 + this.updateStatusBar(file); + + // 恢复滚动位置 + // audit fix (L36):捕获 file.path 用于 rAF 内部的「当前文件没变」校验。 + // 之前直接用 this.currentFile.path,rAF 回调里 this 已经是新文件(A→B 切得很快 + // 时 this.currentFile 已被 B 替换),但 callback 里读 this.scrollPositions.get('B') + // 等于「给 B 应用 A 的滚动位置」。A 的 scrollTop 被顺手存进了 scrollPositions.set('B', A的top) + // —— 第二次开 B 会继承错误的滚动。 + // 现在 rAF 内先比对 capturedPath === this.currentFile.path,不一致就放弃 restore。 + const capturedPath = file.path; + requestAnimationFrame(() => { + if (!this.currentFile || this.currentFile.path !== capturedPath) return; + // 命中:删除再 set,等价于把这条移到 Map 末尾(最近访问),LRU 语义 + if (this.scrollPositions.has(capturedPath)) { + const v = this.scrollPositions.get(capturedPath); + this.scrollPositions.delete(capturedPath); + this.scrollPositions.set(capturedPath, v); + } + const restore = this.scrollPositions.get(capturedPath) || 0; + if (this.bodyElement.parentElement) { + this.bodyElement.parentElement.scrollTop = restore; + } + }); + } + + /** + * 显示「该文件不支持预览」的空态。 + * + * Stage 8 扩展:可编辑文件不一定是 Markdown(txt/py/json/...)—— 这些文件 + * 仍然能加载到编辑器,但 viewer 不渲染。空态改成「仅编辑模式」提示, + * 状态栏仍展示文件名 / 大小 / mtime(与 loadContent 行为一致)。 + * + * 不修改持久化的 state.editorMode —— 调用方负责把编辑器切到 edit 模式。 + * 后续 loadContent / clear 会自动还原空态默认文案。 + * + * @param {{path:string,name:string,size?:number,mtimeMs?:number}} file + */ + showUnsupportedFile(file) { + if (!file) return; + this.currentFile = file; + this.currentContent = ''; + this.currentHeadingId = null; + this._headingMap = new Map(); + if (this._headingObserver) { + try { this._headingObserver.disconnect(); } catch { /* ignore */ } + this._headingObserver = null; + } + + // 隐藏正文,显示空态并改写文案 + if (this.bodyElement) { + this.bodyElement.innerHTML = ''; + this.bodyElement.hidden = true; + } + if (this.emptyElement) { + // 直接 innerHTML 改写,保留外层 .empty-state 的布局/动画样式 + this.emptyElement.innerHTML = ` +
    + +
    +

    该文件不支持预览

    +

    .md.markdown 文件支持 Markdown 预览。
    其他文本格式(.txt / .py / .json 等)仅支持编辑。

    + `; + this.emptyElement.hidden = false; + } + + // 状态栏仍展示文件名 / 大小 / mtime(与 markdown 文件一致) + this.updateStatusBar(file); + + // heading 清空:通知订阅者 + this._emitHeadingChange(); + } + + /** + * 恢复空态元素到 index.html 里的默认文案。 + * 由 loadContent / clear 在替换显示内容前调用,避免上次 showUnsupportedFile + * 改写过的「不支持预览」文字残留。 + */ + _resetEmptyState() { + if (this.emptyElement && this._defaultEmptyHTML !== undefined) { + this.emptyElement.innerHTML = this._defaultEmptyHTML; + } + } + + /** + * 把 的相对 src 改写成 file:// 绝对地址。 + * + * 不改写的话相对路径会相对 index.html(应用安装目录)解析, + * 笔记旁边的图片永远加载不出来。 + * + * 只处理相对路径:绝对路径、data:、http(s): 等一律不动。 + * + * audit C3 安全修复:解析后的绝对路径必须仍在数据目录内 —— 否则恶意 markdown + * 可以写 `` 直接通过 file:// 读到 + * 用户机器上的任意可读文件。getDataDir 回调由构造时注入(app.js 传 + * `() => window.api.getDataDir()`),取不到时退化为「只挡 ../.. 越界字符串」的旧行为。 + * + * @param {string} filePath - 当前 markdown 文件的绝对路径 + */ + async resolveRelativeImages(filePath) { + if (!this.bodyElement || !filePath) return; + // audit fix (perf-MEDIUM):A→B 快速切文件时,旧 loadContent 启动的 + // resolveRelativeImages(A) 是 fire-and-forget;await getDataDir 期间 + // currentFile 已被新 loadContent 替换为本文件 B,但 this.bodyElement + // 也指向已被替换的 DOM(B 的内容)。若不挡,A 的解析结果会污染 B 的图片。 + // + // audit fix (renderer-M3):bodyElement 身份永远不会变(同一个 DOM 节点反复 + // innerHTML),用 generation token 保证:每次调用入口自增,**关键 await 之后** + // 比对 —— 不一致就 return。token 由 loadContent / loadContentLive 在替换 + // innerHTML 之前自增,迫使所有早于本次的内层 await 回调放弃。 + // + // B-1 fix (audit):之前在 ++myGen 之后立刻 `if (myGen !== this._resolveGen) return;` + // —— 这是 no-op:自增后立即比对,token 一定相等,要等到 await 期间才可能变化。 + // 现在只保留 await 后的真正检查(line ~301)。 + const myGen = ++this._resolveGen; + const baseDir = dirnameOf(filePath); + if (!baseDir) return; + + // 异步取当前数据目录 —— 失败/缺失时 dataDir 为 null,跳过边界检查(旧行为)。 + let dataDir = null; + if (this.getDataDir) { + try { + const got = this.getDataDir(); + dataDir = (got && typeof got.then === 'function') ? await got : got; + if (typeof dataDir !== 'string' || !dataDir) dataDir = null; + } catch { + dataDir = null; + } + } + // await 之后再判一次 —— 期间可能被新 loadContent / loadContentLive 接管 + if (myGen !== this._resolveGen) return; + + for (const img of this.bodyElement.querySelectorAll('img[src]')) { + const src = img.getAttribute('src'); + if (!src) continue; + // 带协议(含 data:/file:/http:)或以 / 开头的绝对路径 → 原样保留 + if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('//')) continue; + + let decoded = src; + try { + decoded = decodeURIComponent(src); + } catch { + // 非法编码就按原文当路径 + } + const abs = resolvePath(baseDir, decoded); + if (!abs) continue; // '../..' 越界 + // 安全边界:解析后的路径必须在数据目录内。越界直接剥 src,不暴露外部文件路径。 + if (dataDir && !isPathInside(abs, dataDir)) { + img.removeAttribute('src'); + // 保留视觉占位:把 alt 提到 title,并加一个占位属性让 CSS 可选择 + img.setAttribute('data-blocked', 'external'); + const alt = img.getAttribute('alt'); + if (alt) img.setAttribute('title', `外部图片已拦截:${alt}`); + continue; + } + img.setAttribute('src', toFileUrl(abs)); + // audit fix (P3):图片懒加载 + 异步解码 —— 一篇含 20 张图的笔记, + // 之前 marked 直接输出 ,20 个图同时启动下载 + 都参与初始布局, + // 长文档里看不见的图也抢带宽;CLS 也因为没有 width/height 一图一抖。 + // loading="lazy" 让浏览器在图进入视口前不下载,decoding="async" 让解码 + // 不阻塞主线程(避免大图 decode 卡顿)。这两个属性 setAttribute 已设就 + // 不会重复设(getAttribute 检查避免每次重渲都重写)。 + if (!img.hasAttribute('loading')) img.setAttribute('loading', 'lazy'); + if (!img.hasAttribute('decoding')) img.setAttribute('decoding', 'async'); + } + } + + /** + * 同步文件元数据(路径 / 名称 / mtime / size),不重渲染。 + * + * P1-5:文件被重命名或重载后,state.currentFile.path/name/mtimeMs/size 都更新了, + * 但 viewer.currentFile 还是旧值 —— viewer 的 resolveRelativeImages 走旧 baseDir, + * 笔记里 `` 全部失效。 + * + * 不全量 reloadContent 是因为:内容没变,只是元数据变了,重渲染会丢滚动位置、 + * IntersectionObserver 全量重建 —— 完全不必要。 + * + * @param {{path?:string,name?:string,size?:number,mtimeMs?:number}} meta + */ + setFileMeta(meta) { + if (!meta || !this.currentFile) return; + if (meta.path && meta.path !== this.currentFile.path) { + // 路径变了(重命名 / dataDir 切换)→ 必须把 重新解析到新 baseDir + this.currentFile = { ...this.currentFile, ...meta }; + // audit fix (Round 4 F8):resolveRelativeImages 抛错兜底。setFileMeta 在 + // 重命名 / 切目录高频路径上被调用,里面 for 循环可能因 getDataDir 异常或 + // setAttribute 触发 CSP 拦截抛错 —— 之前无 .catch,未捕获的 Promise rejection + // 在 Electron 主进程 default handler 那里打 log。与 loadContent line 186-190 对齐。 + Promise.resolve(this.resolveRelativeImages(this.currentFile.path)) + .catch((e) => console.warn('[viewer] setFileMeta resolveRelativeImages:', e)); + } else { + // 路径没变(只是 mtime / size 变化)→ 仅更新元数据即可 + this.currentFile = { ...this.currentFile, ...meta }; + } + // 状态栏文件名 / mtime 也要刷新 + this.updateStatusBar(this.currentFile); + } + + /** + * 清空视图,回到空状态。 + * + * audit C2 修复:保留 scrollPositions —— 之前 clear() 一刀切全清, + * 导致「切换文件 → clear → 切回原文件」丢失滚动位置,用户体验差。 + * 现在只清当前文件的 entry(其它文件保留),等价于「清空当前视图但 LRU 缓存仍在」。 + */ + clear() { + // 恢复空态默认文案(showUnsupportedFile 可能改写过了) + this._resetEmptyState(); + + if (this.bodyElement) { + this.bodyElement.innerHTML = ''; + this.bodyElement.hidden = true; + } + if (this.emptyElement) this.emptyElement.hidden = false; + this.currentFile = null; + this.currentContent = ''; + this.currentHeadingId = null; + this._headingMap = new Map(); + if (this._headingObserver) { + try { this._headingObserver.disconnect(); } catch { /* ignore */ } + this._headingObserver = null; + } + // 只删当前文件的滚动位置 —— loadContent 已经在切换前把 top 存进 scrollPositions, + // 再清一次反而覆盖。clear 的语义就是「不再有 currentFile」,所以 entry 留着无害。 + if (this.statusPath) this.statusPath.textContent = this._folderLabel(); + if (this.statusMeta) this.statusMeta.textContent = ''; + this._emitHeadingChange(); + } + + /** + * 当前浏览目录刷新 —— 没有打开文件时把状态栏路径切到当前文件夹。 + * 由 app.js 在 scanDir / loadFileList 成功后调用,路径信息同步到 chip。 + * 有文件打开时不影响(文件路径优先级更高,由 updateStatusBar 控制)。 + */ + refreshCurrentDir() { + if (!this.statusPath) return; + if (this.currentFile) return; // 文件路径优先,不覆盖 + this.statusPath.textContent = this._folderLabel(); + } + + /** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到 '—'。 */ + _folderLabel() { + const dir = this.getCurrentDir ? this.getCurrentDir() : ''; + return dir || '—'; + } + + /** + * 更新状态栏 + */ + updateStatusBar(file) { + if (this.statusPath) { + this.statusPath.textContent = file.path || file.name; + } + if (this.statusMeta) { + const parts = []; + if (typeof file.size === 'number') parts.push(formatSize(file.size)); + if (typeof file.mtimeMs === 'number') parts.push(formatDate(file.mtimeMs)); + this.statusMeta.textContent = parts.join(' · '); + } + } + + /** + * 当前阅读视图的字数统计(CJK 友好) + * + * 词数基于渲染后的纯文本(读者看到的文字,不含 markdown 标记), + * 行数基于源文档 —— 这样切换预览/编辑模式时状态栏的「N 行」不会跳变。 + * 之前这里数的是渲染后的块级元素个数,同一个文件在预览下显示 3 行、 + * 切到编辑就变成 6 行。 + * + * @returns {{chars:number, words:number, lines:number}} + */ + getStats() { + // 行数基于源文档(不是渲染后的 textContent),切预览/编辑时不会跳变。 + const source = this.currentContent || ''; + return getTextStats(source); + } + + /** + * 阅读视图的滚动进度(0..1) + * @returns {number} + */ + getScrollProgress() { + if (!this.bodyElement) return 0; + const scroller = this.bodyElement.parentElement; + if (!scroller) return 0; + const max = scroller.scrollHeight - scroller.clientHeight; + if (max <= 0) return 0; + return Math.min(1, Math.max(0, scroller.scrollTop / max)); + } + + /** + * 把阅读视图滚动到指定比例(0..1)。供双栏反向同步(editor → viewer / viewer → editor)。 + * @param {number} progress + */ + setScrollProgress(progress) { + if (!this.bodyElement) return; + const scroller = this.bodyElement.parentElement; + if (!scroller) return; + const max = scroller.scrollHeight - scroller.clientHeight; + if (max <= 0) return; + const target = Math.min(1, Math.max(0, progress)) * max; + scroller.scrollTop = target; + } + + /** + * 监听阅读视图自身的滚动(用于反向同步到编辑器)。 + * + * audit fix (H1-renderer):listener 必须挂在组件自己的 signal 上 —— 之前 + * 没传 signal,dispose() 时 listener 仍存在,闭包持住 viewer + 回调对象, + * 单例 dispose 后再开新组件(旧 cb 在 rAF 里跑 → 抛"viewer disposed" + * 类错误)。现在挂 signal,dispose() 一次性 unbind;同时返回 unsubscribe + * 让调用方可以单独取消(多重订阅场景)。 + * + * @param {(progress:number) => void} cb + * @returns {() => void} unsubscribe + */ + onScroll(cb) { + if (!this.bodyElement || !this.bodyElement.parentElement) return () => {}; + const scroller = this.bodyElement.parentElement; + const handler = () => { + if (typeof cb === 'function') cb(this.getScrollProgress()); + }; + scroller.addEventListener('scroll', handler, { passive: true, signal: this.signal }); + return () => scroller.removeEventListener('scroll', handler); + } + + /** + * 轻量刷新(仅替换 body HTML,不动滚动位置 / 不触发 status bar 更新 / 不存历史)。 + * + * 用于「编辑实时预览」:用户敲键盘时右侧实时跟手,又不希望每次都重置滚动。 + * 与 loadContent 的关键差异: + * - 不保存当前滚动位置(用户自己滚动到哪里就是哪里) + * - requestAnimationFrame 后不恢复滚动(保持原状) + * - 不更新状态栏 + * + * @param {string} content - 最新的 markdown 字符串 + * @returns {{changed:boolean}} changed=true 表示内容确实变了 + */ + loadContentLive(content) { + if (!this.bodyElement) return { changed: false }; + const next = typeof content === 'string' ? content : ''; + // M-series fix (audit Phase M-H3):即便内容没变(早返回),也要 bump + // generation token,让上一轮 in-flight 的 resolveRelativeImages 在 + // await getDataDir 之后读到旧 myGen → 自行放弃。否则 A→B→A 序列里 + // 中间那次 loadContentLive 早返回,pending 的 resolveRelativeImages + // 仍会写入 body,覆盖新内容里的图片路径(用 baseDir 复用旧文件)。 + // loadContentLive 的 _resolveGen 跳号不致命:后续 loadContent / 下一次 + // loadContentLive 仍会 bump 到新值,最终一致性靠单调递增保证。 + if (next === this.currentContent) { + this._resolveGen += 1; + return { changed: false }; + } + this.currentContent = next; + const html = renderMarkdown(next); + // 自增 generation token(与 loadContent 同源 —— 每次 innerHTML 替换都让 + // 上一轮的 resolveRelativeImages 失效),M7 fix:loadContentLive 内层 + // resolveRelativeImages 也必须走 generation 比对,单靠 capturedBody 不够 + // (bodyElement 身份不变)。 + this._resolveGen += 1; + this.bodyElement.innerHTML = html; + this.resolveRelativeImages(this.currentFile ? this.currentFile.path : null); + // P1-1:增量更新 IntersectionObserver 而不是每次 innerHTML 后全量重建。 + // 编辑实时预览里 200ms 防抖触发一次,10K 行的笔记每次重建 observer + // 会做 disconnect + new + N 次 observe,长文档能感知到卡顿。 + // 仅当 heading 集合发生变化(增 / 删 / id 变)才重建,否则完全跳过。 + // audit fix (Round 4 F11):直呼底层 _rebuildHeadingObserver。_syncHeadingObserver + // 是仅做一层转发的薄壳,签名上的优化语义已被 _rebuildHeadingObserver 全量重建 + // 替代,留着壳子只会让读代码的人多绕一层。直接调底层 + 注释说明「全量重建代价 + // 可忽略」。 + this._rebuildHeadingObserver(); + return { changed: true }; + } + + /** + * 当前在视口里"最顶部可见"的 heading + * @returns {{level:1|2|3, text:string, id:string}|null} + */ + getCurrentHeading() { + if (!this.currentHeadingId || !this._headingMap.size) return null; + return this._headingMap.get(this.currentHeadingId) || null; + } + + /** + * 订阅"当前 heading 变化"事件 + * + * 用 IntersectionObserver 跟踪每个 heading 元素是否进入/离开视口顶部 + * 一带状区域(rootMargin: '0px 0px -80% 0px' 表示视口顶部 20% 那一条带)。 + * 多个 heading 可能在同一带里 → 取"位置最高且仍在带内"的最后一个。 + * + * @param {(heading: {level:1|2|3, text:string, id:string}|null) => void} cb + * @returns {() => void} 取消订阅函数 + */ + onHeadingChange(cb) { + if (typeof cb !== 'function') return () => {}; + this.onHeadingChangeCallbacks.add(cb); + // 立刻用当前 heading 触发一次,让订阅者能立刻同步状态 + try { cb(this.getCurrentHeading()); } catch { /* ignore */ } + return () => this.onHeadingChangeCallbacks.delete(cb); + } + + /** + * 重建 heading IntersectionObserver + * + * 每次 loadContent / loadContentLive 都会调用 —— 因为 DOM 全换了, + * 旧的 observer 持有的 DOM 节点已经无效,必须重建。 + */ + _rebuildHeadingObserver() { + if (this._headingObserver) { + try { this._headingObserver.disconnect(); } catch { /* ignore */ } + this._headingObserver = null; + } + if (!this.bodyElement) return; + const headings = this.bodyElement.querySelectorAll('h1, h2, h3'); + // 同步构建 heading 快查表(id → {level, text, id}), + // 让 getCurrentHeading 直接从 DOM 读取。 + this._headingMap = new Map(); + for (const h of headings) { + if (!h.id) continue; + this._headingMap.set(h.id, { + level: Number(h.tagName.charAt(1)), // 'H1' → 1 + text: h.textContent || '', + id: h.id, + }); + } + // 记录当前观察的 heading id 列表(调试 / 复用参考) + this._observedHeadingIds = headings.length === 0 ? [] : Array.from(headings).map(h => h.id); + if (headings.length === 0) { + this.currentHeadingId = null; + this._emitHeadingChange(); + return; + } + // 把"当前可见的 heading 集合"实时维护:进入带 → 加入;离开带 → 移除。 + // 最后 emit 的 currentHeadingId 是"位置最低且仍在带内"的(最接近顶部 + // 但还没滚走的那个 —— 这就是用户在阅读的章节)。 + const visibleIds = new Set(); + this._headingObserver = new IntersectionObserver((entries) => { + for (const e of entries) { + const id = e.target.id; + if (!id) continue; + if (e.isIntersecting) visibleIds.add(id); + else visibleIds.delete(id); + } + // 找在 visibleIds 里、且 DOM 顺序中位置最低(最后)的 heading + let next = null; + for (const h of headings) { + if (visibleIds.has(h.id)) next = h.id; + } + // 如果一个都不在带内(滚过头了),保持上一次 + if (next === null && visibleIds.size === 0) { + // 不主动改 currentHeadingId —— 让"上次已知"留在状态栏 + return; + } + // 滚过头(带内全空,且最后位置在视口下方) → 选中最后一个 heading + if (next === null) { + // 用 viewport 顶部坐标最近的 heading + const scroller = this.bodyElement.parentElement; + if (scroller) { + // offsetTop 相对最近的 positioned 祖先;本布局里 .viewer / .markdown-body + // 都是 position: static,offsetParent 一路爬到 —— 与 + // scroller.scrollTop 完全不在同一坐标系,会导致「滚过头时永远选不中」。 + // 改用 getBoundingClientRect 拿视口坐标,scroller.scrollTop 在两边都加 + // 上 → 相互抵消,等价于比较 h.rect.top <= scroller.rect.top。 + const scrollerTop = scroller.getBoundingClientRect().top; + let best = null; + let bestDist = Infinity; + for (const h of headings) { + const hTop = h.getBoundingClientRect().top; + if (hTop <= scrollerTop) { + const d = scrollerTop - hTop; + if (d < bestDist) { bestDist = d; best = h.id; } + } + } + next = best; + } + } + if (next !== this.currentHeadingId) { + this.currentHeadingId = next; + this._emitHeadingChange(); + } + }, { + root: this.bodyElement.parentElement || null, + // 顶部 0-20% 的"激活带" —— 进入这个带才算"当前正在阅读" + rootMargin: '0px 0px -80% 0px', + // P2-9:单阈值 0 即可。原来的 [0, 1] 会在「进入带」与「完全在带里」各发一次, + // 状态栏 heading chip 在边界来回闪烁;激活带由 rootMargin 控制已经够准确。 + threshold: 0, + }); + for (const h of headings) this._headingObserver.observe(h); + } + + /** + * 增量更新 heading observer(曾经是 P1-1 的 id-only 优化) + * + * 编辑实时预览(loadContentLive)每次都会 innerHTML 替换全部正文。 + * + * audit fix (renderer-M1):旧实现靠「heading id 列表相等就跳过重建」来 + * 优化,但 innerHTML 替换之后观察的目标节点本身已经被 detach —— id 列表 + * 相等(用户改的是正文而非 heading)时 observer 继续盯着 detach 节点, + * 新 heading 节点没有任何 observer,「当前 heading」追踪直接冻死。 + * + * heading 数量是 O(N) 且 N 通常 5-50,全量重建代价可忽略。直接走全量重建, + * 简单且正确。 + * + * audit fix (Round 4 F11):原 _syncHeadingObserver 是仅做一层转发的薄壳, + * 已被删除,调用方(loadContentLive)直接调 _rebuildHeadingObserver。 + */ + _emitHeadingChange() { + if (!this.onHeadingChangeCallbacks.size) return; + const heading = this.getCurrentHeading(); + for (const cb of this.onHeadingChangeCallbacks) { + try { cb(heading); } catch (e) { console.error('[viewer] onHeadingChange callback 异常:', e); } + } + } +} diff --git a/src/modal-stack.js b/src/modal-stack.js new file mode 100644 index 0000000..3dabfc9 --- /dev/null +++ b/src/modal-stack.js @@ -0,0 +1,55 @@ +// 模态栈(最小版:单槽冲突拒绝) +// +// 现象: +// settingsDialog 与 confirmDialog 都把 DOM 挂到同一个 #modal-root, +// 用 root.replaceChildren(overlay) 覆盖写入。一个还没关就被另一个顶掉, +// 原来 await 的 Promise 永远不会 resolve —— 整条交互挂死。 +// +// 策略(用户已选): +// 已有对话框时拒绝新开(不排队、不顶替),调用方自行处理 toast。 +// +// 接口: +// isOpen() → boolean +// register(overlay) → void;当前 overlay 入栈 +// unregister(overlay) → void;从栈中移除 +// +// 设计上保持简单:不监听 DOM,避免和 dialog 自己的 dispose 顺序打架。 +// 调用方负责 register(open) / unregister(close) 的配对。 +// +// 注:早期版本还有 tryReject() / openOrReject() —— 返回 {ok, reason} 让调用方 +// 决定是否 toast。该模式与「isOpen + 早返回 + 自己 toast」相比无收益(前者要 +// 调用方再分支),实际零调用方,已删。 + +let currentOverlay = null; + +export function isOpen() { + return currentOverlay !== null && currentOverlay.isConnected; +} + +export function register(overlay) { + // audit fix:拒收非 DOM 元素 / null / undefined / 字符串等。旧版不校验, + // 任何传错都会让 isOpen() 在 currentOverlay.isConnected 处抛 TypeError, + // 把「当前是否有模态」这个 hot-path 探测点炸掉,进而影响 IPC 回复路径。 + if (!(overlay instanceof Element)) { + throw new TypeError('modal-stack.register: overlay 必须是 DOM Element'); + } + currentOverlay = overlay; +} + +export function unregister(overlay) { + // audit fix:idempotent —— 不匹配时也清掉已断连的引用。否则:overlay A 被 + // 外部 .remove() 后没调 unregister,再 register(B) → currentOverlay=B。 + // 之后某个调试路径 unregister(A)(按旧引用传)会发现不等、什么都不做, + // 但 B 的 unregister(B) 仍能把 currentOverlay 清成 null —— 这里其实 + // 没问题。真正问题是:A 没调 unregister 又被外部断连,下次 isOpen() 走 + // isConnected 检查返回 false,UI 显示「无模态」但栈里仍持有 dead ref, + // 调试时 dump 出来是误导的。 + if (currentOverlay === overlay) { + currentOverlay = null; + return; + } + // 不匹配:若 currentOverlay 已不连到 DOM(外部 .remove()),顺手清掉。 + if (currentOverlay && !currentOverlay.isConnected) { + currentOverlay = null; + } +} \ No newline at end of file diff --git a/src/modal.js b/src/modal.js new file mode 100644 index 0000000..e9f4bfe --- /dev/null +++ b/src/modal.js @@ -0,0 +1,328 @@ +// 通用 modal 骨架 —— 集中 ARIA / Esc / overlay-click / focus-trap / 单槽冲突 +// +// 之前 settings-dialog.js 与 confirm-dialog.js 字符级复制了同样的 modal-overlay +// 骨架 + 一套关闭事件(overlay-click / Esc / × 按钮)+ focus-trap 接线。 +// 抽到一处后,aria 属性、focus 行为、生命周期都只有一份代码可改。 +// +// 设计目标: +// - 调用方只关心「标题 / 正文 / 按钮」三件事 +// - 关闭路径(Esc / 点背景 / × 按钮)由 modal 自身统一处理 +// - 调用方可以自定义「关闭时取什么值」(resolve(value)) +// - 提供 closeValue / updateBody 等逃生口用于更新对话框内容 +// +// 调用方式: +// const modal = mountModal({ +// title: '...', +// body: '

    ...

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

    ${escapeHtml(message)}

    ` + : ''; + // 审计修复 (Round 11 vol.2):输入框下方预留给 validate 错误信息; + // 初始为空(无错误态),validate 触发时填文案 + aria-invalid。 + const bodyHtml = ` + ${messageHtml} +
    + + + +
    + `; + + const footerHtml = ` + + + `; + + // tryConfirm 必须先声明(即便 body 还没渲染)—— 因为 mountModal 的 onAction + // 回调是延迟触发的,user 真正点按钮 / 按 Enter 时 inputEl / errorEl 都已就绪。 + // 这里只放一个 forward declaration;函数体和引用在 inputEl 之后才生效。 + // audit fix (Phase L1-Settings P1):Enter 与按钮 click 必须走完全同一条 + // validate 路径。早期版本 Enter 直接 modal.close('confirm') 跳过校验, + // 导致重命名场景输入非法名 → dialog 静默关闭 → IPC 拒、用户输入丢失。 + let tryConfirm = () => undefined; + + const modal = mountModal({ + title, + body: bodyHtml, + footer: footerHtml, + // 初始焦点放到输入框(focus-trap 在 queueMicrotask 里调 focus) + initialFocus: `#${INPUT_ID}`, + // Esc / × / 点背景 = 取消(与浏览器原生 prompt 一致) + onBackdropClose: 'cancel', + onAction: (action) => { + // cancel 直接让 mountModal 关闭;confirm 委托给 tryConfirm, + // 由它决定「validate 失败保持打开」或「成功 close」。 + if (action === 'cancel') return 'close'; + if (action === 'confirm') { + // 返回 undefined(mountModal 不再负责关闭),因为 tryConfirm 内部 + // 已经按需调用 modal.close('confirm')。 + tryConfirm(); + return undefined; + } + return undefined; + }, + }); + + if (!modal) { + // mountModal 失败(#modal-root 缺失 / 已有 modal 占位) + // 注意:不能 fallback 到 window.prompt() —— Electron 渲染端 prompt() 会抛错 + // ('prompt() is and will not be supported'),正好是我们要绕开的坑。 + // 兜底行为:当作取消处理(resolve null),并 toast 提示用户,让重要 + // 操作(重命名 / 新建文件)不会静默无反应(audit L2 #7)。 + showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning'); + resolve(null); + return; + } + + const inputEl = modal.body.querySelector(`#${INPUT_ID}`); + const errorEl = modal.body.querySelector(`#${ERROR_ID}`); + if (!inputEl) { + // 极端兜底:input 没渲染出来 —— 直接关掉并 resolve(null),不挂死调用方 + modal.close('cancel'); + resolve(null); + return; + } + + // 错误显示/清除:单点维护行内错误 UI 行为(aria-invalid + 文本 + hidden)。 + function showError(msg) { + if (!errorEl) return; + errorEl.textContent = msg; + errorEl.hidden = false; + inputEl.setAttribute('aria-invalid', 'true'); + inputEl.classList.add('is-invalid'); + } + function clearError() { + if (!errorEl) return; + errorEl.textContent = ''; + errorEl.hidden = true; + inputEl.removeAttribute('aria-invalid'); + inputEl.classList.remove('is-invalid'); + } + + // 用户每次输入都清错误 —— 让他们看到「错误已识别,现在改正」反馈。 + // 守卫:在没 validate 的场景下也保留(clearError 是 no-op 安全)。 + inputEl.addEventListener('input', () => { + if (inputEl.getAttribute('aria-invalid') === 'true') clearError(); + }, { signal: modal.abortSignal }); + + // 单一 resolve 出口:所有关闭路径(Cancel / × / Esc / 背景 / Confirm / Enter)都走这里 + modal.setOnClose((value) => { + // Promise resolve 是幂等的,重复调用安全 + if (value === 'cancel') resolve(null); + else resolve(inputEl.value); + }); + + // IME composition 跟踪 —— CJK 输入法按 Enter 是「确认候选词」而非「提交表单」。 + // 不跟踪会让正在打拼音的用户按 Enter 直接关闭弹窗,丢掉半截输入。 + // 参考 src/ai/ai-chat-panel.js 的 _composing 模式(更严谨的 compositionstart/end)。 + let composing = false; + inputEl.addEventListener('compositionstart', () => { composing = true; }, { signal: modal.abortSignal }); + inputEl.addEventListener('compositionend', () => { composing = false; }, { signal: modal.abortSignal }); + inputEl.addEventListener('blur', () => { composing = false; }, { signal: modal.abortSignal }); + + // 单一确认入口:按钮 click 与 Enter 键都路由到这里。 + // audit fix (Phase L1-Settings P1):早期 Enter 直接 modal.close('confirm'), + // 完全绕过 validate 校验。重命名场景输入非法名(路径分隔符 / 保留设备名) + // 按 Enter → dialog 静默关闭 → IPC 拒、用户 typed text 丢失、错误只通过 + // 通用 toast 模糊暴露。现在 validate 失败时显示行内红字并保持打开, + // 与按钮 click 走完全同一条路径。 + tryConfirm = () => { + // 客户端预校验(Round 11 vol.2):返回 null/undefined = 通过; + // 返回字符串 = 错误消息(dialog 保持打开)。 + if (typeof validate === 'function') { + let errMsg = null; + try { + errMsg = validate(inputEl.value); + } catch (e) { + // validate 自身抛错 → 当成错误消息展示(不静默吞) + errMsg = (e && typeof e.message === 'string' && e.message) || '输入校验失败'; + } + if (errMsg) { + showError(errMsg); + // input 焦点留在原位(用户改完再确认) + try { inputEl.focus({ preventScroll: true }); } catch { /* ignore */ } + return; + } + clearError(); + } + modal.close('confirm'); + }; + + // Enter 提交(焦点在 input 内时);与 form submit 等价但更直接。 + // 守卫 e.isComposing + composing flag:拼音 / 日语 IME 用 Enter 确认候选词时不应触发提交。 + inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.isComposing && !composing) { + e.preventDefault(); + tryConfirm(); + } + }, { signal: modal.abortSignal }); + + // 打开时全选默认文字(模仿 window.prompt() 行为)。 + // focus-trap 内部已用 queueMicrotask 调 focus,我们在它之后排一个 microtask 即可。 + if (selectAllOnOpen && defaultValue) { + queueMicrotask(() => { + try { inputEl.select(); } catch { /* input 已不在 DOM */ } + }); + } + }); +} \ No newline at end of file diff --git a/src/save-button-ui.js b/src/save-button-ui.js new file mode 100644 index 0000000..2e099d3 --- /dev/null +++ b/src/save-button-ui.js @@ -0,0 +1,46 @@ +// 保存按钮 UI 同步 —— 纯函数,与 src/app.js 解耦,便于单测。 +// +// 设计: +// - 始终显示按钮(见 [[feedback-save-button-always-visible]]),靠 class / aria +// 表达三态:保存中 / 有待保存改动 / 干净。 +// - 「保存中」用 savingCount 计数器(>0 即为保存中)—— 共享 inFlightSave 期间 +// 多次进入 save() 不会因共享 then 路径无 finally 而闪烁;归零才真正熄灭。 +// - 不设 disabled 属性 —— Ctrl+S 走 EditorView keymap(与 DOM disabled 无关), +// 设了反而与 click / hover 反馈不一致。视觉提示用 cursor + opacity。 + +/** + * 把 state.isDirty / state.savingCount 同步到按钮的 class / aria-busy / aria-label / title, + * 并把 isDirty 同步到状态栏「未保存」chip(hidden + 文案)。 + * 同时把 isDirty 通过 setDirtyFn 回调传出(用于 IPC 通知主进程)。 + * + * @param {Object} args + * @param {HTMLElement|null} args.btnSave 保存按钮 DOM(可为 null,函数静默 return) + * @param {HTMLElement|null} [args.statusUnsaved] 状态栏未保存 chip(可选;缺省不更新) + * @param {Object} args.state renderer state(用 isDirty / savingCount) + * @param {(dirty: boolean) => any} [args.setDirty] 异步通知外部(IPC);缺省空操作 + */ +export function syncSaveButton({ btnSave, statusUnsaved, state, setDirty = () => {} }) { + if (!btnSave) return; + const isSaving = state.savingCount > 0; + btnSave.classList.toggle('is-dirty', state.isDirty); + btnSave.classList.toggle('is-saving', isSaving); + btnSave.setAttribute('aria-busy', isSaving ? 'true' : 'false'); + // 三态文案:保存中 > 有待保存改动 > 干净 + const label = isSaving + ? '保存中…' + : (state.isDirty ? '保存(有待保存的改动)' : '保存'); + btnSave.setAttribute('aria-label', label); + btnSave.title = isSaving + ? '保存中…' + : (state.isDirty ? '保存(有待保存的改动) (Ctrl+S)' : '保存 (Ctrl+S)'); + // 状态栏「未保存」chip:preview / 全屏等场景下,工具栏按钮可能被遮挡, + // 状态栏右下角的红点 chip 是更显眼的脏态指示。 + // 只反映 isDirty —— is-saving 是过渡态,飞行结束会立刻切回脏/干净, + // 不需要重复在状态栏展示(避免一明一灭的视觉抖动)。 + if (statusUnsaved) { + statusUnsaved.hidden = !state.isDirty; + statusUnsaved.textContent = '未保存'; + statusUnsaved.setAttribute('aria-label', '当前文件有未保存的改动'); + } + setDirty(state.isDirty); +} \ No newline at end of file diff --git a/src/save-toast.js b/src/save-toast.js new file mode 100644 index 0000000..5fd4e52 --- /dev/null +++ b/src/save-toast.js @@ -0,0 +1,83 @@ +// 保存路径 toast 决策(Phase N Q-fix) +// +// 把 save() 主路径与 shared path 都需要的 toast 决策抽成纯函数,方便单测 +// 覆盖所有 silent × result 组合,避免两端 inline if/else 漂移。 +// +// 历史问题:save() IIFE 内部曾直接 `if (!silent) showToast('已保存')`, +// shared path 复用 IIFE 返回值时用的是 IIFE 创建者的 silent —— 用户连按 +// 两次 Ctrl+S(auto-save silent=true 飞行期 user silent=false 手动),silent +// 标志被 IIFE 吞掉,manual caller 收不到「已保存」反馈。重构后: +// - IIFE 内部不再弹 success toast,统一返回 {ok, reason} +// - save() outer(主路径 + shared path)按自己的 silent 决定是否弹 +// - shared path 还有 stale 场景(飞行期切文件 → IIFE 静默 return STALE), +// silent=false manual caller 必须弹「文件已切换,未保存到磁盘」 +// +// 错误 toast(FILE_CHANGED_EXTERNALLY 各分支 / FILE_NOT_FOUND / PATH_NOT_ALLOWED +// / SYMLINK_NOT_ALLOWED / errno 兜底 / IPC_ERROR)仍在 IIFE 各分支里按 result.error +// 弹,与 silent 无关 —— 所有 silent 都弹错误(用户必须感知失败)。本 helper +// 只管 success + STALE 两种 toast 决策。 +// +// @param {boolean} silent +// @param {{ok:boolean, reason?:string}|null|undefined} result +// @param {(message: string, type: 'success'|'warning'|'error'|'info', duration: number) => void} showToast +// @returns {void} + +/** + * 根据调用方 silent 标志 + IIFE 返回结果决定是否弹 toast。 + * + * 决策矩阵: + * - silent=false + ok=true(无 STALE / NO_CHANGES 标记) → 「已保存」(success, 2500ms) + * - silent=false + reason='STALE'(无论 ok) → 「文件已切换,未保存到磁盘」(warning) + * Phase 2 audit 修复 (P1 #3):STALE 不再等同「写盘失败」。飞行期用户切 + * 走时 writeFile 已 resolve,data 可能已落 targetPath。ok=true 让关窗路径 + * 能正确退出;用户视角的反馈(manual Ctrl+S silent=false)应该总是 STALE + * 提示(因为 editor 已不在 targetPath 上),文案保持中性「文件已切换,未 + * 保存到磁盘」—— 如果用户想知道数据到底有没有存,去看 A 文件本身。 + * - silent=false + reason='NO_CHANGES' → 「已是最新」(info, 1500ms) + * fix(audit 2026-08):auto-save 静默写盘后 state.isDirty=false,用户手动 + * 点保存按钮落入 !state.isDirty 早退路径,旧逻辑静默 return 让用户觉得 + * 「按钮坏了 / 没保存上」—— 实际文件已最新。给一个简短 info 让点击有反馈。 + * auto-save silent=true 路径不影响(silent 早退)。 + * - silent=false + reason='NO_FILE' → 「没有打开文件」(info, 1500ms) + * fix(audit Round 7 S-3):旧版与 NO_CHANGES 共用「已是最新」文案,但语义 + * 错位 ——「已是最新」暗示「有文件且未改动」,用户视角「什么都没打开, + * 按 Ctrl+S 当然没东西存」需要准确的「没有打开文件」。auto-save silent + * 路径不影响。 + * - silent=true → 啥都不弹(auto-save / 关窗 silent=true 静默场景) + * - 其它失败 reason(CONFLICT_* / FILE_NOT_FOUND / PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED + * / WRITE_FAILED / IPC_ERROR)→ 错误 toast 已在 IIFE 各分支按 result.error 弹过, + * 本 helper 不重复弹(避免误导性「保存失败(请重试)」覆盖 IIFE 已弹的具体原因)。 + * + * 「已保存」duration 从 1200ms 提到 2500ms(fix audit 2026-08):1200ms 太快, + * 用户实际只看到约半秒淡出动画,根本来不及形成「保存完成」的认知,导致 + * 「保存按钮失灵」类错觉反复出现。2500ms 是大多数 IDE(VS Code / Sublime) + * 「Saved」toast 的常用区间,足够看见。 + */ +export function decideSaveToast(silent, result, showToast) { + if (silent) return; + if (!result) return; + if (result.reason === 'STALE') { + showToast('文件已切换,未保存到磁盘', 'warning', 2500); + return; + } + if (result.reason === 'NO_CHANGES') { + showToast('已是最新', 'info', 1500); + return; + } + if (result.reason === 'NO_FILE') { + // audit fix (Round 7 S-3):与 NO_CHANGES 区分。语义是「没有打开文件」 + // 而不是「文件已是最新」,文案准确反映状态。 + showToast('没有打开文件', 'info', 1500); + return; + } + if (result.reason === 'DELEGATED') { + // audit fix (Round 8 S-1):force_overwrite 把写盘委托给递归 inner save(), + // inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责 + // 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。 + return; + } + if (result.ok) { + showToast('已保存', 'success', 2500); + } + // 其它失败 reason:IIFE 内部已按 result.error 弹过原因 toast,这里不重复。 +} \ No newline at end of file diff --git a/src/settings-dialog.js b/src/settings-dialog.js new file mode 100644 index 0000000..88c3f35 --- /dev/null +++ b/src/settings-dialog.js @@ -0,0 +1,1002 @@ +// 设置对话框 +// +// 三个分区:外观 / AI / 关于 +// - 外观:主题配色(视觉 swatch)+ 阅读字号(视觉卡片)+ 阅读行距(视觉卡片) +// - AI:服务提供方(视觉卡片)+ Base URL + API Key + Model + System Prompt +// - 关于:开发者信息 + 主页链接 +// +// **功能入口单一来源原则**:已经有即时触发入口的功能不进设置对话框: +// - 数据文件夹 → 侧栏底部「打开 / 切换数据文件夹」(高频操作贴近作用区域) +// - 主题循环 / 置顶 / 视图模式 → 顶部工具栏或托盘菜单 +// - 文件列表排序 → 左侧栏工具条 +// - 自动保存 → 顶部工具栏按钮 +// 见 [[feedback-no-toolbar-duplicate-in-settings]] / [[feedback-prefer-near-affected-area]]。 + +import { showToast } from './feedback.js'; +import { escapeAttr } from './utils.js'; +import { isOpen as modalIsOpen } from './modal-stack.js'; +import { mountModal } from './modal.js'; +// schema 经 preload 的 contextBridge 过桥,不能直接 import +// shared/settings-schema.js —— 那是 CommonJS,renderer 是 Chromium 原生 ESM, +// 没有 CJS 互操作(改扩展名也不行),import 会让整个应用起不来。 +const SETTINGS_UI_OPTIONS = (typeof window !== 'undefined' + && window.api?.settingsSchema?.SETTINGS_UI_OPTIONS) || {}; + +// 取自 schema —— 这里重新解构成局部常量只是为了让模板字符串更短。 +// 一律兜底成空数组:桥缺失时对话框应当渲染出空选项,而不是在 .map 上抛错。 +const READER_FONT_SIZES = SETTINGS_UI_OPTIONS.readerFontSize ?? []; +const READER_LINE_HEIGHTS = SETTINGS_UI_OPTIONS.readerLineHeight ?? []; +const THEME_PALETTES = SETTINGS_UI_OPTIONS.themePalette ?? []; +const AI_PROVIDERS = SETTINGS_UI_OPTIONS.aiProvider ?? []; + +// 默认 baseURL / model:按 provider 切换「placeholder」提示用户 +// OpenAI 兼容:baseURL 含 /v1;Anthropic:baseURL 不含 /v1 +const AI_DEFAULTS = { + openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }, + anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-opus-5' }, +}; + +// 配色 swatch 用 CSS 自定义属性渲染真实色块,避免硬编码颜色导致配色变更时不一致。 +// 每个 palette 的 strip 由 bg + accent + fg 三段组成,给用户直观的视觉印象。 +// audit fix (H4/H5):default accent 改为 #4f5ec7(与 02-palettes.css 同步, +// 是过 WCAG AA 的真实色,preview 与实际主题保持一致)。 +const PALETTE_PREVIEW_COLORS = { + default: { bg: '#08090a', accent: '#4f5ec7' }, + ocean: { bg: '#0b141d', accent: '#6cc5e6' }, + forest: { bg: '#0e1410', accent: '#76c78d' }, + lavender: { bg: '#14101a', accent: '#c89ce8' }, + sunset: { bg: '#1a0e14', accent: '#ffa091' }, +}; +// 配色卡片直接用 schema 的 label,避免「薰衣」 vs「薰衣草」漂移: +// 之前 PALETTE_SHORT_LABEL 给 5 列横排做单字省略,但 schema 标签(最长的 +// 「薰衣草」3 字)也塞得下;现在统一只用 schema 描述,没有重复事实源。 + +// API Key 显示/隐藏切换按钮的图标 + 标签(state-driven): +// aria-pressed 表示「当前是否处于显示态」,与 icon/aria-label/label-text 同步切换, +// 否则用户/读屏只看到静态"显示/隐藏",无法判断当前密码框到底是隐藏还是明文。 +const EYE_OPEN_SVG = ` + +`; +const EYE_OFF_SVG = ` + +`; +function renderAiKeyToggleLabel(isShown) { + return isShown + ? `${EYE_OFF_SVG}隐藏` + : `${EYE_OPEN_SVG}显示`; +} + +// 视觉选择器共用的 ✓ 角标 SVG(state-driven 选中态用) +const CHECK_SVG = ` + +`; + +export class SettingsDialog { + /** + * @param {object} options + * @param {object} options.api - window.api + * @param {object} options.settingsStore - SettingsStore 实例 + * @param {(changes: object) => void|Promise} options.onChanged - 保存后回调 + */ + constructor({ api, settingsStore, onChanged }) { + this.api = api; + this.settingsStore = settingsStore; + this.onChanged = onChanged; + this.modal = null; + this._saving = false; + } + + /** + * 打开对话框(返回 Promise,关闭时 resolve 为 'saved' 或 'cancel') + * + * @param {object} [options] + * @param {'ai'} [options.focusSection] - 渲染后自动滚动到对应段并把焦点放在第一个必填空字段上。 + * 用途:用户点 AI 按钮但 AI 未配置 → 直接打开设置并定位到 AI 段,比 toast 更直观。 + */ + async open(options = {}) { + // 审计修复 (Round 11 P2):防并发 open() race。 + // 旧版只查 modalIsOpen()(DOM 里有没有 .modal-overlay),但两次快速调用都 + // 在 mountModal 之前命中 → 两次都通过守卫 → 第二次 mountModal 返回 null + // → 第二次 caller 走 console.error + 静默 resolve 'cancel',用户无任何反馈。 + // 现在加实例级 _opening 标志(同步置 true),后续调用全拦截;_opening 在 + // Promise finally 里清掉。this.modal 同步状态比 _render 晚,所以加 _opening。 + if (this.modal || this._opening) { + if (options && options.focusSection) { + focusSettingsSectionInOpenDialog(options.focusSection); + return 'cancel'; + } + showToast('已有对话框打开', 'warning'); + return 'cancel'; + } + if (modalIsOpen()) { + // 但是 — 当外部因为「AI 未配置」自动调用打开时,如果设置已经在开,我们不重复弹窗, + // 而是把当前对话框滚到目标 section(用户已经在设置里,不必再次打扰)。 + if (options && options.focusSection) { + focusSettingsSectionInOpenDialog(options.focusSection); + return 'cancel'; + } + showToast('已有对话框打开', 'warning'); + return 'cancel'; + } + + this._opening = true; + const initial = this.settingsStore.getAll(); + + // 拿到 config.json 的文件名 + 所在目录名 —— API Key hint 用, + // 让用户知道数据存在哪个文件、能一键打开核对。 + // + // audit fix (Round 9 CFG-1):之前读 `cfg.path` / `cfg.dir`,但 main 端 + // `app:get-config-path` 在 M4 脱敏后返回的是 `{name, dirName}` —— 两个字段 + // 恒为 undefined,`initial._configPath` 永不赋值,导致 hint 里的路径与 + // [打开文件夹] 按钮整段模板都不渲染(文案还断在「存储于 config.json」和 + // 「。每次请求」之间)。按 main 的真实契约读 name / dirName。 + // getConfigPath 缺失(旧版 preload)走空串兜底,hint 区降级显示。 + if (typeof this.api.getConfigPath === 'function') { + try { + const cfg = await this.api.getConfigPath(); + if (cfg && cfg.name) { + initial._configName = cfg.name; + initial._configDirName = cfg.dirName || ''; + } + } catch { /* 静默:忽略 */ } + } + + // 一次性把 API Key 真值同步进 initial(main.js getAll 返回 aiApiKey:'' + + // _hasAiKey 布尔位;reveal 真值必须发生在 _render 之前)。 + // + // 不这么做会出现两个 bug: + // 1) 用户清空 input 后 value('' )=== initial.aiApiKey('') → diff 失真, + // "已清空" 不会被写入存储,下一次开 dialog 又被 reveal 填回去。 + // 2) reveal 异步期间用户点保存,diff 基线仍是 '',行为不可预测。 + // + // 在这里 await 而不是 _render 里 fire-and-forget,能彻底避免 + // 「reveal 异步回来覆盖用户输入」的 race(用户开 dialog 后立刻改 key, + // 旧 reveal 还在 pending,回来时会把用户的输入覆盖掉)。 + // + // Q7/Q9 fix (audit):reveal 必须先 arm —— main.js 端要求 reveal 必须发生在 + // arm 后的 5 秒窗口内(per-sender)。打开设置对话框本身是用户主动行为, + // (顶部工具栏设置按钮 / AI 未配置时自动跳转到 AI 段 / Ctrl+,)都算用户动作。 + // arm + reveal 都在同一个 async 块内完成,5 秒足够。 + // audit C1 安全修复:reveal 失败时不能静默 —— 否则用户看到空白 Key 字段会以为 + // 没配置过,输入新值后保存把磁盘上原本正常的 Key 覆盖坏掉。 + // _aiKeyRevealFailed 标记传给 _render:input value 仍保持空(用户可手动重填), + // 但额外渲染一行 hint 告知「Key 已存但无法回显」+ diff 阶段把 aiApiKey 排除 + // (避免空白输入被当成「清空 Key」覆盖磁盘)。 + let _aiKeyRevealFailed = false; + if (initial._hasAiKey && typeof this.api.armRevealAiKey === 'function' && typeof this.api.revealAiKey === 'function') { + try { + const arm = await this.api.armRevealAiKey(); + if (arm && arm.ok) { + const res = await this.api.revealAiKey(); + if (res && res.ok) { + // 审计修复 (Round 11 P1):reveal 返回 ok=true 但 aiApiKey 为空 + // (契约违反或瞬时状态)必须视为失败 —— 否则 input 看似空 + 无 hint + + // 用户输入新 Key 后保存,diff 会把空 input 视为"清空"或"替换",静默 + // 覆盖磁盘上原本正常的 Key。把 ok=true 但无 aiApiKey 视为 reveal 失败, + // 让 hint + diff 守卫生效。 + // + // audit fix (Settings P2 / validateKey typeof guard):加 typeof 守卫。 + // res.aiApiKey 契约上是非空字符串,但 main 端契约违反(返回 + // 非 string 类型 —— 例如未来的 API 改动返回 base64 buffer 对象) + // 会绕过 `if (res.aiApiKey)` 真值检查,把非字符串塞进 initial.aiApiKey。 + // 后续 _save 的 diff 比较 `aiApiKey !== (initial.aiApiKey || '')` + // 会因类型不同永远不相等,把「未变更」也当成「清空」写入,破坏磁盘 + // 数据。typeof !== 'string' 一律走 reveal 失败路径,hint + 守卫生效。 + if (typeof res.aiApiKey === 'string' && res.aiApiKey) { + initial.aiApiKey = res.aiApiKey; + } else { + _aiKeyRevealFailed = true; + } + } else { + _aiKeyRevealFailed = true; + } + } else { + _aiKeyRevealFailed = true; + } + } catch (e) { + // 审计修复 (Round 11 P3):catch 里记一条 console.warn 便于排查; + // UX 不变(仍走 hint 路径),但生产环境有线索可循。 + console.warn('[settings-dialog] reveal failed:', e); + _aiKeyRevealFailed = true; + } + } + initial._aiKeyRevealFailed = _aiKeyRevealFailed; + + return new Promise((resolve) => { + this._render(initial, resolve); + // 渲染后再做跳转:scrollIntoView 需要元素已挂载;focus 也得让出 + // mountModal 的 initial-focus(footer 保存按钮),所以 setTimeout 推一拍。 + if (options && options.focusSection) { + setTimeout(() => focusSettingsSectionInOpenDialog(options.focusSection), 0); + } + }).finally(() => { + // 审计修复 (Round 11 P2):无论 resolve 走 cancel / save / 异常,_opening + // 都要清 —— 否则下一次 open() 被这个标志拦截、永远打不开对话框。 + // 必须在 finally(不是 then)里:异常路径也要恢复。this.modal 的清理 + // 由 modal.js 解绑时同步做,这里只管 _opening。 + this._opening = false; + }); + } + + _render(initial, resolve) { + const bodyHtml = [ + this._renderAppearanceSection(initial), + this._renderAiSection(initial), + this._renderAboutSection(initial), + ].join(''); + const footerHtml = ` + + + `; + + const modal = mountModal({ + title: '设置', + titleId: 'settings-title', + body: bodyHtml, + footer: footerHtml, + onBackdropClose: 'cancel', + onAction: (action) => { + // 保存按钮:自己处理异步 + 错误回滚,_save() 内部决定是否关闭 + if (action === 'save') { + this._save(initial); + return; // 不返回 'close',让 _save 决定是否 modal.close + } + // 取消按钮 / 任何 data-action 路径(除 save)→ 由 mountModal 统一关闭 + // (不自己调 modal.close,避免与 modal.js 的 close(action) 重复触发) + return 'close'; + }, + }); + + if (!modal) { + // audit fix (Settings P2 / mountModal silent fail):之前只 console.error + // 然后静默 resolve('cancel') —— 用户点「设置」按钮无任何反馈、还以为 + // 自己没点中。同时 toast 一条警告与 console 错误,方便诊断 root 缺失 + // (通常是 index.html 漏挂 #modal-root / 已有 modal 占据单槽)。 + console.error('[settings-dialog] 无法挂载 modal(root 缺失或已有对话框)'); + showToast('无法打开设置对话框,请重试', 'error'); + resolve('cancel'); + return; + } + this.modal = modal; + const signal = modal.abortSignal; + const overlay = modal.overlay; + + // audit fix (Settings P2 / Ctrl+S):模态内的快捷保存。 + // 旧版用户改完设置必须离开键盘、精确点 footer「保存」按钮 —— 鼠标路径, + // 对键盘党不友好。Ctrl+S / Cmd+S 走 _save(),行为与按钮 click 完全一致 + // (含 _saving 防并发 + onChanged 副作用 + modal.close 路径)。 + // - preventDefault:阻止 Electron / 浏览器默认「保存网页」动作(Electron + // 默认会弹保存对话框,污染 UX)。 + // - IME 合成期守卫:与 modal.js Esc 守卫一致 —— 拼音/日语 IME 按 Ctrl+S + // 可能是「保存候选词」之类的本地操作,未 commit 之前不应触发我们的 save。 + // - stopPropagation:避免冒泡到 document 触发 app 级别的 Ctrl+S 处理器 + // (main.js 注册了 'menu:save' via onMenuCommand 走不同的"保存当前笔记"路径, + // 与"保存设置"语义不同 —— 在设置里要拦截)。 + // - signal 绑定:modal close 时随 abortSignal 自动解绑,避免泄漏。 + overlay.addEventListener('keydown', (e) => { + if (e.isComposing || e.keyCode === 229) return; + const isSaveCombo = (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey + && (e.key === 's' || e.key === 'S'); + if (!isSaveCombo) return; + e.preventDefault(); + e.stopPropagation(); + this._save(initial); + }, { signal }); + + // 外观段:3 个视觉选择器(配色 / 字号 / 行距)+ 实时预览 + this._bindAppearancePickers(overlay, signal); + + // audit fix (H13):textarea 不能用模板里的 escapeAttr(systemPrompt) 注入内容—— + // escapeAttr 把 < > & 转成 < > &,再次打开设置时用户看到的是字面 + // &lt;b&gt; 这种"双重转义"内容。现在 textarea 模板里只渲染空 body, + // mount 后用 DOM property 直接赋原值,避开 entity 编解码。 + const aiSystemPromptEl = overlay.querySelector('#settings-ai-system-prompt'); + if (aiSystemPromptEl) aiSystemPromptEl.value = initial.aiSystemPrompt || ''; + + // AI Key 显示/隐藏 + const aiKeyInput = overlay.querySelector('#settings-ai-api-key'); + const aiKeyToggle = overlay.querySelector('[data-action="toggle-ai-key"]'); + if (aiKeyInput && aiKeyToggle) { + aiKeyToggle.addEventListener('click', () => { + // nextShown = true 表示按下后变为「显示」态(type=text) + const nextShown = aiKeyInput.type === 'password'; + aiKeyInput.type = nextShown ? 'text' : 'password'; + // 同步图标/文字/aria 状态:之前只有 type 变化,按钮自身没有任何反馈, + // 用户/读屏无法判断当前是显示还是隐藏态。 + aiKeyToggle.setAttribute('aria-pressed', String(nextShown)); + aiKeyToggle.setAttribute('aria-label', nextShown ? '隐藏 API Key' : '显示 API Key'); + aiKeyToggle.innerHTML = renderAiKeyToggleLabel(nextShown); + aiKeyInput.focus(); + // 切到显示态 + input 已有值时全选文本,方便复制 / 检查 Key 是否正确; + // 空 input 跳过(用户接下来要输入,不应全选)。 + if (nextShown && aiKeyInput.value) aiKeyInput.select(); + }, { signal }); + } + // 对话框关闭时立即清空 API Key input,避免被 DevTools / 截图 / 复制粘贴泄露 + if (typeof modal.setOnClose === 'function') { + modal.setOnClose(() => { + if (aiKeyInput) aiKeyInput.value = ''; + }); + } + + // AI Provider 切换:按选中的 provider 改 baseURL / model placeholder + hint 文案。 + // 已填的值不主动清空 —— 用户可能是中转服务(OpenAI 兼容),故意保留旧值。 + const aiProviderRadios = overlay.querySelectorAll('input[name="ai-provider"]'); + const aiBaseUrlInput = overlay.querySelector('#settings-ai-base-url'); + const aiModelInput = overlay.querySelector('#settings-ai-model'); + const aiBaseUrlHint = overlay.querySelector('#settings-ai-base-url-hint'); + if (aiProviderRadios.length && aiBaseUrlInput && aiModelInput) { + const applyHint = (providerValue) => { + const defaults = AI_DEFAULTS[providerValue] || AI_DEFAULTS.openai; + aiBaseUrlInput.placeholder = defaults.baseUrl; + aiModelInput.placeholder = defaults.model; + // 句式统一:只讲协议路径是否含 /v1 前缀。"兼容"信息已经在卡片 label 表达。 + if (aiBaseUrlHint) { + aiBaseUrlHint.innerHTML = providerValue === 'anthropic' + ? 'Anthropic 兼容:请求路径不含 /v1 前缀。' + : 'OpenAI 兼容:请求路径含 /v1 前缀。'; + } + }; + aiProviderRadios.forEach((radio) => { + radio.addEventListener('change', () => { + if (radio.checked) applyHint(radio.value); + }, { signal }); + }); + + // 清空 AI 配置:把 5 个字段全部清掉,复位 provider 到默认 openai, + // placeholder/hint 跟着 reset。**不直接保存** —— 跟 [重置为默认] + // 行为一致(用户在 [保存] 上决定要不要落盘;不点保存则视为放弃)。 + const clearAiBtn = overlay.querySelector('[data-action="clear-ai"]'); + if (clearAiBtn) { + clearAiBtn.addEventListener('click', () => { + // 审计修复 (Round 11 P1-7):先 blur IME 合成中的 input。 + // 用户在 Key 输入框里 IME 拼音没打完(compositionend 未发)时点清空, + // 直接置 '' 会丢失正在输入的内容且 IME 状态错乱。blur 让 IME 先 commit/cancel。 + const imeTargets = [aiKeyInput, aiBaseUrlInput, aiModelInput].filter(Boolean); + const active = document.activeElement; + if (active && imeTargets.includes(active) && typeof active.blur === 'function') { + active.blur(); + } + const aiSystemPromptEl = overlay.querySelector('#settings-ai-system-prompt'); + const openaiRadio = overlay.querySelector('input[name="ai-provider"][value="openai"]'); + if (openaiRadio) { + openaiRadio.checked = true; + // 派发 change 让 _bindAppearancePickers 里的委托监听器跑: + // - syncSelectedClass 切 .is-selected + ✓ 角标 + // - ai-provider 的 placeholder / hint 由 aiProviderRadios 单独监听器同步 + openaiRadio.dispatchEvent(new Event('change', { bubbles: true })); + } + applyHint('openai'); + aiBaseUrlInput.value = ''; + if (aiKeyInput) aiKeyInput.value = ''; + aiModelInput.value = ''; + if (aiSystemPromptEl) aiSystemPromptEl.value = ''; + // 审计修复 (Round 11 P2-4):去掉重复的 applyHint('openai')。 + // 上面 dispatch + openaiRadio.checked 已触发 aiProviderRadios 的 change + // 监听器跑过一次 applyHint,再调一次是冗余。 + showToast('已清空 AI 配置,点保存后生效', 'info', 1800); + // focus 到 Base URL 让用户能直接接着填;不放 model 是因为清空后 + // 通常会先确认/切换 provider,再选对应模型。 + aiBaseUrlInput.focus(); + }, { signal }); + } + } + + // 打开 config.json 所在目录 —— 用户点 hint 里的 [打开文件夹] 触发。 + // + // audit fix (Round 9 CFG-1):之前走 `app:open-path` 并传 `initial._configDir`, + // 但那个 handler 强制 target === 当前**数据**目录,配置目录(userData)会被 + // PATH_NOT_ALLOWED 拒掉 —— 即使按钮渲染出来也点不动。改走专用的 + // `app:open-config-dir`(不接参数,路径在主进程内部组合)。 + const openConfigDirBtn = overlay.querySelector('[data-action="open-config-dir"]'); + if (openConfigDirBtn && typeof this.api.openConfigDir === 'function') { + openConfigDirBtn.addEventListener('click', async () => { + try { + const result = await this.api.openConfigDir(); + if (!result || !result.ok) { + showToast(`打开失败:${(result && (result.message || result.error)) || '未知错误'}`, 'error'); + } + } catch { + showToast('打开配置目录失败', 'error'); + } + }, { signal }); + } + + // 关于段:开发者主页链接走 openExternal(系统默认浏览器打开), + // 不让 Electron 内嵌 webview 接管,保持与 app.js 中其它外链一致的体验。 + // + // 委托到 overlay 上而不是给每个 .about-link 单独挂监听:未来如果加更多外链 + // (GitHub / 文档站等),直接复用同一段 click handler 即可。 + // - preventDefault:阻止默认 href 跳转(Electron 内打开一个空白 webview 是 UX 灾难) + // - URL 来自 data-* 属性,模板里已 escapeAttr,运行时直接读取 + // - openExternal 缺失(极旧 preload)走静默兜底,链接至少还能 hover 看 URL + const aboutLinks = overlay.querySelectorAll('.about-link[data-external-url]'); + if (aboutLinks.length) { + overlay.addEventListener('click', async (e) => { + const target = e.target; + if (!(target instanceof Element)) return; + const link = target.closest('.about-link[data-external-url]'); + if (!link) return; + const url = link.getAttribute('data-external-url'); + if (!url) return; + e.preventDefault(); + if (typeof this.api.openExternal !== 'function') return; + try { + const result = await this.api.openExternal(url); + if (!result || !result.ok) { + const msg = result && result.message ? result.message : '无法打开该链接'; + showToast(msg, 'warning'); + } + } catch { + showToast('无法打开该链接', 'warning'); + } + }, { signal }); + } + + modal.setOnClose((value) => { + this.modal = null; + // 关闭即重置:成功路径(modal.close('save'))和用户取消(Esc / × / 背景) + // 都会走这里,不重置的话下次开 dialog 时 _saving=true 会让保存按钮永远禁用。 + this._saving = false; + resolve(value === 'save' ? 'saved' : 'cancel'); + }); + } + + /** 外观段:3 个 picker 卡片组 + 实时预览 */ + _renderAppearanceSection(initial) { + // 注意:暗/亮主题循环按钮在顶部工具栏,所以这里不提供「主题」选择; + // 只提供「配色(palette)」,因为它没有顶部按钮入口。 + const palette = THEME_PALETTES.some(p => p.value === initial.themePalette) ? initial.themePalette : 'default'; + const paletteLabel = THEME_PALETTES.find(p => p.value === palette)?.label || palette; + const fs = READER_FONT_SIZES.includes(initial.readerFontSize) ? initial.readerFontSize : 17; + const lh = READER_LINE_HEIGHTS.includes(initial.readerLineHeight) ? initial.readerLineHeight : 1.85; + + // 配色卡片:5 列横排,每张展示该 palette 的 bg + accent 二色条 + const paletteCards = THEME_PALETTES.map((p) => { + const colors = PALETTE_PREVIEW_COLORS[p.value] || PALETTE_PREVIEW_COLORS.default; + const isSelected = p.value === palette; + const shortLabel = p.label; + return ` + + `; + }).join(''); + + // 字号卡片:5 张卡,每张渲染一个 "Aa" 样本 + const fontSizeCards = READER_FONT_SIZES.map((n) => { + const isSelected = n === fs; + return ` + + `; + }).join(''); + + // 行距卡片:4 张卡,每张渲染 3 行示例文本 + const lineHeightCards = READER_LINE_HEIGHTS.map((n) => { + const isSelected = n === lh; + return ` + + `; + }).join(''); + + return ` +
    +
    + +
    +

    外观

    +

    配色风格、阅读字号与行距

    +
    +
    +
    +
    +
    + 配色 + ${escapeAttr(paletteLabel)} +
    +
    ${paletteCards}
    +
    +
    +
    +
    +
    + 阅读字号 + ${fs} px +
    +
    ${fontSizeCards}
    +
    +
    +
    +
    +
    + 阅读行距 + ${lh} +
    +
    ${lineHeightCards}
    +
    +
    +
    +
    预览 · 所见即所得
    +
    +
    段落与排版
    +

    这是一段示例文本,预览选中的排版风格。包含 inline code链接的实际观感。

    +
    引用块用左侧色条表达,行距与正文同步跟随。
    +
      +
    • 列表项,行距也受选中值影响
    • +
    • 第二项,便于对比
    • +
    +
    # 代码块
    +print('hello')
    +
    +
    +
    + `; + } + + /** + * AI 段:用户在设置里填「OpenAI 兼容」API 的配置。 + * - Provider:OpenAI 兼容 (/chat/completions) 或 Anthropic (/v1/messages) + * - Base URL:按 provider 不同;切换 provider 时自动改 placeholder + * - API Key:密码框 + 显示/隐藏眼睛按钮,避免在设置里裸明文(但本机本地存储仍然明文) + * - Model:模型名,自由文本(不同服务命名不一样) + * - System Prompt:留空时主进程用内置中文 prompt(默认 Markdown 助手) + * + * 不做"已配置/未配置"提示 —— 让用户自己知道有没有填; + * 工具栏 AI 按钮在未配置时给出 toast 提示已足够。 + */ + _renderAiSection(initial) { + // 初始值都来自 schema(必填项:string 类型 + 空串默认;首次打开是空) + const provider = AI_PROVIDERS.some(p => p.value === initial.aiProvider) ? initial.aiProvider : 'openai'; + const baseUrl = initial.aiBaseUrl || ''; + const apiKey = initial.aiApiKey || ''; + const model = initial.aiModel || ''; + // audit fix (H13):systemPrompt 不在模板里 escapeAttr 注入,改在 open() mount 后 + // 直接给 textarea.value 赋值(绕过 entity 编解码,保留用户输入的原貌)。 + // initial.aiSystemPrompt || ''; + + const currentHints = AI_DEFAULTS[provider] || AI_DEFAULTS.openai; + + // AI 服务卡片:整宽单列堆叠(grid 1fr),每张只显示 icon + 名称 + hint。 + // + // 之前是 2 列横排 + 右侧 .aiprovider-protocol 角标,但 hint 文本里已经包含 + // 协议路径("/chat/completions · ..."),protocol 角标是纯重复信息; + // 2 列 + 长 hint 还导致文本断行成 3 行("Azure 等"溢出),视觉拥挤。 + // 只有 2 个 provider,"2 列并排比较"收益小,整宽反而给 hint 留够单行空间。 + // + // icon 用 "GPT" / "Claude" 文字而不是单字母 "O"/"A" —— 后者在小尺寸下跟数字 + // 0、符号△ 等容易混淆,文字最无歧义。 + const providerCards = AI_PROVIDERS.map((p) => { + const isSelected = p.value === provider; + const iconText = p.value === 'anthropic' ? 'Claude' : 'GPT'; + return ` + + `; + }).join(''); + + return ` +
    +
    + +
    +

    AI

    +

    调用大模型协助修改文档

    +
    +
    + +

    连接

    +
    +
    +
    + 服务提供方 +
    +
    ${providerCards}
    +
    +
    +
    + + +
    ${provider === 'anthropic' + ? 'Anthropic 兼容:请求路径不含 /v1 前缀。' + : 'OpenAI 兼容:请求路径含 /v1 前缀。'}
    +
    +
    + +
    + + +
    + ${initial._aiKeyRevealFailed + ? `
    + Key 已保存但当前无法回显。如要替换请输入完整新 Key;留空不会清空已有 Key(需清空请手动编辑 config.json)。 +
    ` + : ''} +
    +
    + + +
    对应服务的模型标识(具体名称以其文档为准)。
    +
    + +
    + +
    + API Key 明文存储于本机 config.json${initial._configName ? `${initial._configDirName ? `(位于 ${escapeAttr(initial._configDirName)} 配置目录)` : ''}` : ''}。每次请求以 HTTP header 形式发送至上方的 Base URL,请使用你信任的服务。 +
    +
    + +

    行为

    +
    + + +
    让 AI 按特定角色或格式回复;留空时使用内置默认。
    +
    + +
    + +
    + 清空 Base URL、API Key、模型与提示词。 + +
    +
    +
    + `; + } + + /** + * 关于段:开发者信息 + 主页链接。 + * + * 字段全部硬编码(不是用户可改的设置),所以本段不参与 diff / 保存: + * - 链接用「关于」段单独的 click 监听器走 openExternal 打开 + * - 段内不放 input / picker,没有需要被 _save 读出的值 + */ + _renderAboutSection(_initial) { + // 链接硬编码:data-* 属性携带 URL,渲染后由 _render 末尾的 click 委托读取并 + // 走 openExternal。escapeAttr 防止 URL 里出现 " 关闭属性的边界。 + const devName = '关济寰'; + const devUrl = 'https://www.guanjihuan.com/about'; + return ` +
    +
    + +
    +

    关于

    +

    开发者信息

    +
    +
    +
    +
    + 开发者: + ${escapeAttr(devName)} +
    +
    + 主页: + ${escapeAttr(devUrl)} +
    +
    +
    + `; + } + + /** + * 绑定外观段 3 个 picker 的交互: + * - 切换 card → 同步 .is-selected 视觉态(✓ 角标 + 边框高亮) + * - 实时更新 .md-preview 的字号/行距 + * - 更新右上角的数值标签 + * + * ⚠️ .is-selected 必须跟随 radio checked 同步,否则切了 radio + * 但 ✓ 角标还卡在初始那张卡上(之前只在渲染时设一次)。 + */ + _bindAppearancePickers(overlay, signal) { + const updatePreviewStyle = (fs, lh) => { + const preview = overlay.querySelector('#md-preview'); + if (preview) { + preview.style.setProperty('--sample-font-size', `${fs}px`); + preview.style.setProperty('--sample-line-height', String(lh)); + } + }; + + // 同步切 .is-selected:找到 radio 所在的 picker-grid, + // 把所有卡片的 .is-selected 重新对一次 :checked 状态 + const syncSelectedClass = (radio) => { + const grid = radio.closest('.picker-grid'); + if (!grid) return; + grid.querySelectorAll('.picker-card').forEach((card) => { + const r = card.querySelector('input[type="radio"]'); + card.classList.toggle('is-selected', !!(r && r.checked)); + }); + }; + + // 委托:整个 modal 上挂一个 change 监听就够了 + overlay.addEventListener('change', (e) => { + const target = e.target; + if (!(target instanceof HTMLInputElement)) return; + const name = target.name; + if (!name) return; + + if (name === 'theme-palette') { + syncSelectedClass(target); + // 用 schema 里的中文 label(不是 value 'default'),跟卡片 label 视觉对齐 + const valueLabel = overlay.querySelector('#palette-value-label'); + if (valueLabel) { + const opt = THEME_PALETTES.find((p) => p.value === target.value); + valueLabel.textContent = opt ? opt.label : target.value; + } + } else if (name === 'reader-font-size') { + syncSelectedClass(target); + const fs = parseInt(target.value, 10); + const valueLabel = overlay.querySelector('#fontsize-value-label'); + if (valueLabel) valueLabel.textContent = `${fs} px`; + const lh = parseFloat(overlay.querySelector('input[name="reader-line-height"]:checked')?.value || '1.85'); + updatePreviewStyle(fs, lh); + } else if (name === 'reader-line-height') { + syncSelectedClass(target); + const lh = parseFloat(target.value); + const valueLabel = overlay.querySelector('#lineheight-value-label'); + if (valueLabel) valueLabel.textContent = String(lh); + const fs = parseInt(overlay.querySelector('input[name="reader-font-size"]:checked')?.value || '17', 10); + updatePreviewStyle(fs, lh); + } else if (name === 'ai-provider') { + // AI provider 的 is-selected 同样需要同步(picker 通用) + syncSelectedClass(target); + // placeholder / hint 的更新在 aiProviderRadios 的 change 里处理 + } + }, { signal }); + } + + async _save(initial) { + const modal = this.modal; + if (!modal) return; + const overlay = modal.overlay; + + // 【in-flight 保护】双击保存按钮 / 重复 Enter 会触发两次 update;第二次应直接忽略 + if (this._saving) return; + this._saving = true; + + // 取出保存按钮并禁用,防止重复点击 + const saveBtn = overlay.querySelector('[data-action="save"]'); + if (saveBtn) saveBtn.disabled = true; + + try { + const paletteChecked = overlay.querySelector('input[name="theme-palette"]:checked'); + const fsChecked = overlay.querySelector('input[name="reader-font-size"]:checked'); + const lhChecked = overlay.querySelector('input[name="reader-line-height"]:checked'); + const aiProviderChecked = overlay.querySelector('input[name="ai-provider"]:checked'); + const aiBaseUrlEl = overlay.querySelector('#settings-ai-base-url'); + const aiApiKeyEl = overlay.querySelector('#settings-ai-api-key'); + const aiModelEl = overlay.querySelector('#settings-ai-model'); + const aiSystemPromptEl = overlay.querySelector('#settings-ai-system-prompt'); + + // 任何关键 DOM 缺失都直接放弃(不弹误导性错误) + if (!paletteChecked || !fsChecked || !lhChecked) { + console.error('[settings-dialog] 关键输入元素缺失,无法保存'); + showToast('设置对话框内部错误', 'error'); + return; + } + + const themePalette = paletteChecked.value; + const readerFontSize = parseInt(fsChecked.value, 10); + const readerLineHeight = parseFloat(lhChecked.value); + + // AI 字段:trim 后存为字符串(schema 默认是空字符串)。 + // 不强制非空 —— 用户可能想清空 AI 配置。 + // aiProvider 是 enum(radio 选中):不需要 trim(trim enum 值是 no-op, + // 还可能掩盖 schema 校验之外的脏数据;schema 兜底拒绝非法值)。 + const aiProvider = aiProviderChecked ? (aiProviderChecked.value || 'openai') : 'openai'; + const aiBaseUrl = aiBaseUrlEl ? (aiBaseUrlEl.value || '').trim() : ''; + const aiApiKey = aiApiKeyEl ? (aiApiKeyEl.value || '').trim() : ''; + const aiModel = aiModelEl ? (aiModelEl.value || '').trim() : ''; + const aiSystemPrompt = aiSystemPromptEl ? (aiSystemPromptEl.value || '').trim() : ''; + + // dataDir / fileListSort / autoSaveDebounceMs 已搬出设置对话框 + // (分别由侧栏底部「切换数据文件夹」/ sidebar-sort 工具条 / toolbar 自动保存按钮处理), + // 这里不再读它们的 input。见 [[feedback-prefer-near-affected-area]]。 + + const changes = {}; + if (themePalette !== initial.themePalette) changes.themePalette = themePalette; + if (readerFontSize !== initial.readerFontSize) changes.readerFontSize = readerFontSize; + if (readerLineHeight !== initial.readerLineHeight) changes.readerLineHeight = readerLineHeight; + // AI 字段:与 initial 不一致才写(避免空串覆盖已有值的常见误操作)。 + // audit C1:reveal 失败时 aiApiKey 字段无法回显(input 看似空), + // 但磁盘上有真值。如果把当前空 input 当成「清空」写入,会把正常 Key 覆盖坏。 + // 这种情况下直接跳过 aiApiKey 字段的 diff —— 用户必须输入完整新 Key 才会被写入。 + if (aiProvider !== (initial.aiProvider || 'openai')) changes.aiProvider = aiProvider; + if (aiBaseUrl !== (initial.aiBaseUrl || '')) changes.aiBaseUrl = aiBaseUrl; + // 审计修复 (Round 11 P0):reveal-failed 路径必须接受用户输入的新 Key。 + // 之前条件 `!initial._aiKeyRevealFailed && aiApiKey !== ...` 在 reveal 失败时 + // 直接 short-circuit,永远不进入 changes.aiApiKey —— 用户按 hint 提示输入新 + // Key 后点保存,新 Key 被静默丢弃,与文案「输入完整新 Key 会被写入」严重不符。 + // 修正:reveal 失败时如果用户输入了非空值视为要替换;只有留空才算「无变更」跳过。 + if (!initial._aiKeyRevealFailed) { + if (aiApiKey !== (initial.aiApiKey || '')) changes.aiApiKey = aiApiKey; + } else if (aiApiKey) { + changes.aiApiKey = aiApiKey; + } + if (aiModel !== (initial.aiModel || '')) changes.aiModel = aiModel; + if (aiSystemPrompt !== (initial.aiSystemPrompt || '')) changes.aiSystemPrompt = aiSystemPrompt; + + if (Object.keys(changes).length === 0) { + modal.close('save'); + return; + } + + await this.settingsStore.update(changes); + // audit fix (Round 4 F7):onChanged 单独 try/catch。settingsStore.update 已 + // 落盘,但 onChanged 内部可能调主题切换 / 字体刷新等同步副作用报错(如 CSP + // 拦截 / 用户代理阻止)—— 一旦抛错就阻断主流程,用户看到红色「保存失败」 + // toast,但 config.json 已写入,下次启动仍是新设置,视觉与磁盘状态割裂。 + // 落盘成功就视为「保存成功」,回调错误降级为 warn + 次级 toast 不阻断主流程。 + let onChangedError = null; + if (this.onChanged) { + try { + await this.onChanged(changes); + } catch (e) { + onChangedError = e; + console.warn('[settings-dialog] onChanged 副作用失败(配置已落盘):', e); + } + } + // audit fix (Round 4 F5):modal 关闭后不再弹「已保存」。onChanged 期间用户 + // 可能按 Esc / × 关闭 modal —— modal.close 已被调用,DOM 已 unmount,但 + // showToast 仍会弹在右下角,用户视觉上看到「modal 已关 + 突然冒出 toast」 + // 割裂。showToast 前先看 modal 是否仍在 DOM 里。 + if (document.querySelector('.modal-overlay')) { + if (onChangedError) { + showToast(`已保存,但部分视觉刷新失败:${onChangedError.message || '未知错误'}`, 'warning', 3500); + } else { + showToast('设置已保存', 'success', 1800); + } + } + modal.close('save'); + } catch (e) { + console.error('[settings-dialog] 保存失败:', e); + showToast(`保存失败:${e.message}`, 'error'); + } + // 统一收口:成功 / 失败 / 早退(DOM 缺失 / 无变更)三条路径都走这里。 + // 之前只有 catch 分支复位 _saving —— 成功后 modal.close('save') 直接 + // return,_saving 留在 true;再次 open() 会被 `if (this._saving) return` + // 误挡,必须 reload 才能恢复(按钮看起来"失灵",但实际上是状态卡死)。 + this._saving = false; + if (saveBtn) saveBtn.disabled = false; + } +} + +/** + * 把当前打开的设置对话框滚到指定 section,并 focus 第一个必填空字段。 + * 用于:「AI 未配置 → 自动打开设置」时已有对话框的场景,以及 open() 的初始 focusSection。 + * 模块级 helper —— 不依赖任何实例状态,直接 query 当前 modal-overlay。 + * @param {'ai'} section + */ +function focusSettingsSectionInOpenDialog(section) { + const overlay = document.querySelector('.modal-overlay'); + if (!overlay) return; + if (section === 'ai') { + // AI 是第 2 个 .settings-section(外观 / AI),但靠 selector 找更稳 + const apiKeyElProbe = overlay.querySelector('#settings-ai-api-key'); + const aiSection = apiKeyElProbe ? apiKeyElProbe.closest('.settings-section') : null; + if (aiSection) { + // 滚到 AI 段顶部(用 'instant' 而非 smooth —— 用户已经在等反馈,瞬时跳转不晃眼) + aiSection.scrollIntoView({ block: 'start', behavior: 'instant' }); + } + // 焦点:按 Base URL → API Key → Model 顺序,挑第一个空的(用户最缺的那一个)。 + // 全填了则 focus 到 Base URL(这样用户至少能看见该段没漏),避免没目标就啥也不做。 + const baseUrlEl = overlay.querySelector('#settings-ai-base-url'); + const apiKeyEl = overlay.querySelector('#settings-ai-api-key'); + const modelEl = overlay.querySelector('#settings-ai-model'); + const firstEmpty = [baseUrlEl, apiKeyEl, modelEl].find((el) => el && !el.value.trim()); + const target = firstEmpty || baseUrlEl; + if (target) { + try { target.focus(); } catch { /* 元素不可聚焦时静默 */ } + } + } +} diff --git a/src/settings-store.js b/src/settings-store.js new file mode 100644 index 0000000..1bcd48a --- /dev/null +++ b/src/settings-store.js @@ -0,0 +1,225 @@ +// 设置存储 +// 封装主进程 IPC 的 getSettings / saveSettings,提供本地缓存和 change 事件订阅。 +// +// 持久化由主进程负责(userData/config.json),这里只做内存缓存 + 同步广播。 +// +// 关键能力(Stage 7 增强): +// - preUpdateHook:写主进程前可拦截;返回 false 取消(用于「未保存改动时改 dataDir」之类场景) +// - 单调 token:并发的 update 调用,后到的响应不会覆盖后到的状态(audit #9) + +import { EventEmitter } from './event-emitter.js'; + +// schema 经 preload 的 contextBridge 过桥,不能直接 import +// shared/settings-schema.js —— 那是 CommonJS,而 renderer 是 Chromium +// 原生 ESM(nodeIntegration:false),没有 CJS 互操作,import 会让整个应用起不来。 +// preload 先于 renderer 模块求值,所以这里在顶层取值是安全的。 +const DEFAULT_SETTINGS = (typeof window !== 'undefined' + && window.api?.settingsSchema?.DEFAULT_SETTINGS) || {}; + +/** + * 归一化设置对象。桥不可用时退化为「默认值 + 已知键覆盖」。 + * @param {*} raw + */ +function coerceLoadedSettings(raw) { + if (typeof window !== 'undefined' && typeof window.api?.coerceLoadedSettings === 'function') { + return window.api.coerceLoadedSettings(raw); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...DEFAULT_SETTINGS }; + const out = { ...DEFAULT_SETTINGS }; + for (const key of Object.keys(DEFAULT_SETTINGS)) { + if (key in raw) out[key] = raw[key]; + } + return out; +} + +export class SettingsStore extends EventEmitter { + /** + * @param {object} api - window.api(由 preload 注入的 contextBridge API) + */ + constructor(api) { + super(); + this.api = api; + this._settings = { ...DEFAULT_SETTINGS }; + this._loaded = false; + // preUpdateHook: (partial, currentSettings) => boolean | Promise + // 返回 false 取消 update(不会写入内存,也不会触发 IPC)。 + // 用于 app.js 在 dataDir 变更时先检查编辑器是否有未保存改动(audit #1)。 + this._preUpdateHook = null; + // 单调递增 token:每次 update 自增;响应回来时只接受 token 匹配的写入。 + // 防止「A 提交 → B 提交 → B 响应先到 → A 响应后到覆盖 B 的状态」(audit #9)。 + this._updateToken = 0; + } + + /** + * 注册 preUpdateHook。同一时刻只允许一个 hook;后注册的覆盖前者。 + * hook 抛错视作「拒绝更新」。 + * @param {(partial: object, current: object) => boolean | Promise} hook + */ + setPreUpdateHook(hook) { + this._preUpdateHook = typeof hook === 'function' ? hook : null; + } + + /** + * 取当前注册的 preUpdateHook。返回函数或 null。 + * 供需要在临时覆盖前先备份、用完再恢复的场景(例如 app.js 的 settings 对话框 + * 临时挂一个 dataDir 脏检查的 hook,关闭后还原)。 + */ + getPreUpdateHook() { + return this._preUpdateHook; + } + + /** + * 从主进程拉取设置到内存 + */ + async load() { + if (!this.api || typeof this.api.getSettings !== 'function') { + console.warn('[settings-store] window.api.getSettings 不可用,使用默认值'); + this._settings = { ...DEFAULT_SETTINGS }; + this._loaded = true; + this.emit('change', { settings: this._settings }); + return this._settings; + } + try { + const remote = await this.api.getSettings(); + this._settings = coerceLoadedSettings(remote); + } catch (e) { + console.error('[settings-store] load 失败:', e); + this._settings = { ...DEFAULT_SETTINGS }; + } + this._loaded = true; + this.emit('change', { settings: this._settings }); + return this._settings; + } + + /** + * 读取单个设置 + */ + get(key) { + return this._settings[key]; + } + + /** + * 读取所有设置 + */ + getAll() { + return { ...this._settings }; + } + + /** + * 部分更新设置(异步写入主进程) + * + * 策略:乐观更新 —— 先在本地应用,立刻 emit 'change' 通知监听者; + * 然后异步写主进程。**失败时回滚到写入前的值并再 emit 一次 'change'**, + * 否则界面会停留在一个磁盘上并不存在的状态(比如显示着一个校验失败的 dataDir)。 + * + * 并发安全:每次 update 取一个单调递增 token;响应回来时若 token 已被新调用顶掉, + * 直接丢弃响应(不在本地应用),避免后到的旧响应覆盖新状态(audit #9)。 + * + * @param {object} partial + * @returns {Promise} 持久化后的完整设置 + * @throws {Error} 主进程校验失败(例如 dataDir 不存在)、IPC 异常、或 preUpdateHook 拒绝 + */ + async update(partial) { + if (!partial || typeof partial !== 'object') return this._settings; + + // preUpdateHook 拦截(audit #1) + if (this._preUpdateHook) { + let proceed = true; + try { + proceed = await this._preUpdateHook(partial, this._settings); + } catch (e) { + console.error('[settings-store] preUpdateHook 抛错:', e); + proceed = false; + } + if (!proceed) { + throw new Error('SETTINGS_UPDATE_CANCELLED'); + } + } + + // 取 token;并发的 update 会拿到不同 token,旧的 IPC 响应会被丢弃 + const myToken = ++this._updateToken; + + // 立刻在本地应用(乐观更新)。 + // L3 fix (audit):屏蔽 aiApiKey —— 真实值仍通过 api.saveSettings 发给主进程, + // 但 _settings 缓存不存真值。主进程 echo 的 settings 也是脱敏后的(aiApiKey=''), + // 写盘后 getAll() 也只返回 ''。这样乐观窗口期内(IPC 飞行中)任何 read path + // (getAll / emit 的 settings)都拿不到真值。 + // L9 fix (audit):emit 的 `changes` payload 也 redact aiApiKey —— 之前 + // partial.aiApiKey 是明文发出去的,虽然当前唯一监听器 syncBehaviorPrefsUI + // 只读 theme/font 等无关字段,但防御性 redact 让「将来新增监听器不会 + // 因为忘了过滤而拿到真值」。'***' 而不是 '',保留「字段被改过」的语义 + // (partial.aiApiKey === '' 不一定代表用户清空 Key,也可能是 reveal 后又改)。 + const before = { ...this._settings }; + const cachedPartial = { ...partial }; + const emitChanges = { ...partial }; + if ('aiApiKey' in cachedPartial) { + cachedPartial.aiApiKey = ''; // 不缓存真值 + emitChanges.aiApiKey = '***'; // 通知监听器「Key 字段有变」但不暴露值 + } + this._settings = { ...this._settings, ...cachedPartial }; + this.emit('change', { settings: this._settings, changes: emitChanges, previous: before }); + + if (!this.api || typeof this.api.saveSettings !== 'function') { + return this._settings; + } + + const rollback = (reason) => { + // 只有当我们仍是「最新调用」时才回滚;否则让新调用的状态保留 + if (myToken !== this._updateToken) return; + const failed = { ...this._settings }; + this._settings = before; + // audit fix (H2-settings):原 emit 用 `changes: before`,那是「写之前」的 + // 旧值快照,下游若按正常语义解读会把旧值当成新变更去应用。回滚的语义 + // 应当是「刚才那批 partial 提交失败、状态已退回」—— changes 给 partial + // (试图落盘但失败的内容),previous 给 failed(被撤回的中间态),并附 + // rolledBack=true + reason 让监听器按异常路径分支处理。 + // + // Phase N 修复:redact aiApiKey —— partial 里可能含用户刚输入的真值 Key。 + // emitChanges 已用 '***' 占位(L9 fix),这里同样把 partial 的 aiApiKey 抹掉, + // 防止监听器从 changes payload 里拿回真值。 + const emitChanges = { ...partial }; + if ('aiApiKey' in emitChanges) emitChanges.aiApiKey = '***'; + this.emit('change', { + settings: this._settings, + changes: emitChanges, + previous: failed, + rolledBack: true, + reason, + }); + }; + + let res; + try { + res = await this.api.saveSettings(partial); + } catch (e) { + console.error('[settings-store] update IPC 异常:', e); + rollback(e?.message); + throw e; + } + + // 响应已过时:被新调用顶掉了;直接丢弃(不应用也不回滚,新调用有自己的 before) + if (myToken !== this._updateToken) { + return this._settings; + } + + // 主进程返回 { ok:false, error } 表示校验失败(如目录不存在) + if (res && res.ok === false) { + const message = res.error || '保存设置失败'; + rollback(message); + throw new Error(message); + } + + // 当前协议:{ ok:true, settings } + if (res && res.settings && typeof res.settings === 'object') { + this._settings = coerceLoadedSettings(res.settings); + } + return this._settings; + } + + /** + * 是否已从主进程加载 + */ + isLoaded() { + return this._loaded; + } +} diff --git a/src/shortcuts.js b/src/shortcuts.js new file mode 100644 index 0000000..2e04056 --- /dev/null +++ b/src/shortcuts.js @@ -0,0 +1,206 @@ +// 全局快捷键 +// +// 10 个常用快捷键(详见下文清单): +// - Ctrl/Cmd + F 聚焦文件搜索框(编辑器内让给 CM6 查找面板) +// - Ctrl/Cmd + S 保存当前编辑的文件(脏状态时) +// - Ctrl/Cmd + E 切换视图模式(preview → edit → split) +// - Ctrl/Cmd + N 新建文件 +// - Ctrl/Cmd + , 打开设置 +// - Ctrl/Cmd + R 重新加载界面(与主菜单 reload 等价) +// - Ctrl/Cmd + Shift+T 切换主题 +// - Ctrl/Cmd + Shift+F 切换聚焦模式(隐藏工具栏/侧栏/状态栏) +// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6) +// - Esc(搜索框内) 清空搜索 +// +// 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时), +// 但事件仍会继续冒泡到 document —— 所以这里必须主动避让,否则 +// 在编辑器里按 Ctrl+F 会「同时」打开 CM 搜索面板并把焦点抢到侧栏搜索框。 +// +// 模态对话框打开期间(#modal-root 有 overlay),Ctrl+, / Ctrl+F / Ctrl+Shift+T +// 应当静默不触发,否则会把焦点抢走、或者顶掉现有 dialog(详见 modal-stack.js)。 +// 模态自己的 Esc → 关闭 在 dialog 内部已经独立处理。 +// Ctrl+R 不抑制:模态挂死时这反而是逃生口。 + +import { isOpen as modalIsOpen } from './modal-stack.js'; + +/** + * 事件是否发生在 CodeMirror 编辑器内部。 + * @param {Event} e + * @returns {boolean} + */ +function isInsideEditor(e) { + const target = e.target; + if (!target || typeof target.closest !== 'function') return false; + return !!target.closest('.cm-editor'); +} + +/** + * @param {object} handlers + * @param {() => void} handlers.onFocusSearch + * @param {() => void} handlers.onClearSearch + * @param {() => void} handlers.onSettings + * @param {() => void} handlers.onToggleTheme + * @param {() => void} handlers.onSave - Ctrl/Cmd+S + * @param {() => void} handlers.onToggleEditorMode - Ctrl/Cmd+E + * @param {() => void} [handlers.onNewFile] - Ctrl/Cmd+N + * @param {() => void} [handlers.onToggleFocusMode] - Ctrl/Cmd+Shift+F + * @param {() => void} [handlers.onToggleAi] - Ctrl/Cmd+Shift+A(AI 修改面板) + * @param {() => void} [handlers.onReload] - Ctrl/Cmd+R(README 之前提到但未注册——audit 补全) + */ +export function mountKeyboardShortcuts({ + onFocusSearch, + onClearSearch, + onSettings, + onToggleTheme, + onSave, + onToggleEditorMode, + onNewFile, + onToggleFocusMode, + onToggleAi, + onReload, +}) { + // audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。 + // 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount + // 会让 keydown listener 累积(每个按键触发 N 个回调);生产路径虽然只 mount + // 一次不致命,但 devtools 反复 Ctrl+R 重载 + HMR 场景下 listener 长期持有 + // handlers 闭包。 + const handler = (e) => { + const ctrl = e.ctrlKey || e.metaKey; + const modalOpen = modalIsOpen(); + + // Esc 清空搜索(仅当焦点在搜索框内,且不在模态内——模态自身监听 Esc) + if (e.key === 'Escape' && document.activeElement?.id === 'search-input' && !modalOpen) { + // audit fix (Round 4 F3):CJK IME 合成期间按 Esc 想收候选窗口时,document + // 仍可能 fire keydown。本分支若不拦,会调用 onClearSearch 清空搜索词 + IME + // 半截输入一起丢。下方 Ctrl+letter 分支已有 isComposing/keyCode=229 守卫, + // Esc 分支同样加上保持对称。 + if (e.isComposing || e.keyCode === 229) return; + if (onClearSearch) onClearSearch(); + return; + } + + if (!ctrl) return; + + // C3 fix (audit):CJK IME 合成期间(CJ / 中文拼音 / 日文 IME 等)浏览器 + // 仍会 fire keydown 给 Ctrl+letter 组合,且 key 已是真实字符(如 'e')。 + // 此时触发「切换视图模式 / 新建文件 / 切换 AI」会让用户 + // 拼音还没敲完视图就跳了 —— 而且 editor 是 CM6 容器时,模式切换会 + // destroy view,合成中的 IME buffer 跟着丢。 + // keyCode === 229 是历史 fallback(Firefox / 旧 Chromium 用 keyCode 标识 + // composition 状态),与 isComposing 一起兜底。 + if (e.isComposing || e.keyCode === 229) return; + + const inEditor = isInsideEditor(e); + + // 模态打开时禁用所有 Ctrl/Cmd 类快捷键,避免抢焦点或顶替对话框。 + // 注意:编辑器内的 Ctrl+S/Ctrl+F 已经在 inEditor 分支里 return 了, + // 所以这里再补一个 modalOpen 不会影响 CM6 内部的快捷键。 + if (modalOpen && !inEditor) { + // 但 Ctrl+S 仍允许 —— 在模态内按 Ctrl+S 不该有副作用 + // 真正会被拦下的:Ctrl+, / Ctrl+F / Ctrl+Shift+T / Ctrl+E / Ctrl+N / Ctrl+Shift+F / Ctrl+Shift+A + // 用 toLowerCase() 同时覆盖 Ctrl 与 Cmd,按 Shift 时 key 已是 'F'/'T' 等大写 + // —— 所以 'f' 一行就同时兜住 Ctrl+F 和 Ctrl+Shift+F,不用再写一份 shiftKey 子句。 + const k = e.key.toLowerCase(); + // P3 fix:用 Set 集中管理「modal 时需要吞掉的快捷键」白名单, + // 之前手写 6 个 k === X 链式条件,新增快捷键容易漏写一份。 + // shiftKey-only 的 't' / 'a' 用 `${k}:shift` 作 key,加 shift 后缀区分。 + const SUPPRESS_KEYS = new Set([ + ',', + 'f', + 't:shift', + 'e', + 'n', + 'a:shift', + // M1 fix (audit):模态打开时拦截 Ctrl+W —— 否则 Electron 默认行为 + // 是直接关闭窗口(带「未保存」的 settings dialog 也一起没了)。 + // 'w' 无 shift 修饰,token 写法同 'f' / 'e' / 'n'。 + 'w', + ]); + const token = e.shiftKey && (k === 't' || k === 'a') ? `${k}:shift` : k; + if (SUPPRESS_KEYS.has(token) && !e.altKey) { + e.preventDefault(); + return; + } + } + + // Ctrl/Cmd + F → 聚焦搜索(不带 Shift,让位给 Ctrl+Shift+F = 聚焦模式) + // 编辑器内让给 CM6 的查找面板(文内查找显然比跳到文件搜索更符合预期) + if ((e.key === 'f' || e.key === 'F') && !e.shiftKey) { + if (inEditor) return; + e.preventDefault(); + if (onFocusSearch) onFocusSearch(); + return; + } + + // Ctrl/Cmd + , → 设置 + if (e.key === ',') { + e.preventDefault(); + if (onSettings) onSettings(); + return; + } + + // Ctrl/Cmd + Shift + T → 切换主题 + if ((e.key === 'T' || e.key === 't') && e.shiftKey) { + e.preventDefault(); + if (onToggleTheme) onToggleTheme(); + return; + } + + // Ctrl/Cmd + S → 保存(无 shift 修饰,避免误触) + // 编辑器内 CM6 的 Prec.highest keymap 已经调过 onSave 了, + // 这里再调一次会触发两次写盘 + 两个 toast。 + if ((e.key === 's' || e.key === 'S') && !e.shiftKey && !e.altKey) { + if (inEditor) return; + e.preventDefault(); + if (onSave) onSave(); + return; + } + + // Ctrl/Cmd + E → 切换视图模式(CM6 没占用,编辑器内也生效) + if ((e.key === 'e' || e.key === 'E') && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (onToggleEditorMode) onToggleEditorMode(); + return; + } + + // Ctrl/Cmd + N → 新建文件 + // 注意:Ctrl+N 是 Electron「新建窗口」的传统快捷键,但本应用不提供多窗口, + // 直接复用为「新建文件」更符合笔记应用直觉。 + if ((e.key === 'n' || e.key === 'N') && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (onNewFile) onNewFile(); + return; + } + + // Ctrl/Cmd + Shift + F → 聚焦模式(隐藏工具栏/侧栏/状态栏,纯阅读) + // 与 Ctrl+F 区别开 —— 后者是文内查找(编辑器内)/聚焦文件搜索(其它位置)。 + if ((e.key === 'F' || e.key === 'f') && e.shiftKey && !e.altKey) { + e.preventDefault(); + if (onToggleFocusMode) onToggleFocusMode(); + return; + } + + // Ctrl/Cmd + Shift + A → 切换 AI 修改面板(与聚焦模式同用 Shift 修饰, + // 避开 Ctrl+S/E/N/F/T 等已占用位置) + // C2 fix (audit):编辑器聚焦时让位给 CM6 的 keymap(Shift+A 在编辑器里是 + // "全选" 之外的扩展键位),避免与编辑器内选择行为冲突。 + if ((e.key === 'A' || e.key === 'a') && e.shiftKey && !e.altKey) { + if (inEditor) return; + e.preventDefault(); + if (onToggleAi) onToggleAi(); + return; + } + + // Ctrl/Cmd + R → 重新加载界面(README 之前提到但未注册——audit 补全)。 + // 与主菜单「重新加载」等价(两者都是 webContents.reload / location.reload)。 + // 不走 modalOpen 抑制:模态挂死时用户可能就需要这个来强制刷新。 + // 也不走 inEditor 抑制:CM6 没占 Ctrl+R。 + if ((e.key === 'r' || e.key === 'R') && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (onReload) onReload(); + return; + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); +} diff --git a/src/splitter.js b/src/splitter.js new file mode 100644 index 0000000..f729499 --- /dev/null +++ b/src/splitter.js @@ -0,0 +1,260 @@ +// 通用拖拽分割条工厂 +// ============================================================================ +// +// 起源(Stage 4d 抽出):src/app.js 里有三段几乎一模一样的 mountSplitter*: +// - mountSplitter (editor ↔ viewer,3480-3640) +// - mountSidebarSplitter (sidebar,3652-3850) +// - mountAiSplitter (AI panel,3865-3984) +// +// 三段共享的样板: +// - 鼠标 + 触屏 drag 状态机(dragging / pointerId / startX / startWidth) +// - cursor: col-resize + userSelect: none 切换 +// - is-dragging class 切换 +// - 命中区扩展(仅 sidebar / AI:±HIT_RADIUS 像素也算命中) +// - document 级 mousemove / touchmove / mouseup / touchend / touchcancel +// +// 不共享的部分(由调用方注入): +// - 边界 clamp:editor 走 frSpace 动态算,sidebar/AI 走静态 [MIN, MAX] +// - 持久化:splitRatio / sidebarWidth / aiWidth 三个字段 +// - 命中区距离计算:sidebar 缓存中线(Phase M-H6),AI 每次重读 +// +// 设计: +// - createSplitter(opts) 接受 handle / setSize / getSize / clampSize / onDragEnd +// 等回调;工厂不耦合业务(不知道是 px 还是 ratio,不知道写到 :root 还是 +// .appShell),保持单一职责。 +// - 内部维护自己挂的 listener 列表,dispose() 一次性解除 —— 调用方在 pagehide +// 兜底调一次,避免 15 个 document 级 listener 长期持有闭包。 +// ============================================================================ + +/** + * @typedef {Object} HitZoneOpts + * @property {number} radius - 鼠标距 splitter 中线 ± radius 像素都算命中 + * @property {() => boolean} [isAvailable] - 当前是否启用命中区(AI 关闭时返回 false) + * 默认 true + * @property {(coord: number) => number} distanceTo - 鼠标坐标 → 到 splitter 中线的距离 + * (sidebar 缓存中线版本由调用方实现,工厂不关心怎么算) + * @property {(inZone: boolean) => void} [onHover] - 进/出命中区的视觉反馈 + * 默认:改 document.body.style.cursor(仅 axis='x' → col-resize) + */ + +/** + * @typedef {Object} SplitterOpts + * @property {HTMLElement} handle - 拖拽条元素 + * @property {'x' | 'y'} [axis='x'] - 'x' = 水平拖动改宽度;'y' = 垂直拖动改高度 + * @property {() => number} getSize - 当前目标元素的尺寸(px) + * 拖动期间会读多次(每次 mousemove 都会读);mount 时也会读一次确认初值 + * @property {(px: number) => void} setSize - 把 px 写到目标(CSS 变量 / inline style) + * 调用方负责写哪儿(:root / .appShell / 元素自身) + * @property {(px: number) => number} clampSize - clamp 到合法范围(动态边界由调用方负责) + * @property {(px: number) => void} onDragEnd - 拖完调一次,参数是已 clamp 的 px + * 调用方负责持久化 / 反算 ratio / 弹错误提示 + * @property {() => number} [initialSize] - 启动初始化。mount 时会用 setSize 应用一次 + * @property {HTMLElement} [body] - 设 cursor/userSelect 的元素,默认 document.body + * @property {HitZoneOpts} [hitZone] - 扩展命中区(不传 = 不启用) + * @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners] + * 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。 + * dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。 + */ + +/** + * 创建通用拖拽分割条。 + * + * 鼠标 / 触屏 拖拽的语义与原三段实现一致: + * - mouse / touch 都能起拖;触屏用 touch.identifier 关联 touchmove/touchend + * - 拖动期间:document.body.style.cursor = 'col-resize' 或 'row-resize', + * userSelect = 'none';拖完恢复 + * - 拖完一次:调 onDragEnd(clampSize(getSize())),由调用方持久化 + * + * 命中区扩展(hitZone)语义: + * - document 级 capture mousedown:鼠标在 ±radius 内且目标不是 handle 自己, + * 启动拖动(capture 阶段确保在 file-list / editor 等之前拿到事件) + * - document 级 mousemove:拖动中按 axis 改尺寸;空闲时按 distanceTo 算 inZone, + * 通知 onHover 做视觉反馈(默认 cursor) + * - 调用方传 isAvailable 返回 false 时,整个命中区逻辑跳过(AI 关闭时) + * + * @param {SplitterOpts} opts + * @returns {{ + * dispose: () => void, + * rebalance?: () => void, + * }} + * dispose 解除挂载的所有 listener(document 级 + handle 级 + window 级) + * rebalance 仅当传了 initialSize 时返回 —— 调用方在 window resize / layout 变化 + * 时调,按最新 frSpace / DPI 重算尺寸(不影响持久化值) + */ +export function createSplitter(opts) { + const { + handle, + axis = 'x', + getSize, + setSize, + clampSize, + onDragEnd, + initialSize, + body = (typeof document !== 'undefined' ? document.body : null), + hitZone = null, + windowListeners = null, + } = opts; + + if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素'); + if (typeof getSize !== 'function') throw new Error('[splitter] getSize 必须是函数'); + if (typeof setSize !== 'function') throw new Error('[splitter] setSize 必须是函数'); + if (typeof clampSize !== 'function') throw new Error('[splitter] clampSize 必须是函数'); + if (typeof onDragEnd !== 'function') throw new Error('[splitter] onDragEnd 必须是函数'); + + const cursor = axis === 'x' ? 'col-resize' : 'row-resize'; + // axis 对应的 clientX/Y 字段名 + const coordKey = axis === 'x' ? 'clientX' : 'clientY'; + // 触屏坐标取 touches[i[]. 的 clientX/Y,命名与 coordKey 一致 + + /** 当前挂的所有 listener,dispose 时一次性解除 */ + const listeners = []; + + function addListener(target, type, listener, options) { + target.addEventListener(type, listener, options); + listeners.push({ target, type, listener, options }); + } + + // 拖拽状态机 —— 与原三段实现语义一致 + let dragging = false; + let pointerId = null; + let startCoord = 0; + let startSize = 0; + + function coordOf(eventLike) { + return eventLike[coordKey]; + } + + function startDrag(coord, id) { + dragging = true; + pointerId = id; + handle.classList.add('is-dragging'); + startCoord = coord; + startSize = getSize(); + if (body) { + body.style.cursor = cursor; + body.style.userSelect = 'none'; + } + } + + function applyDrag(coord) { + const dCoord = coord - startCoord; + setSize(clampSize(startSize + dCoord)); + } + + function endDrag() { + if (!dragging) return; + dragging = false; + pointerId = null; + handle.classList.remove('is-dragging'); + if (body) { + body.style.cursor = ''; + body.style.userSelect = ''; + } + onDragEnd(clampSize(getSize())); + } + + // ----- 自身 handle 上的 mousedown / touchstart ----- + addListener(handle, 'mousedown', (e) => { + startDrag(coordOf(e), null); + e.preventDefault(); + }); + + addListener(handle, 'touchstart', (e) => { + const t = e.touches[0]; + if (!t) return; + startDrag(coordOf(t), t.identifier); + }, { passive: true }); + + // ----- document 级 mousemove / touchmove / ----end ----- + addListener(document, 'mousemove', (e) => { + if (!dragging || pointerId !== null) return; + applyDrag(coordOf(e)); + }); + + addListener(document, 'touchmove', (e) => { + if (!dragging || pointerId === null) return; + const t = Array.from(e.touches).find((x) => x.identifier === pointerId); + if (!t) return; + applyDrag(coordOf(t)); + }, { passive: true }); + + addListener(document, 'mouseup', endDrag); + addListener(document, 'touchend', endDrag); + addListener(document, 'touchcancel', endDrag); + + // ----- 命中区扩展(可选)----- + if (hitZone) { + const { radius, isAvailable = () => true, distanceTo, onHover } = hitZone; + + // document 级 capture mousedown:捕获阶段拿到事件,避免被 file-list 等吞掉 + addListener(document, 'mousedown', (e) => { + if (dragging) return; + if (e.button !== 0) return; // 只接左键 + if (!isAvailable()) return; + // handle 自己 / handle 内子节点(含 reset 按钮等)不重入 —— 子节点自己 + // 处理 mousedown(一般带 stopPropagation),否则会出现「点了按钮又拖动」。 + if (handle.contains(e.target)) return; + if (distanceTo(coordOf(e)) > radius) return; + e.preventDefault(); + e.stopPropagation(); + startDrag(coordOf(e), null); + }, true); + + // 拖动中 + 空闲命中区视觉反馈(合并到一个监听器,少派发一次) + let lastHover = null; + addListener(document, 'mousemove', (e) => { + if (dragging) { + if (pointerId === null) applyDrag(coordOf(e)); + return; + } + if (!isAvailable()) return; + const inZone = distanceTo(coordOf(e)) <= radius; + if (inZone !== lastHover) { + lastHover = inZone; + if (onHover) onHover(inZone); + else if (body) body.style.cursor = inZone ? cursor : ''; + } + }); + } + + // ----- 扩展 window 监听器(可选)----- + // 例如 sidebar splitter 用 resize / scroll 失效命中区中线缓存。 + // 走 addListener 同一张表,dispose() 一次性解除。 + if (windowListeners && windowListeners.length) { + for (const { type, listener, options } of windowListeners) { + addListener(window, type, listener, options); + } + } + + // ----- 启动初始化 ----- + if (typeof initialSize === 'function') { + setSize(clampSize(initialSize())); + } + + // ----- 返回 ----- + const result = { + /** 解除所有挂载的 listener。可重复调用。 */ + dispose() { + for (const { target, type, listener, options } of listeners) { + try { target.removeEventListener(type, listener, options); } catch { /* ignore */ } + } + listeners.length = 0; + if (body) { + body.style.cursor = ''; + body.style.userSelect = ''; + } + handle.classList.remove('is-dragging'); + }, + }; + + // 仅当调用方传 initialSize 时才暴露 rebalance —— 让"窗口 resize 时按 frSpace 重算" + // 这类外部触发走单点真理(避免多个 mountSplitter 都各自挂 resize 监听器)。 + if (typeof initialSize === 'function') { + result.rebalance = () => { + if (dragging) return; + setSize(clampSize(initialSize())); + }; + } + + return result; +} \ No newline at end of file diff --git a/src/stats.js b/src/stats.js new file mode 100644 index 0000000..b1bbfcb --- /dev/null +++ b/src/stats.js @@ -0,0 +1,76 @@ +// 文本统计 —— 词数 / 行数 / 字符数 的单一来源 +// +// 单一来源原因: +// 之前 editor.getStats() 走 view.state.doc.lines(CM6 逻辑行),viewer.getStats() +// 走 source.split('\n').length —— 同一份内容在 preview / edit 模式下状态栏 +// 「N 行」会跳变(例如尾部空行 / 长内容折行)。这里把行数算法收敛为: +// - 源文本 = source 给的(无论从 CM6 还是从 viewer 缓存) +// - 行数 = 非空行数(至少 1) +// +// CJK 分词算法保留 utils.countWords 的版本(已迭代到正确版本): +// - 中文 / 日文 / 韩文字符按字符计 +// - 拉丁 / 数字按空白分词后再过滤纯标点 +// - 不重叠计数 + +/** + * 统计 CJK 友好的词数。 + * 实现细节见 utils.countWords(语义相同,只是换了模块)。 + * + * @param {string} text + * @returns {number} + */ +export function countWords(text) { + if (!text || typeof text !== 'string') return 0; + const trimmed = text.trim(); + if (!trimmed) return 0; + const cjkRe = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu; + const cjkCount = (trimmed.match(cjkRe) || []).length; + const latinPart = trimmed.replace(cjkRe, ' '); + const latinCount = (latinPart.match(/\S+/g) || []) + .filter((token) => /[\p{L}\p{N}]/u.test(token)).length; + return cjkCount + latinCount; +} + +/** + * 统一统计:基于「源文档」字符串同时给出 chars/words/lines。 + * + * - chars = source.length(不含末尾 trimming;CM6 doc.toString() 不含末尾换行) + * - words = countWords(source)(CJK 单字 + 拉丁词) + * - lines = 非空行数(至少 1,纯空白内容 = 1 行) + * + * 用 source 而不是 bodyElement.textContent: + * 切预览 / 编辑时状态栏「N 行」不会跳变。textContent 是渲染后的纯文本, + * 与 markdown 源(特别是 HTML / 列表 / 代码块)数量上不同。 + * + * @param {string} source + * @returns {{chars:number, words:number, lines:number}} + */ +export function getTextStats(source) { + const text = typeof source === 'string' ? source : ''; + if (!text) return { chars: 0, words: 0, lines: 0 }; + // 非空行数:split 出所有行,去掉纯空白,再取长度。 + // 纯空白内容(只有 \n / 空格)→ 0 行,让 formatStatusStats 走「0 词」分支。 + // 之前用 `|| 1` 把「空」和「刚好 1 行内容」都映射成 1,新打开的空白文件 + // 状态栏立刻显示「1 行」误导用户以为已有内容(audit L8)。 + const lines = text.split('\n').filter((ln) => ln.trim().length > 0).length; + return { + chars: text.length, + words: countWords(text), + lines, + }; +} + +/** + * 渲染状态栏 chip 文案。 + * - 0 行 / 0 词时不显示(由 caller 决定) + * - 单文件被删或为空 → `${words} 词` + * - 正常 → `${words} 词 · ${lines} 行` + * + * @param {{words:number, lines:number}} stats + * @returns {string} + */ +export function formatStatusStats(stats) { + const words = (stats.words || 0).toLocaleString('zh-CN'); + const lines = stats.lines || 0; + return lines > 0 ? `${words} 词 · ${lines} 行` : `${words} 词`; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..b25feac --- /dev/null +++ b/src/styles.css @@ -0,0 +1,18 @@ +/* =========================================================================== + Notes — 样式入口(Stage 4a:分片) + 通过 @import 按顺序串联 01..12。 + 浏览器原生 CSS @import 性能成本可忽略(12 个文件并行或合并);本应用零打包。 + =========================================================================== */ + +@import url('./styles/01-tokens.css'); +@import url('./styles/02-palettes.css'); +@import url('./styles/03-base.css'); +@import url('./styles/04-toolbar.css'); +@import url('./styles/05-sidebar.css'); +@import url('./styles/06-markdown.css'); +@import url('./styles/07-statusbar.css'); +@import url('./styles/08-modal.css'); +@import url('./styles/09-misc.css'); +@import url('./styles/10-a11y.css'); +@import url('./styles/11-ai.css'); +@import url('./styles/12-print.css'); diff --git a/src/styles/01-tokens.css b/src/styles/01-tokens.css new file mode 100644 index 0000000..1e193dc --- /dev/null +++ b/src/styles/01-tokens.css @@ -0,0 +1,105 @@ +/* =========================================================================== + Design Tokens + 自动从 src/styles.css 拆分(Stage 4a) + =========================================================================== */ + +/* =========================================================================== + Notes — 全局样式 + 设计理念:现代简约 + 排版优先 + + - 设计令牌:间距、字号、颜色、阴影、动画曲线统一管理 + - 浅色 / 深色主题:暖色调低饱和度,长时间阅读舒适 + - 排版:行高 1.85、字号 16px、优化的中英文字体栈 + - 组件:浮动卡片、柔和阴影、克制的圆角 + =========================================================================== */ + +/* ---------- Design Tokens ---------- */ +:root { + /* 间距系统(基于 4 的倍数) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-9: 48px; + + /* 圆角 */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; /* 仅 .modal 消费 (styles.css:1892) */ + + /* 字号(基于 1.2 比例) + --fs-base 14 → 15:UI 文字比 markdown 正文小一档会看着发「瘦」; + 提到 15px 后配合 font-weight: 500 可读性显著提升,但只 +1px, + 不破坏现有 52px 工具栏 / 38px 状态栏的纵向布局 */ + --fs-xs: 11px; + --fs-xs-readable: 12px; /* 11px 在低 DPI/小屏幕上易"糊",settings 对话框内的 hint / 副标题用它 */ + --fs-sm: 13px; + --fs-base: 15px; + --fs-lg: 17px; + --fs-xl: 20px; + --fs-2xl: 24px; + --fs-4xl: 38px; + + /* 行高 */ + --lh-snug: 1.4; + --lh-normal: 1.55; + --lh-relaxed: 1.75; + --lh-loose: 1.85; + + /* 字距 */ + --tracking-tight: -0.01em; + --tracking-wide: 0.02em; + + /* 字体栈:英文优先 Inter,中文用系统字体保性能 */ + --font-sans: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + "Helvetica Neue", Helvetica, Arial, sans-serif; + + /* 阅读字体(用于 markdown 正文):CJK 优先 */ + --font-reading: + "Source Serif Pro", "Noto Serif CJK SC", "Source Han Serif SC", + "Songti SC", "STSong", -apple-system, BlinkMacSystemFont, + "Segoe UI", "PingFang SC", serif; + + /* 等宽字体:JetBrains Mono 优先,回落到系统等宽 */ + --font-mono: + "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, + "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + + /* 高度 */ + --h-toolbar: 52px; + --h-statusbar: 38px; + --w-sidebar: 220px; + --sidebar-width: var(--w-sidebar); + --w-sidebar-narrow: 200px; + --w-reader-max: 720px; + + /* 阅读排版:默认值与现有 --fs-lg / --lh-loose 同步; + 在 行内 style 上由 renderer 动态覆盖,切主题不会重置 */ + --reader-font-size: var(--fs-lg); + --reader-line-height: var(--lh-loose); + + /* Z-index */ + --z-sidebar: 5; + --z-toolbar: 10; + --z-statusbar: 10; + --z-modal: 1000; + --z-toast: 1100; + --z-boot-error: 1200; /* 比 toast 高一档,确保启动错误盖在所有内容之上 */ + + /* 过渡曲线 */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --transition-fast: 120ms var(--ease-out); + --transition-normal: 200ms var(--ease-out); + --transition-slow: 320ms var(--ease-out); + + /* 遮罩(modal / drawer / popover 背后的暗化层) —— 与主题正交, + light 主题用更轻的 alpha,否则会把 modal 卡片压成灰底破坏文字对比 */ + --scrim: rgba(0, 0, 0, 0.5); + --scrim-soft: rgba(0, 0, 0, 0.12); +} + diff --git a/src/styles/02-palettes.css b/src/styles/02-palettes.css new file mode 100644 index 0000000..94e86e8 --- /dev/null +++ b/src/styles/02-palettes.css @@ -0,0 +1,500 @@ +/* =========================================================================== + Palettes × Themes —— 收敛表达 + Stage 4a.2:用「主题骨架 + palette 差异补丁」替代原 10 块字符级复制 + + 设计思路: + 所有 palette × theme 共享同一组 token 表达(bg-hover / bg-active / 阴影 + / scrollbar / 边框等),但每个 palette 在每个 theme 下各自有自己的 + 视觉个性(accent 色、带色相的 bg / fg)。 + + 所以分两层: + 1. 主题骨架(深 / 浅):定义所有「主题相关但 palette 无感」的 token。 + 主题骨架只装「公共部分」,不装任何 palette 个性。 + 2. palette 差异补丁:每块只覆盖该 palette 的 accent 系列 + 带色相的 + bg/fg/text + markdown 配色 + toolbar-divider。其余 token 走主题骨架。 + + 选择器特异性: + - [data-theme="dark"] (0,1,1) + - [data-theme-palette="X"][data-theme="Y"] (0,2,0) + 后者更高,所以 palette 块能覆盖主题骨架。 + =========================================================================== */ + +/* ---------- Dark Theme Skeleton ---------- + 暗色通用 token —— 5 个 palette 的暗色版共享这些。 + palette 块只覆盖带色相部分(accent + 自身 bg/fg/md-*)。 +*/ +[data-theme="dark"] { + /* 背景交互态:所有暗色 palette 都用白叠加 */ + --bg-hover: rgba(255, 255, 255, 0.04); + --bg-active: rgba(255, 255, 255, 0.08); + + /* 边框:所有暗色 palette 浅边用白叠加、强边用更深的白 */ + --border-subtle: rgba(255, 255, 255, 0.04); + --border-strong: rgba(255, 255, 255, 0.12); + + /* 滚动条 */ + --scrollbar-thumb: rgba(255, 255, 255, 0.10); + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.20); + + /* 阴影:暗色下更深 */ + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.32), 0 2px 4px rgba(0, 0, 0, 0.16); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.42), 0 4px 8px rgba(0, 0, 0, 0.25); + /* --shadow-glow 由 palette 设(带 accent-soft) */ + + /* 语义色:所有暗色 palette 统一 + audit fix (H4/H5):--danger 由 #ff6b6b → #d23636(white-on-bg 对比从 2.2:1 + 提到 4.86:1,AA 通过)。btn-danger / error chip 都用这个 token,统一收敛。 */ + --danger: #d23636; + --btn-on-danger: #ffffff; + --warning: #ffb454; + --success: #5ad19a; + + /* Markdown 通用(不带色相):所有暗色 palette 都用白叠加 */ + --md-table-border: rgba(255, 255, 255, 0.08); + --md-table-stripe: rgba(255, 255, 255, 0.02); + --md-table-header-bg: rgba(255, 255, 255, 0.04); + --md-hr: rgba(255, 255, 255, 0.08); + /* --md-blockquote-* / --md-inline-code-bg / --md-code-bg / --md-link 由 palette 设 */ + --md-heading: var(--fg-primary); + + /* ── 文字版语义色 ─────────────────────────────────────────────── + 用途:状态栏 chip 字、AI 错误小字、表单 hint、设置卡 selected label + 等 11-14px 的有色正文。装饰色(warning / danger / success / accent) + 为图标、边框、圆点、单字符(AI diff +/-)设计,直接套到正文字符上 + 会因高彩度小字"振动"看着累。这里取 60-65% 装饰色 + 35-40% + fg-primary 调和:色相仍可识别(暖橙 / 红 / 绿 / 蓝调),但 chroma + 降到不会刺眼的程度。 + 比例选择: + dark 用 60% 装饰色 —— 深底 fg-primary 偏白,混出"暖白"效果。 + light 用 70% 装饰色 —— 浅底 fg-primary 偏黑,需要更高的色比保证 + 文字可见度,70% 仍比 100% 纯色温和一档。 + 边界:borders / dots / icons / 单字符 +/- 继续用纯 *(不退色), + 详见各组件 CSS 注释。 */ + --warning-text: color-mix(in srgb, var(--warning) 60%, var(--fg-primary)); + --danger-text: color-mix(in srgb, var(--danger) 60%, var(--fg-primary)); + --accent-text: color-mix(in srgb, var(--accent) 65%, var(--fg-primary)); +} + +/* ---------- Light Theme Skeleton ---------- */ +[data-theme="light"] { + --bg-hover: rgba(0, 0, 0, 0.04); + --bg-active: rgba(0, 0, 0, 0.07); + + --border-subtle: rgba(0, 0, 0, 0.04); + --border-strong: rgba(0, 0, 0, 0.14); + + --scrollbar-thumb: rgba(0, 0, 0, 0.14); + --scrollbar-thumb-hover: rgba(0, 0, 0, 0.26); + + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.035), 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.035); + --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.09), 0 4px 12px rgba(0, 0, 0, 0.05); + /* --shadow-glow 由 palette 设(带 accent-soft) */ + + /* audit fix (H4/H5):light --danger 由 #e54848 → #c52424(white-on-bg 对比从 + 3.93:1 提到 5.84:1,AA 通过)。--warning / --success 本身对比足够,未动。 */ + --danger: #c52424; + --btn-on-danger: #ffffff; + --warning: #d97706; + --success: #10b981; + + --md-table-border: rgba(0, 0, 0, 0.08); + --md-table-stripe: rgba(0, 0, 0, 0.02); + --md-table-header-bg: rgba(0, 0, 0, 0.03); + --md-hr: rgba(0, 0, 0, 0.08); + --md-heading: var(--fg-primary); + + /* 文字版语义色(与 dark 段对应注释)。light 底 fg-primary 偏黑, + 用 70% 装饰色让色相仍清晰,但比纯色温和:避免红字"扎眼"、橙字"泛酸"。 */ + --warning-text: color-mix(in srgb, var(--warning) 70%, var(--fg-primary)); + --danger-text: color-mix(in srgb, var(--danger) 70%, var(--fg-primary)); + --accent-text: color-mix(in srgb, var(--accent) 70%, var(--fg-primary)); +} + +/* =========================================================================== + Palette overrides —— 每个块只覆盖该 palette 个性化的 token + =========================================================================== */ + +/* ---------- Default (Linear 风格:近黑中性底 + indigo) ---------- + 兜底:body 上没有 data-theme-palette 时也应用 default(兼容未挂属性场景)。 */ +body:not([data-theme-palette]), +[data-theme-palette="default"][data-theme="dark"] { + --bg-app: #08090a; + --bg-toolbar: rgba(8, 9, 10, 0.85); + --bg-sidebar: rgba(255, 255, 255, 0.022); + --bg-viewer: #08090a; + --bg-elevated: #16171a; + --bg-card: #101113; + --bg-card-hover: #191a1d; + + --fg-primary: #f7f8f8; + --fg-secondary: #b0b6c0; + --fg-tertiary: #8e95a1; + --fg-muted: #6c7178; + + --border-default: rgba(255, 255, 255, 0.06); + + /* audit fix (H4/H5):--accent 由 #7883e0 → #4f5ec7(white-on-bg 对比从 3.46:1 + 提到 5.6:1,AA 通过)。其它 indigo 调色板都是浅 accent 配深色字(--btn-on-accent + = #1a232e),已经满足 AA,无需调。 */ + --accent: #4f5ec7; + --btn-on-accent: #ffffff; + --accent-hover: #6473d3; + --accent-soft: rgba(79, 94, 199, 0.22); + --accent-soft-hover: rgba(79, 94, 199, 0.28); + --accent-selection: rgba(79, 94, 199, 0.50); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #101113; + --md-code-text: #e6e9f1; + --md-inline-code-bg: rgba(120, 131, 224, 0.20); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + /* indigo 在近黑底只有 4.2:1,链接调亮一档(2026-08 进一步提亮到 ~7:1) */ + --md-link: #a3aaef; + --md-link-hover: #b8bef4; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(255, 255, 255, 0.08) 50%, transparent 100%); +} + +[data-theme-palette="default"][data-theme="light"] { + --bg-app: #fbfbfc; + --bg-toolbar: rgba(255, 255, 255, 0.85); + --bg-sidebar: #f4f5f8; + --bg-viewer: #fbfbfc; + --bg-elevated: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #f7f8f9; + + --fg-primary: #282a30; + --fg-secondary: #525968; + --fg-tertiary: #676e7d; + --fg-muted: #767b83; + + --border-default: rgba(0, 0, 0, 0.07); + + --accent: #5e6ad2; + --btn-on-accent: #ffffff; + --accent-hover: #4d58bd; + --accent-soft: rgba(94, 106, 210, 0.12); + --accent-soft-hover: rgba(94, 106, 210, 0.16); + --accent-selection: rgba(94, 106, 210, 0.28); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #f4f5f8; + --md-code-text: #282a30; + --md-inline-code-bg: rgba(94, 106, 210, 0.10); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + /* light 主题的 --md-link 用比 accent 更深的色:modal 卡片底色浅(#eceefa / #e2eff5 / #e6f1ea / #faeae7), + 直接用 accent 只剩 3.5-4.0:1,不达 WCAG AA(4.5:1)。这里给每个 light 调色板独立加深。 */ + --md-link: #4244a8; + --md-link-hover: #2f3082; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(0, 0, 0, 0.08) 50%, transparent 100%); +} + +/* ---------- Ocean (深海军 + 青绿) ---------- */ +[data-theme-palette="ocean"][data-theme="dark"] { + --bg-app: #0b141d; + --bg-toolbar: rgba(11, 20, 29, 0.85); + --bg-sidebar: rgba(180, 210, 235, 0.04); + --bg-viewer: #0b141d; + --bg-elevated: #131e2a; + --bg-card: #16212e; + --bg-card-hover: #1c2837; + + --fg-primary: #e3ecf3; + --fg-secondary: #c4d2dc; + --fg-tertiary: #96b1c6; + --fg-muted: #6c8090; + + --border-default: rgba(180, 210, 235, 0.08); + + --accent: #6cc5e6; + --btn-on-accent: #1a232e; + --accent-hover: #85d2ee; + --accent-soft: rgba(108, 197, 230, 0.20); + --accent-soft-hover: rgba(108, 197, 230, 0.26); + --accent-selection: rgba(108, 197, 230, 0.48); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #111923; + --md-code-text: #cbd6e0; + --md-inline-code-bg: rgba(108, 197, 230, 0.18); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #85d2ee; + --md-link-hover: #9cdcf2; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(180, 210, 235, 0.08) 50%, transparent 100%); +} + +[data-theme-palette="ocean"][data-theme="light"] { + --bg-app: #eef4f8; + --bg-toolbar: rgba(255, 255, 255, 0.85); + --bg-sidebar: #e5eef5; + --bg-viewer: #eef4f8; + --bg-elevated: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #eef4f8; + + --fg-primary: #0f1d2a; + --fg-secondary: #34465a; + --fg-tertiary: #4f697a; + --fg-muted: #6a7d8d; + + --border-default: rgba(15, 40, 70, 0.08); + + --accent: #0e7da8; + --btn-on-accent: #ffffff; + --accent-hover: #0a688d; + --accent-soft: rgba(14, 125, 168, 0.12); + --accent-soft-hover: rgba(14, 125, 168, 0.16); + --accent-selection: rgba(14, 125, 168, 0.30); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #e7eef4; + --md-code-text: #0f1d2a; + --md-inline-code-bg: rgba(14, 125, 168, 0.08); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #084060; + --md-link-hover: #05304a; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(15, 40, 70, 0.08) 50%, transparent 100%); +} + +/* ---------- Forest (松黑 + 翠玉) ---------- */ +[data-theme-palette="forest"][data-theme="dark"] { + --bg-app: #0e1410; + --bg-toolbar: rgba(14, 20, 16, 0.85); + --bg-sidebar: rgba(180, 210, 190, 0.04); + --bg-viewer: #0e1410; + --bg-elevated: #16201a; + --bg-card: #19251e; + --bg-card-hover: #1f2d26; + + --fg-primary: #e6efe8; + --fg-secondary: #c4d4ca; + --fg-tertiary: #9eb3a4; + --fg-muted: #6c7d72; + + --border-default: rgba(180, 210, 190, 0.08); + + --accent: #76c78d; + --btn-on-accent: #1a232e; + --accent-hover: #8dd4a3; + --accent-soft: rgba(118, 199, 141, 0.20); + --accent-soft-hover: rgba(118, 199, 141, 0.26); + --accent-selection: rgba(118, 199, 141, 0.48); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #141c16; + --md-code-text: #cad7ce; + --md-inline-code-bg: rgba(118, 199, 141, 0.18); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #8dd4a3; + --md-link-hover: #a4e0b9; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(180, 210, 190, 0.08) 50%, transparent 100%); +} + +[data-theme-palette="forest"][data-theme="light"] { + --bg-app: #f0f5f1; + --bg-toolbar: rgba(255, 255, 255, 0.85); + --bg-sidebar: #e6efe9; + --bg-viewer: #f0f5f1; + --bg-elevated: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #f0f5f1; + + --fg-primary: #102018; + --fg-secondary: #34483c; + --fg-tertiary: #4f6356; + --fg-muted: #6d7e73; + + --border-default: rgba(20, 55, 30, 0.08); + + --accent: #2c8c52; + --btn-on-accent: #000000; + --accent-hover: #227544; + --accent-soft: rgba(44, 140, 82, 0.12); + --accent-soft-hover: rgba(44, 140, 82, 0.16); + --accent-selection: rgba(44, 140, 82, 0.30); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #e8efe9; + --md-code-text: #102018; + --md-inline-code-bg: rgba(44, 140, 82, 0.08); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #1a5a30; + --md-link-hover: #0f4220; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(20, 55, 30, 0.08) 50%, transparent 100%); +} + +/* ---------- Lavender (茄紫 + 粉紫) ---------- */ +[data-theme-palette="lavender"][data-theme="dark"] { + --bg-app: #14101a; + --bg-toolbar: rgba(20, 16, 26, 0.85); + --bg-sidebar: rgba(200, 180, 220, 0.04); + --bg-viewer: #14101a; + --bg-elevated: #1d1726; + --bg-card: #211a2c; + --bg-card-hover: #282034; + + --fg-primary: #ece6f2; + --fg-secondary: #d0c5dc; + --fg-tertiary: #a99cc0; + --fg-muted: #76688c; + + --border-default: rgba(200, 180, 220, 0.08); + + --accent: #c89ce8; + --btn-on-accent: #1a232e; + --accent-hover: #d4abee; + --accent-soft: rgba(200, 156, 232, 0.20); + --accent-soft-hover: rgba(200, 156, 232, 0.26); + --accent-selection: rgba(200, 156, 232, 0.48); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #1a1423; + --md-code-text: #d3cce0; + --md-inline-code-bg: rgba(200, 156, 232, 0.18); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #d4abee; + --md-link-hover: #e0baf3; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(200, 180, 220, 0.08) 50%, transparent 100%); +} + +[data-theme-palette="lavender"][data-theme="light"] { + --bg-app: #f5f0fa; + --bg-toolbar: rgba(255, 255, 255, 0.85); + --bg-sidebar: #ebe4f3; + --bg-viewer: #f5f0fa; + --bg-elevated: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #f5f0fa; + + --fg-primary: #1a1228; + --fg-secondary: #3d3252; + --fg-tertiary: #5d4f7a; + --fg-muted: #6c5f86; + + --border-default: rgba(60, 30, 90, 0.08); + + --accent: #7c3aaf; + --btn-on-accent: #ffffff; + --accent-hover: #6a2f95; + --accent-soft: rgba(124, 58, 175, 0.12); + --accent-soft-hover: rgba(124, 58, 175, 0.16); + --accent-selection: rgba(124, 58, 175, 0.30); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #ebe2f2; + --md-code-text: #1a1228; + --md-inline-code-bg: rgba(124, 58, 175, 0.08); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #5e1f8a; + --md-link-hover: #421560; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(60, 30, 90, 0.08) 50%, transparent 100%); +} + +/* ---------- Sunset (酒红 + 桃粉) ---------- */ +[data-theme-palette="sunset"][data-theme="dark"] { + --bg-app: #1a0e14; + --bg-toolbar: rgba(26, 14, 20, 0.85); + --bg-sidebar: rgba(220, 170, 180, 0.04); + --bg-viewer: #1a0e14; + --bg-elevated: #261520; + --bg-card: #2c1a26; + --bg-card-hover: #34212e; + + --fg-primary: #f4e6ea; + --fg-secondary: #d8c5cc; + --fg-tertiary: #b598a8; + --fg-muted: #806878; + + --border-default: rgba(220, 170, 180, 0.08); + + --accent: #ffa091; + --btn-on-accent: #1a232e; + --accent-hover: #ffb1a4; + --accent-soft: rgba(255, 160, 145, 0.20); + --accent-soft-hover: rgba(255, 160, 145, 0.26); + --accent-selection: rgba(255, 160, 145, 0.48); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #20131a; + --md-code-text: #dccdd2; + --md-inline-code-bg: rgba(255, 160, 145, 0.18); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #ffb1a4; + --md-link-hover: #ffc2b6; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(220, 170, 180, 0.08) 50%, transparent 100%); +} + +[data-theme-palette="sunset"][data-theme="light"] { + --bg-app: #fbf2ee; + --bg-toolbar: rgba(255, 255, 255, 0.85); + --bg-sidebar: #f3e6e1; + --bg-viewer: #fbf2ee; + --bg-elevated: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #fbf2ee; + + --fg-primary: #2a121a; + --fg-secondary: #48303a; + --fg-tertiary: #6b4d58; + --fg-muted: #7b636c; + + --border-default: rgba(80, 30, 40, 0.08); + + --accent: #d94f3a; + --btn-on-accent: #000000; + --accent-hover: #bf3f2d; + --accent-soft: rgba(217, 79, 58, 0.12); + --accent-soft-hover: rgba(217, 79, 58, 0.16); + --accent-selection: rgba(217, 79, 58, 0.30); + + --shadow-glow: 0 0 0 3px var(--accent-soft); + + --md-code-bg: #f1e3dd; + --md-code-text: #2a121a; + --md-inline-code-bg: rgba(217, 79, 58, 0.08); + --md-blockquote-border: var(--accent); + --md-blockquote-bg: var(--accent-soft); + --md-link: #8e1f10; + --md-link-hover: #621208; + + --toolbar-divider: linear-gradient(90deg, + transparent 0%, rgba(80, 30, 40, 0.08) 50%, transparent 100%); +} diff --git a/src/styles/03-base.css b/src/styles/03-base.css new file mode 100644 index 0000000..9ad99da --- /dev/null +++ b/src/styles/03-base.css @@ -0,0 +1,159 @@ +/* =========================================================================== + Reset + Focus Visibility + 自动从 src/styles.css 拆分(Stage 4a) + =========================================================================== */ + +/* ---------- Reset ---------- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + width: 100%; + overflow: hidden; + -webkit-text-size-adjust: 100%; +} + +/* + 长文件名截断(fix:长文件名导致 toolbar 偏移): + - html/body 是顶层容器,需要 min-width:0 防止内部 grid item 的 min-content + 把整个 grid 撑得比视口宽(虽然 .toolbar 自己已加 min-width:0,但 body grid + 在 grid-template-columns 未指定的情况下默认按 max-content 自适应,多加 + 一层防御能避免任何子 grid item 把 body 撑出去) +*/ +html, body { min-width: 0; } + +body { + font-family: var(--font-sans); + font-size: var(--fs-base); + line-height: var(--lh-normal); + color: var(--fg-primary); + background: var(--bg-app); + /* font-weight: 500 全局提升,让 UI 文字(statusbar / file-meta / 空状态等 + 没有显式 font-weight 的元素)默认就更厚重, + 解决「看着发淡」的问题。Markdown body 等显式 400 的不受影响 */ + font-weight: 500; + /* 字体平滑策略:默认开启 ClearType(subpixel-antialiased), + 在 Windows LCD 上比 grayscale 抗锯齿更锐利、字形更黑。 + 旧值 antialiased 在 Chromium 上会强制关闭 ClearType,反而让字变细变糊。 + Retina / 高 DPI 屏幕下用下方媒体查询切回 antialiased 避免色边 */ + -webkit-font-smoothing: subpixel-antialiased; + -moz-osx-font-smoothing: auto; + text-rendering: optimizeLegibility; + font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "cv11" 1; + /* 让浏览器能在字体缺少对应字重时合成粗体(如 system Segoe UI 落到 600), + 避免某些 weight 实际渲染为 400 看着发淡 */ + font-synthesis: weight; + user-select: none; + display: grid; + /* 四行:顶部工具栏 / 主区 / AI 对话 dock / 状态栏;fr 单位随窗口缩放自适应。 + AI dock 默认 hidden(auto 高度 = 0);展开时挤占 app-shell 高度, + 状态栏始终钉在最底。 */ + grid-template-rows: var(--h-toolbar) 1fr auto var(--h-statusbar); + /* + 长文件名截断(fix:长文件名导致 toolbar 偏移): + - 单列 grid 显式声明 minmax(0, 1fr) 防止任何 grid item 的 min-content + 把整列撑得比视口宽(虽然 .toolbar 自己已加 min-width:0,但显式 1fr + 让浏览器明确按可用空间分配,防御性更好) + */ + grid-template-columns: minmax(0, 1fr); + /* 防止内容溢出导致整个 body 高度超过视口,破坏 grid */ + height: 100vh; + overflow: hidden; + /* 切主题时让背景/前景色平滑过渡(避免一帧硬切) */ + transition: background var(--transition-slow), color var(--transition-slow); +} + +/* 高 DPI / Retina 屏幕:关闭 subpixel,否则字形边缘出现红蓝色彩边看着脏。 + 非高 DPI 屏幕(普通 LCD)保留 subpixel,字形更黑更锐利 */ +@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { + body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } +} + +/* 把四个「chrome」元素显式钉到对应 grid 行 —— 防御性。 + 即使 DOM 里在它们之前有其他 grid 子元素(如 .focus-hotzone 在某些路径下 + 被加进 grid 流),也不会错位。 + .ai-chat-dock 默认 hidden;展开时占据第 3 行(auto),状态栏被推到第 4 行。 */ +.toolbar { grid-row: 1; } +.app-shell { grid-row: 2; } +.ai-chat-dock { grid-row: 3; } +.statusbar { grid-row: 4; } + +/* [hidden] 必须真正生效: + 浏览器 UA 默认 [hidden] { display: none } 与 .status-chip { display: inline-flex } + 同特异性 (0,1,0),用户样式后写胜出 → 隐藏时仍占位、推挤布局。 + 状态栏的 status-stats / status-heading 一旦被设 [hidden] 还会拉伸 17.5px, + 把右侧 chip 簇向右推;其他模块也有类似问题。统一加 !important 兜底。 */ +[hidden] { + display: none !important; +} + +/* ---------- 键盘焦点可见性 ---------- + - 鼠标点击不应画外圈:浏览器原生行为 (Chromium / WebKit 已支持 :focus-visible) + - 键盘 Tab 走到哪儿都该看到一个 2px accent 环 + - 不破坏现有「假轮廓」元素(.search-input / .form-input):它们各自有 + :not(:focus-visible) 块把鼠标态的 box-shadow 收回,外圈再叠加 +*/ +:where(a, button, [role="option"], [tabindex], input, select, textarea, summary, label):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: inherit; +} +/* 列表项的 outline 不需要 offset,否则会贴住左侧色条 */ +.file-item:focus-visible { + outline-offset: -2px; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +a { + /* 全局链接默认色:用 --accent-text(65% 装饰色 + 35% fg-primary)。 + 之前纯 --accent 在 13-14px 链接上"扎眼":锚点链接有时是 4-5 字短词, + 在一片中性正文里像贴了荧光贴纸。markdown-body 自己的链接走 --md-link + (更亮一档),全局默认行内/工具栏链接走这一档,视觉权重一致 */ + color: var(--accent-text); + text-decoration: none; + transition: color var(--transition-fast); +} +a:hover { + /* hover 提亮一档:从文字版(--accent-text)→ 纯 --accent,给出"可点"的明确反馈 */ + color: var(--accent); +} + +input { + font: inherit; + color: inherit; +} + +ul { + list-style: none; +} + +code, pre, kbd { + font-family: var(--font-mono); + user-select: text; +} + +::selection { + /* 用 --accent-selection(alpha 0.28-0.50)而非 --accent-soft(0.12-0.22): + 旧值在亮色主题上 alpha 仅 0.12,选区看起来像没选中,新建笔记标题、 + 重命名、设置输入框、AI dock 等所有 input 的文字选中态都受影响。 + CM 编辑器早就走 --accent-selection(见 src/editor-theme.js),全局规则 + 跟它对齐,保证 input 内 / 编辑器内 / Markdown viewer 任意位置选中态一致 */ + background: var(--accent-selection); + color: var(--fg-primary); +} + diff --git a/src/styles/04-toolbar.css b/src/styles/04-toolbar.css new file mode 100644 index 0000000..31e1ccd --- /dev/null +++ b/src/styles/04-toolbar.css @@ -0,0 +1,565 @@ +/* =========================================================================== + Toolbar + App Shell + 自动从 src/styles.css 拆分(Stage 4a) + =========================================================================== */ + +/* ============================================================ + Toolbar (custom titlebar) + ============================================================ */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--h-toolbar); + /* 右侧用更小的内边距,避免在默认 1200px 窗口下按钮组被裁; + 顶部标题区仍保留 --space-5 的呼吸感 */ + padding: 0 4px 0 var(--space-5); + background: var(--bg-toolbar); + /* audit fix (P2):去掉 backdrop-filter —— toolbar 在 bg-toolbar 实色之上, + 毛玻璃的 saturate(180%) blur(20px) 强制把整条 1200×40 px 区域放到独立 + compositor layer,且每帧滚动主内容时 GPU 都要重算这条带覆盖区域的模糊。 + 视觉上完全无差异(实色背景上看不出毛玻璃),但每次滚动都是几十 KB 像素的 + 重复 GPU 工作。 */ + border-bottom: 1px solid var(--border-subtle); + z-index: var(--z-toolbar); + position: relative; + /* + 长文件名截断(fix:长文件名导致 toolbar 偏移): + - toolbar 是 body grid 子元素,min-width 默认是 auto = min-content。 + .app-title-text 的 white-space:nowrap + 长文本让 min-content 极大, + toolbar 默认会撑到比视口还宽,把 .toolbar-right(关闭等按钮)挤出视口。 + - min-width:0 覆盖默认 min-content,让 toolbar 接受 body grid 给它的宽度 + - overflow:hidden 兜底:万一有其他内容溢出,clip 在 toolbar 内部而不是溢出到 body + (body 也是 overflow:hidden,但那是最后一道防线,视觉上能看到 toolbar 自身的偏移) + */ + min-width: 0; + overflow: hidden; +} + +.toolbar::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: var(--toolbar-divider); +} + +.drag-region { + -webkit-app-region: drag; + flex: 1; + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; + height: 100%; + padding-left: 2px; +} + +.no-drag { + -webkit-app-region: no-drag; + display: flex; + align-items: center; + gap: var(--space-1); +} + +/* 工具栏分组:将同一类别的按钮聚拢(视觉相邻),组间用 divider 隔开 */ +.toolbar-group { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.toolbar-divider { + display: inline-block; + width: 1px; + height: 18px; + /* 用半透明白色渐变,比纯色 border 更精致;亮主题用对应浅色 */ + background: linear-gradient(180deg, + transparent 0%, + var(--border-default) 25%, + var(--border-default) 75%, + transparent 100%); + /* 从 6px 减到 5px:默认 1200px 窗口下 6 个分组 + 7 个 divider 累计 84px gap, + 改 5px 后 70px,让工具栏横向更紧凑 */ + margin: 0 5px; + flex-shrink: 0; +} + +.app-title { + font-size: var(--fs-base); + font-weight: 600; + color: var(--fg-primary); + letter-spacing: var(--tracking-wide); + display: flex; + align-items: center; + gap: 10px; + /* 选中态:避免拖拽时被高亮 */ + user-select: none; + /* + 长文件名截断(fix:长文件名导致 toolbar 偏移): + - min-width:0 是关键 —— flex 容器默认 min-content 是整段文本, + 没有它 .app-title 会拒绝收缩,把 .toolbar-right(按钮组)挤出视口右侧 + - overflow:hidden 让超出部分不可见,配合 .app-title-text 的 ellipsis 截断 + */ + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +/* + 长文件名截断(fix:长文件名导致 toolbar 偏移): + - 真正承载文本的子 span —— 直接文本节点在 flex 容器里无法触发 ellipsis + - min-width:0 + flex:1 1 auto 让它优先吃光可用空间,超出才走截断 + - text-overflow:ellipsis + white-space:nowrap 让长文件名从右侧省略 +*/ +.app-title-text { + display: block; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-title::before { + content: ""; + width: 20px; + height: 20px; + border-radius: 6px; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.18), + 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + /* 固定图标尺寸,不参与收缩(flex-shrink:0) */ + flex: 0 0 auto; +} + +/* 应用图标(来自 icon.ico 的 PNG data URL): + - 20×20 + 6px 圆角,与 .app-title::before 占位形状一致,替换它而不是叠加 + - draggable="false" 防选中后拖出浏览器(默认 选中状态可拖) + - 与 .app-title 共享同一行(display:flex 在 .toolbar-left) */ +.app-icon { + width: 20px; + height: 20px; + border-radius: 6px; + flex: 0 0 auto; + /* 与 ::before 占位的多层 box-shadow 对齐,亮 / 暗主题下都不漂浮 */ + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.18), + 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +/* 真图标加载成功 → 隐藏 .app-title::before 占位,避免双图标 */ +body.has-app-icon .app-title::before { + display: none; +} + +/* Toolbar 按钮:图标按钮 + 悬浮效果 + 默认色用 secondary(暗色 ~8.5:1, 亮色 ~8:1), + 旧值 secondary 在暗色仅 ~5.5:1,加上 13px 字号看着发灰,2026-08 统一提亮 */ +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + color: var(--fg-secondary); + /* 加 transition 阴影和位移,让 hover 反馈更细腻 */ + transition: + background var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.btn-icon:hover { + background: var(--bg-hover); + color: var(--fg-primary); +} + +.btn-icon:active { + /* scale 从 0.94 减到 0.96:原值 0.94 在 34px 按钮上缩 2px, + 视觉跳动明显;0.96 只缩 1px,反馈更克制 */ + background: var(--bg-active); + transform: scale(0.96); +} + +.btn-icon.is-active { + background: var(--accent-soft); + color: var(--accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); +} + +.btn-icon.is-active:hover { + background: var(--accent-soft-hover); +} + +.window-controls { + display: flex; + align-items: center; + margin-left: 4px; + /* 让最右侧的关闭按钮贴齐视口右沿(沿用平台常见 UX),但避免裁掉 hover 区; + 之前用 -8px 时会把按钮裁进视口外 8px。改为 -2px 把贴边视觉压回视口内 */ + margin-right: -2px; + gap: 0; + /* 用渐变分隔线替代纯色 —— 比 border 精致,符合 macOS 视觉 */ + position: relative; +} +.window-controls::before { + content: ""; + position: absolute; + left: -8px; + top: 50%; + transform: translateY(-50%); + height: 20px; + width: 1px; + background: linear-gradient(180deg, + transparent 0%, + var(--border-default) 25%, + var(--border-default) 75%, + transparent 100%); +} + +.btn-window { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + /* 同 .btn-icon:用 secondary 替代 tertiary,避免暗色下窗口控制按钮发灰 */ + color: var(--fg-secondary); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.btn-window:hover { + background: var(--bg-hover); + color: var(--fg-primary); +} + +.btn-window.btn-close:hover { + background: var(--danger); + color: var(--btn-on-danger, #ffffff); +} + +/* 工具栏按钮组(视图模式三连按钮) */ +.btn-group { + display: inline-flex; + align-items: center; + /* 按钮组之间的间距由 .toolbar-divider 提供,这里去掉额外 margin-left */ + margin-left: 0; + gap: 1px; + /* padding: 2px + 按钮 30px = 34px,与外层独立 .btn-icon 同高,保证视觉对齐 */ + padding: 2px; + /* 容器底色比 hover 更轻,避免和单个按钮 hover 态重叠太深 */ + background: color-mix(in srgb, var(--bg-hover) 70%, transparent); + border-radius: var(--radius-sm); + box-shadow: inset 0 0 0 1px var(--border-subtle); +} + +.btn-group .btn-icon { + width: 30px; + height: 30px; + border-radius: 5px; +} + +.btn-group .btn-icon:hover:not(.is-active) { + background: var(--bg-hover); +} + +.btn-group .btn-icon.is-active { + background: var(--bg-elevated); + color: var(--accent); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.12), + inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); +} + +/* 图标 + 文字按钮(视图模式专用): + 让核心交互(预览/编辑/双栏)一眼可识别, + 同时保留 segmented control 的紧凑感。 + 12px 字号 + secondary(暗色 ~8.5:1)才能保证文字不糊; + 2026-08 把 secondary 从 ~5.5:1 提到 ~8.5:1 后视图模式按钮显著更清晰 */ +.btn-icon-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 30px; + padding: 0 10px; + border-radius: 5px; + color: var(--fg-secondary); + font-size: var(--fs-xs-readable); + font-weight: 500; + letter-spacing: var(--tracking-wide); + transition: + background var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); + white-space: nowrap; +} + +.btn-icon-text:hover:not(.is-active) { + background: var(--bg-hover); +} + +.btn-icon-text:active { + transform: scale(0.96); +} + +.btn-icon-text .btn-label { + line-height: 1; + /* 与按钮对齐基线 */ +} + +.btn-icon-text.is-active { + background: var(--bg-elevated); + /* 文字版(--accent-text):view mode "预览 / 编辑 / 双栏"、autosave "自动保存" + 这种带字按钮 active 时若用纯 accent,"预览" "自动保存" 在工具栏里整片 + accent 短词同时亮起来会让眼睛辛苦。降一档后 bg-elevated + inset 1px + accent 边框仍清楚表达"按下",文字不再贴脸 */ + color: var(--accent-text); + font-weight: 600; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.12), + inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); +} + +/* (audit fix Round 9: .toolbar-status-dot / .btn-icon-text > .toolbar-status-dot + 死 CSS —— 保存按钮改 is-dirty 状态后该指示点彻底弃用,见 + feedback-save-button-always-visible.md。iOS pill switch 替代品见下) */ + +/* iOS pill switch —— 替代 .toolbar-status-dot 作为开关语义。 + 26×14px 嵌入 .btn-icon-text,与现有 24px 按钮中线对齐。 + 视觉态由 [aria-pressed="true|false"] 选择器驱动,与项目 + 「aria-pressed 表示开关态」的模式一致(参考 btn-theme)。 + 保留 .btn-icon-text 外壳让按钮仍享有 hover/active 视觉反馈。 */ +.toolbar-toggle .toggle-switch { + position: relative; + display: inline-block; + width: 26px; + height: 14px; + margin-left: 4px; + flex: 0 0 auto; +} +.toolbar-toggle .toggle-switch-track { + position: absolute; + inset: 0; + background: var(--fg-muted); + opacity: 0.5; + border-radius: 7px; + transition: background var(--transition-fast), opacity var(--transition-fast); +} +.toolbar-toggle .toggle-switch-thumb { + position: absolute; + top: 1px; + left: 1px; + width: 12px; + height: 12px; + background: var(--bg-elevated); + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); + transition: transform var(--transition-fast); +} +.toolbar-toggle[aria-pressed="true"] .toggle-switch-track { + background: var(--accent); + opacity: 1; +} +.toolbar-toggle[aria-pressed="true"] .toggle-switch-thumb { + transform: translateX(12px); +} +/* hover 微微提亮,与其它 btn-icon-text hover 一致 */ +.toolbar-toggle:hover .toggle-switch-track { opacity: 0.85; } +.toolbar-toggle[aria-pressed="true"]:hover .toggle-switch-track { opacity: 1; } +/* 关态时整体暗化,与工具栏 fg-tertiary 一致,强调「关闭」语义 */ +.toolbar-toggle[aria-pressed="false"] { + color: var(--fg-tertiary); +} +/* a11y:prefers-reduced-motion 下关闭 transform 过渡 */ +@media (prefers-reduced-motion: reduce) { + .toolbar-toggle .toggle-switch-track, + .toolbar-toggle .toggle-switch-thumb { transition: none; } +} + +.btn-icon.is-dirty, +.btn-icon-text.is-dirty { + /* 脏状态:使用 warning 色作为视觉提示,并轻微脉动 */ + color: var(--warning); + background: color-mix(in srgb, var(--warning) 12%, transparent); + animation: dirty-pulse 1.8s ease-in-out infinite; +} + +.btn-icon.is-dirty:hover, +.btn-icon-text.is-dirty:hover { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 18%, transparent); +} + +/* 保存中:与 is-dirty 的 warning 区分,用 fg-secondary 中性色 + 微微透明度。 + cursor: progress 给鼠标用户明确提示(参见 [[feedback-save-button-always-visible]] + ——按钮始终可见,这里只是临时视觉变化,不切换 hidden)。 */ +.btn-icon.is-saving, +.btn-icon-text.is-saving { + cursor: progress; + opacity: 0.6; + color: var(--fg-secondary); + background: var(--bg-hover); + /* 关掉 dirty-pulse 动画:避免 is-saving + is-dirty 叠加造成闪烁 */ + animation: none; +} +/* a11y:prefers-reduced-motion 下保持稳定视觉,不透明度也保留 */ +@media (prefers-reduced-motion: reduce) { + .btn-icon.is-saving, + .btn-icon-text.is-saving { + opacity: 0.7; + cursor: progress; + } +} + +/* 主题按钮图标:按当前主题显示"代表当前状态"的那一态—— + 暗色主题下显示月亮,亮色主题下显示太阳(点击图标会切到对侧主题)。 + 两个 SVG 同时存在于 DOM,靠 [data-theme] 切换可见性,免 JS 维护。 */ +#btn-theme .theme-icon { + display: none; +} + +body[data-theme="dark"] #btn-theme .theme-icon-moon, +body[data-theme="light"] #btn-theme .theme-icon-sun { + display: block; +} + +/* (audit fix Round 9: .toolbar-status-dot 死 CSS,详见 04-toolbar.css 顶部注释) */ + +/* dirty-pulse:脉动从 1 → 0.65 太深,warning 色按钮在 50% 帧会瞬间 + 看着像"warning 没了"。改成 1 → 0.78:仍能看出脉动,但始终保持 warning + 视觉在场,"未保存"语义不中断。1.8s 节奏不变 */ +@keyframes dirty-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.78; } +} + +/* 工具栏图标按钮的 disabled 态(如非 md 文件下 preview/split 按钮) + 复用 .context-menu-item 的禁用色(fg-tertiary + opacity),保持一致 */ +.btn-icon:disabled, +.btn-icon[disabled] { + color: var(--fg-tertiary); + cursor: not-allowed; + opacity: 0.4; + pointer-events: none; +} + +/* 图标+文字按钮(视图模式 预览/编辑/双栏)的 disabled 态。 + 非 Markdown 文件打开时 preview/split 按钮会被禁用 —— 这里让灰态视觉一致 */ +.btn-icon-text:disabled, +.btn-icon-text[disabled] { + color: var(--fg-tertiary); + cursor: not-allowed; + opacity: 0.4; + pointer-events: none; +} + +/* 窄窗口(< 820px):toolbar 文字标签隐藏,只保留图标。 + 与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌 */ +@media (max-width: 820px) { + .btn-icon-text .btn-label { + display: none; + } + .btn-icon-text { + /* 没文字时收紧 padding,让按钮保持图标按钮的紧凑感 */ + padding: 0 6px; + } +} + +/* ============================================================ + App Shell + ============================================================ */ +.app-shell { + display: grid; + /* 侧栏宽度走 --sidebar-width(运行时可拖拽调整),默认值来自 --w-sidebar; + 这样改 token 不必同时改 grid 模板,也避免与窄屏媒体查询耦合 */ + grid-template-columns: var(--sidebar-width) 4px 1fr; + min-height: 0; + overflow: hidden; +} + +/* 侧栏拖拽分割条:只在 split / preview / edit 单栏模式下显示。 + 视觉宽度 4px。命中区扩到 ±6px(共 12px):由 app.js 的 mountSidebarSplitter + 监听 document mousedown + mousemove,在鼠标距 splitter 中线 ≤6px 时启动拖动, + 并把光标改成 col-resize 提示可拖。这是 grid track 4px 限制下的标准做法。 */ +.sidebar-splitter { + background: var(--border-subtle); + cursor: col-resize; + position: relative; + width: 4px; + transition: background var(--transition-fast); +} + +.sidebar-splitter:hover, +.sidebar-splitter.is-dragging { + background: var(--accent-soft); +} + +.sidebar-splitter::after { + /* 抓握提示:两条短竖线 */ + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 1px; + height: 24px; + background: var(--border-default); + box-shadow: 3px 0 0 var(--border-default); +} + +.sidebar-splitter:hover::after, +.sidebar-splitter.is-dragging::after { + background: var(--accent); + box-shadow: 3px 0 0 var(--accent); +} + +/* 侧栏恢复默认宽度按钮(hover splitter 时显形) + - 4px splitter 视觉宽度里塞不下按钮,所以按钮是 absolute 定位居中 + 视觉溢出; + 但 splitter 本身 hover 区域扩到 ±6px(HIT_RADIUS),按钮顺势接管这块区域 + - 默认 opacity:0 + pointer-events:none → 完全不挡 drag 命中区; + hover splitter / 按钮自身 / focus-visible 时显形 */ +.sidebar-splitter-reset { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 18px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: 3px; + color: var(--fg-secondary); + cursor: pointer; + padding: 0; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast); +} +.sidebar-splitter:hover .sidebar-splitter-reset, +.sidebar-splitter-reset:focus-visible { + opacity: 1; + pointer-events: auto; +} +.sidebar-splitter-reset:hover { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent); +} +.sidebar-splitter-reset:active { + background: var(--accent); + color: var(--bg-card); +} + diff --git a/src/styles/05-sidebar.css b/src/styles/05-sidebar.css new file mode 100644 index 0000000..eea8ab2 --- /dev/null +++ b/src/styles/05-sidebar.css @@ -0,0 +1,872 @@ +/* =========================================================================== + Sidebar + Viewer + Editor Split Layout + 自动从 src/styles.css 拆分(Stage 4a) + =========================================================================== */ + +/* ============================================================ + Sidebar + ============================================================ */ +.sidebar { + background: var(--bg-sidebar); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + min-height: 0; + z-index: var(--z-sidebar); + /* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */ + min-width: 0; +} + +.sidebar-header { + /* 紧凑的 header —— 上下 12px、左右 14px,让搜索框有呼吸但不浪费空间 */ + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* 新建笔记行:放在 .sidebar-header 下方、排序条上方的独立主操作行。 + 左右 padding 用 10px(与 .sidebar-sort / .file-list 对齐), + 让「创建」按钮和它作用的列表视觉上是一组;搜索框保留 14px 更深的内嵌来强调 */ +.sidebar-new-row { + padding: 10px 10px 8px; +} + +/* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮 + ——accent 文字 + 微微的 accent 底色 + 1px accent 边框,hover/active 进一步加深。 + 2026-08 把字号从 12px 提到 14px、字重 500 → 600、加 8% accent 底色 + 32% accent 边框, + 解决「新建笔记」字看着发淡、按钮太瘦的问题 */ +.sidebar-new-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + height: 34px; + padding: 0 12px; + border-radius: var(--radius-sm); + /* audit fix (Round 9):font-size 13px (fs-sm) 的文字走 --accent-text(混色 65/70%), + 不能用装饰色 token --accent(饱和度太高,13px 文字看着刺眼)。 + 见 [[feedback-colored-text-tier]]。background / border 仍走 --accent 因为 + 它们是装饰而非文字。 */ + color: var(--accent-text); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent); + font-size: var(--fs-sm); + font-weight: 600; + letter-spacing: 0; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} +.sidebar-new-btn:hover { + /* audit fix (CSS P2 / sidebar new-btn hover):之前 color 走 --accent-hover + (纯 accent 饱和色),13px 小字直接用饱和色会"扎眼"、在 dark 调色板 + 还会出现 vibration(红绿高频抖动)现象。base 态已经走 --accent-text + (accent 65/70% 混色),hover 应当保持同套语义色 token 一致,只是 + 把混色比例上调一档或换更暖的 --accent-text 变体,让视觉权重随交互 + 上升但 chroma 仍温和(见 [[feedback-colored-text-tier]])。 */ + color: var(--accent-text); + border-color: var(--accent); + background: var(--accent-soft-hover); +} +.sidebar-new-btn:active { + transform: scale(0.98); +} +.sidebar-new-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.search-wrap { + position: relative; + flex: 1; + min-width: 0; +} + +.search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + /* 用 secondary 替代 tertiary:搜索框是核心入口,图标应该一眼看清 */ + color: var(--fg-secondary); + pointer-events: none; + transition: color var(--transition-fast); +} + +.search-input { + width: 100%; + /* 收紧内边距、改为 8px/0/8px/32px;高度由 line-height + 7px*2 padding 决定 ≈ 30px */ + padding: 7px 10px 7px 32px; + background: var(--bg-card); + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + color: var(--fg-primary); + transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast); +} + +.search-input::placeholder { + /* 占位符原本用 fg-muted(暗色 ~2:1 几乎看不见),提到 fg-secondary 让"搜索文件..." + 提示文字可见 —— 用户至少能看清这里有输入框 */ + color: var(--fg-tertiary); +} + +.search-input:hover { + border-color: var(--border-default); +} + +.search-input:focus { + /* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */ + outline: none; + border-color: var(--accent); + box-shadow: var(--shadow-glow); + background: var(--bg-elevated); +} +@supports (focus-visible) { + .search-input:focus:not(:focus-visible) { + box-shadow: none; + } + .search-input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + } +} + +.search-input:focus + .search-icon { + color: var(--accent); +} + +/* ============================================================ + Sidebar 排序工具条 + 紧贴它作用的 .file-list 上方 —— 这是「列表的工具」, + 不应该埋进三层深的设置对话框(见 [[feedback-prefer-near-affected-area]])。 + 设计目标:紧凑到不抢搜索框注意力,active 态明确但不过重。 + ============================================================ */ +.sidebar-sort { + display: flex; + gap: 2px; + /* 顶 header 12px、底 6px —— 让工具条与列表的视觉关联 > 与 header 的关联 */ + padding: 6px 10px 8px 10px; + border-bottom: 1px solid var(--border-subtle); +} + +.sidebar-sort-btn { + flex: 1 1 0; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 8px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + /* 2026-08 把字号从 12px 提到 13px(--fs-sm),与文件 meta / 工具栏按钮文字同档, + 解决排序按钮在 13px 字号下字看着小、发淡的问题。字重 500 → 600 让文字更"实" */ + color: var(--fg-secondary); + font-size: var(--fs-sm); + font-weight: 600; + letter-spacing: var(--tracking-wide); + cursor: pointer; + /* 防止「修改时间」等 4 字标签在 180px 窄侧栏里换行成两行(高度塌到 46px, + 挤压下方文件列表)。即使不够也用省略号截断,保持一行紧凑 */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: + background var(--transition-fast), + color var(--transition-fast), + border-color var(--transition-fast); +} + +.sidebar-sort-btn:hover { + color: var(--fg-secondary); + background: var(--bg-hover); +} + +.sidebar-sort-btn.is-active { + /* active 选中态:文字降彩度(--accent-text)。"名称"/"修改时间"/"创建时间" + 三个 sort 按钮并排时,纯 accent 文字让三者同时"跳出来",观感刺眼; + 改用文字版后,accent-soft 底色仍明确选中,但字不再"贴脸"。 */ + color: var(--accent-text); + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 20%, transparent); +} + +.sidebar-sort-btn.is-active:hover { + background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover)); +} + +.sidebar-sort-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.sidebar-sort-btn svg { + flex: 0 0 auto; +} + +.file-list { + flex: 1; + overflow-y: auto; + /* 上下 8px 比之前的 12px 更紧凑;列表项自身有 padding 已经够了 */ + padding: 8px 10px; + /* 列表项间距:用 gap 而非 item 的 margin-bottom,避免 list 总高累加偏差。 + 1px gap 与原 margin-bottom:1px 视觉一致,但语义更清晰 */ + display: flex; + flex-direction: column; + gap: 1px; + /* 滚动条预留空间 —— 让滚动条 hover 才出现,不抢视觉 */ + scrollbar-gutter: stable; +} + +.file-list:empty::after { + /* 空列表:淡灰色提示。设置里改数据目录、或把 .md 拖到目录里都会自动消失。 + 原来用 --fg-muted 在 bg-sidebar 上仅 ~3.2:1(default dark),低于 WCAG AA。 + 提到 --fg-tertiary(~5.4:1)让这段引导文字真能"看清"—— + 无列表可看时用户全部注意力都落在这段字上,发淡等于无效提示 */ + content: attr(data-empty); + display: block; + padding: var(--space-7) var(--space-3); + text-align: center; + color: var(--fg-tertiary); + font-size: var(--fs-sm); + line-height: var(--lh-relaxed); + /* 留点上方间距,与 sidebar-header 拉开 */ + margin-top: var(--space-2); + /* 避免一行字过长在窄侧栏里截断为半个字 */ + word-break: break-word; +} + +.file-item { + /* grid 两列:16px 图标 + 1fr 文本区。 + 用 grid 而不是 padding-left+absolute 是为了: + 1) 文本区能正常占满剩余宽度(min-width:0 走 grid 才生效) + 2) 图标列固定,文本列弹性,缩窄侧栏不挤图标 */ + display: grid; + grid-template-columns: 16px 1fr; + align-items: center; + column-gap: 10px; + /* 上下 padding 9px → 6px:原 9px 在 13px name + 13px meta 两行下让单条 file-item + 撑到 ~60px,50 个文件就要 3000px 滚动,太松。压到 6px 后 ~50px/item, + 列表更紧凑、可见项更多,仍保留点击区域与 hover 反馈呼吸 */ + padding: 6px 12px 6px 10px; + /* margin-bottom 去掉,改用 file-list 的 gap 统一间距; + 同时消除"两行间距靠 margin 累加"导致的列表总高计算偏差 */ + border-radius: var(--radius-sm); + cursor: pointer; + /* transition 增加 border-color / box-shadow,让激活态色条 + 微弱内阴影的进入更顺滑 */ + transition: + background var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); + position: relative; +} + +.file-text { + /* 文本列:name + meta 仍然竖排;min-width:0 让 ellipsis 真正生效 */ + display: flex; + flex-direction: column; + /* name↔meta gap 2px → 1px:与 file-item 上下 padding 同步收紧,让整条紧凑 */ + gap: 1px; + min-width: 0; +} + +.file-icon { + /* 平时 muted,hover/active 时跟随主题升级。 + 用 grid 让图标"漂"在 item 左侧 + 上下居中,比 padding-left+absolute 更稳 */ + color: var(--fg-tertiary); + flex-shrink: 0; + transition: color var(--transition-fast); +} + +.file-item:hover .file-icon { + color: var(--fg-secondary); +} + +.file-item:active { + transform: scale(0.985); +} + +.file-item.is-active { + /* 激活态:accent-soft 底色 + 左侧 3px 色条。 + 用 accent-soft 替代 accent-soft-hover:hover 态是临时高亮(更深一档), + active 态是稳定选中,两者用不同 token 区分意图 */ + background: var(--accent-soft); +} + +.file-item.is-active:hover { + /* 激活态 hover 走更深一档 —— 与 idle 态 hover(bg-hover)形成层次差 */ + background: var(--accent-soft-hover); +} + +.file-item.is-active .file-icon { + /* 激活态:图标与正文同色,构成一致的视觉语言 */ + color: var(--accent); +} + +.file-item.is-active::before { + content: ""; + position: absolute; + /* 从 -3px 移到 0,让色条贴着 item 内沿,更紧凑不"漂" */ + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + border-radius: 0 2px 2px 0; + background: var(--accent); +} + +.file-name { + font-size: var(--fs-sm); + color: var(--fg-primary); + /* 字重 500 → 600:让 file-name 成为主视觉。 + 之前 file-meta 也是 600 + 同字号,导致 meta 看起来比 name 还"实", + 层级倒挂(次要信息喧宾夺主)。现在 name 600 / meta 500,名字优先 */ + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: var(--tracking-tight); + transition: color var(--transition-fast); +} + +.file-item .file-name mark { + /* 搜索高亮:在文件名里命中搜索关键字时的视觉标识。 + 22% accent 背景已经给出"高亮"信号,文字再纯 accent 会与背景同色相"贴 + 一起"。改用 --accent-text —— 仍可识别色相(青蓝/紫/红等色调随 palette), + 但 chroma 降到不会与底色"撞色"的程度。 + 字重保持 700,因为 mark 通常只有 1-3 字,需要靠粗细扛"高亮"识别。 */ + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent-text); + padding: 0 2px; + border-radius: 3px; + font-weight: 700; +} + +.file-item.is-active .file-name { + /* accent #5e6ad2 在 #08090a 上只有 ~4.0:1,作为正文偏暗。 + 用与正文链接同色相的 --md-link(已在 #08090a 上 ~5.5:1)替代 */ + color: var(--md-link); + /* 已与 idle file-name 同字重(600),保持选中态只是颜色升级、不再额外加粗 */ + font-weight: 600; +} + +.file-meta { + /* 字号 13px → 12px(--fs-xs-readable),比 file-name 小一档; + 字重 600 → 500 让 meta 视觉退到次级,与 file-name 的 600 形成对比。 + 两层降级(更小 + 更轻)让 meta 真正成为"辅助信息"而不是与 name 平级 */ + font-size: var(--fs-xs-readable); + color: var(--fg-secondary); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: var(--tracking-wide); + /* 窄侧栏(拖到 180px)时 meta 行可能换行成两行,让 file-item 高度跳变。 + 强制单行 + 省略号,保持列表项高度稳定 */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-item.is-active .file-meta { + color: var(--md-link); + opacity: 0.75; +} + +.sidebar-footer { + padding: 10px 12px; + border-top: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + gap: 2px; +} + +/* 侧栏底部视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开。 + 用 1px 细线 + 上下 6px 留白,避免紧贴导致 AI 按钮和文件夹链接"挤成一团" */ +.sidebar-footer-sep { + height: 1px; + background: var(--border-subtle); + margin: 6px 0; +} + +/* AI 主操作按钮在侧栏底部已不再与 footer-sep 紧邻(sep 在 AI 上方), + 不再需要 margin-bottom: 0 来解耦 gap。 */ + +.link-subtle { + display: flex; + align-items: center; + gap: var(--space-2); + /* 11px 字号在低 DPI 上偏糊,提到 12px(fs-xs-readable)保持视觉小但不模糊; + 颜色已用 secondary(2026-08 提亮后约 8.5:1 on dark) */ + font-size: var(--fs-xs-readable); + color: var(--fg-secondary); + padding: 6px 10px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast), color var(--transition-fast); + font-weight: 500; + min-width: 0; /* 允许 flex item 在窄侧栏(180px)收缩到内容宽度以下 */ +} + +/* 文字单行 + 溢出省略号:防止「切换数据文件夹…」在窄侧栏里折成两行 */ +.link-subtle > span { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* link-subtle / footer-action:侧栏底部"打开数据目录 / 切换目录"等 + 二级链接。hover 时给 bg-hover + accent 文字提示"可点"—— + 与全局 a 一致用 --accent-text,让"hover 才出颜色"的反馈也维持文字版。 */ +.link-subtle:hover { + background: var(--bg-hover); + color: var(--accent-text); +} + +/* ', + ...opts, + }); +} + +describe('mountModal 基本结构', () => { + it('#modal-root 缺失 → 返回 null', () => { + document.body.innerHTML = ''; + const result = mountModal({ title: 'x' }); + expect(result).toBeNull(); + }); + + it('挂载后返回 controller 且 overlay 在 DOM 里', () => { + const modal = makeModal(); + expect(modal).not.toBeNull(); + expect(modal.overlay).toBeInstanceOf(HTMLElement); + expect(modal.overlay.classList.contains('modal-overlay')).toBe(true); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true); + }); + + it('设了 ARIA 属性', () => { + const modal = makeModal({ title: 'aria 测试' }); + expect(modal.overlay.getAttribute('role')).toBe('dialog'); + expect(modal.overlay.getAttribute('aria-modal')).toBe('true'); + const titleId = modal.overlay.getAttribute('aria-labelledby'); + expect(titleId).toBeTruthy(); + // title 元素真有这个 id + const titleEl = document.getElementById(titleId); + expect(titleEl).not.toBeNull(); + expect(titleEl.textContent).toBe('aria 测试'); + }); + + it('body / footer 内容写入对应容器', () => { + const modal = makeModal({ + body: '
    hello
    ', + footer: '', + }); + expect(modal.body.querySelector('.custom-body').textContent).toBe('hello'); + expect(modal.footer.querySelector('[data-action="cancel"]')).not.toBeNull(); + }); +}); + +describe('mountModal 关闭路径', () => { + it('close(value) → 从 DOM 移除 + onClose(value) 触发', () => { + const modal = makeModal(); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + modal.close('ok'); + + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false); + expect(modalIsOpen()).toBe(false); + expect(onClose).toHaveBeenCalledWith('ok'); + }); + + it('重复 close 是幂等的', () => { + const modal = makeModal(); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + modal.close('first'); + modal.close('second'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith('first'); + }); + + it('点 × 按钮 → onBackdropClose 触发关闭', () => { + const modal = makeModal({ onBackdropClose: 'cancel' }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + const closeBtn = modal.overlay.querySelector('.modal-close'); + closeBtn.click(); + + expect(onClose).toHaveBeenCalledWith('cancel'); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false); + }); + + it('按 Esc → onBackdropClose 触发关闭', () => { + const modal = makeModal({ onBackdropClose: 'esc-cancel' }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + modal.overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(onClose).toHaveBeenCalledWith('esc-cancel'); + }); + + it('点 overlay 背景(不是 modal 内部) → onBackdropClose', () => { + const modal = makeModal({ onBackdropClose: 'dismissed' }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + // 直接派发 click 到 overlay 本身 + modal.overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onClose).toHaveBeenCalledWith('dismissed'); + }); + + it('点 modal 内部(不是 overlay 背景)不关闭', () => { + const modal = makeModal({ onBackdropClose: 'should-not-fire' }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + // 点击 modal 内部元素 + const inner = modal.overlay.querySelector('.modal'); + inner.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onClose).not.toHaveBeenCalled(); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true); + }); +}); + +describe('mountModal onAction 回调', () => { + it('点 footer [data-action] 按钮 → onAction(action, controller)', () => { + const onAction = vi.fn(); + const modal = makeModal({ + onAction, + footer: ` + + + `, + }); + + const cancelBtn = modal.footer.querySelector('[data-action="cancel"]'); + cancelBtn.click(); + + expect(onAction).toHaveBeenCalledWith('cancel', modal); + }); + + it('onAction 返回 "close" → 自动 close(action)', () => { + const onAction = vi.fn(() => 'close'); + const modal = makeModal({ onAction }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + const btn = modal.footer.querySelector('[data-action="ok"]'); + btn.click(); + + expect(onAction).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledWith('ok'); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false); + }); + + it('onAction 返回 undefined → 不关', () => { + const onAction = vi.fn(); + const modal = makeModal({ onAction }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + + modal.footer.querySelector('[data-action="ok"]').click(); + + expect(onAction).toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true); + }); + + // audit fix (Round 8 M-3):onAction 抛同步错时 modal 不能挂死。 + // 旧实现:onAction 抛错 → event listener 抛 → modal 不关 → 用户没法关窗。 + // 修复:try/catch 兜底,错误打 console,modal 保持打开(用户看到具体错误)。 + it('onAction 抛同步错 → 不挂死,modal 仍可关(Round 8 M-3)', () => { + const onAction = vi.fn(() => { throw new Error('dialog logic exploded'); }); + const modal = makeModal({ onAction }); + const onClose = vi.fn(); + modal.setOnClose(onClose); + // 静默 console.error,避免测试输出噪音 + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => modal.footer.querySelector('[data-action="ok"]').click()).not.toThrow(); + + expect(onAction).toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + // 关键断言 —— modal 仍可关闭(× 按钮正常工作) + modal.footer.querySelector('[data-action="__modal_close__"], .modal-close')?.click(); + // 没有 × 时 overlay 背景点击也走 onBackdropClose;这里显式 close(value) 模拟用户关窗 + modal.close('manual'); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false); + }); +}); + +describe('mountModal data-action 作用域(仅 modal-level)', () => { + // 契约:data-action 是 modal-level 属性,只被 modal 在 footer + × 关闭按钮上拦截。 + // body 内的 [data-action] 是 dialog 自己的 in-body action(例如 settings-dialog + // 的「显示/隐藏 API Key」「清空 AI 配置」),由 dialog 各自 bind 处理;modal + // 不应拦截、不应触发 onAction、也不应自动关闭。 + // + // 之前 modal 无差别拦截所有 [data-action],body 内按钮的 onAction 默认返回 + // 'close' → 点「显示」就立刻关 modal,UX 坏掉。 + it('body 内 [data-action] 按钮不触发 onAction', () => { + const onAction = vi.fn(() => 'close'); + const modal = makeModal({ + onAction, + body: '', + footer: '', + }); + + modal.body.querySelector('[data-action="toggle-ai-key"]').click(); + + expect(onAction).not.toHaveBeenCalled(); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true); + }); + + it('body 内 [data-action] 按钮即使 onAction 返回 "close" 也不关 modal', () => { + const onAction = vi.fn(() => 'close'); + const onClose = vi.fn(); + const modal = makeModal({ + onAction, + onClose, + body: '', + }); + modal.setOnClose(onClose); + + modal.body.querySelector('[data-action="clear-ai"]').click(); + + expect(onAction).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true); + }); + + it('footer 内 [data-action] 仍然走 onAction(回归保护)', () => { + const onAction = vi.fn(() => 'close'); + const modal = makeModal({ + onAction, + body: '', + footer: '', + }); + + modal.footer.querySelector('[data-action="footer-btn"]').click(); + + expect(onAction).toHaveBeenCalledWith('footer-btn', modal); + expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false); + }); +}); + +describe('mountModal 单槽冲突', () => { + it('已开一个再开第二个 → 第二个返回 null', () => { + const first = makeModal({ title: 'first' }); + const second = makeModal({ title: 'second' }); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + }); + + it('关掉第一个后才能再开第二个', () => { + const first = makeModal({ title: 'first' }); + expect(makeModal({ title: 'second' })).toBeNull(); + + first.close('ok'); + + const second = makeModal({ title: 'second' }); + expect(second).not.toBeNull(); + }); +}); + +describe('mountModal setBody / setFooter', () => { + it('setBody 替换正文', () => { + const modal = makeModal(); + modal.setBody('

    new

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