This commit is contained in:
2026-10-18 04:39:37 +08:00
parent ff366a4daf
commit e686763c35
21 changed files with 951 additions and 339 deletions

View File

@@ -152,32 +152,6 @@
<span id="app-title" class="app-title"><span id="app-title-text" class="app-title-text">Notes</span></span>
</div>
<div class="toolbar-right no-drag">
<!--
搜索文件入口(设计优化 2026-09):
- 从侧栏上移到底部顶栏首位,腾出侧栏垂直空间给文件列表
- 紧贴侧栏上方,视觉上明确"作用于侧栏"
- flex:0 1 240px + min-width:160px:窗口过窄时让搜索框先收缩,
不挤掉右侧窗口控制按钮
-->
<div class="toolbar-search">
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<!-- aria-controls="file-list":让屏幕阅读器在输入时宣告"过滤文件列表",
也让用户知道输入框控制的列表元素 id(audit P2-22)。
仅声明关系,不改变视觉/键盘行为。 -->
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
aria-controls="file-list"
>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 应用组:主题 + 置顶 -->
<div class="toolbar-group">
<div class="btn-group" role="group" aria-label="外观">
@@ -274,8 +248,30 @@
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 操作组:保存 + 关闭当前文件 + 设置 -->
<!-- 操作组:撤销 / 重做 + 保存 + 关闭当前文件 + 设置 -->
<div class="toolbar-group">
<!--
撤销 / 重做(2026-10 新增):CodeMirror history 的按钮入口。
作用在编辑器 → 贴近保存按钮成组(见 [[feedback-prefer-near-affected-area]])。
disabled 态由 app.js syncUndoRedoButtons 按「有无打开文件 + 视图模式 +
history 深度」实时同步;HTML 默认 disabled 保证首帧不闪可点态。
Ctrl+Z / Ctrl+Y 快捷键由 CodeMirror historyKeymap 在编辑器内处理,
data-shortcut 仅供工具栏 hover 提示 chip 使用。
-->
<div class="btn-group" role="group" aria-label="撤销与重做">
<button class="btn-icon btn-group-item" id="btn-undo" title="撤销 (Ctrl+Z)" aria-label="撤销" data-shortcut="Ctrl+Z" disabled>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 14 4 9l5-5"></path>
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"></path>
</svg>
</button>
<button class="btn-icon btn-group-item" id="btn-redo" title="重做 (Ctrl+Y)" aria-label="重做" data-shortcut="Ctrl+Y" disabled>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 14 5-5-5-5"></path>
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13"></path>
</svg>
</button>
</div>
<!--
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态(warning 色 + 脉动)。
见 [[feedback-save-button-always-visible]]。
@@ -340,8 +336,31 @@
-->
<div class="app-shell" id="app">
<aside class="sidebar" id="sidebar" aria-label="文件与目录">
<!-- 搜索框已上移到工具栏(见 .toolbar-search),侧栏不再保留 header。
排序工具条作为首屏唯一入口,垂直空间专注于文件列表 -->
<!--
搜索文件入口(设计回调 2026-10):从工具栏移回侧栏顶部。
- 紧贴它过滤的 .file-list(见 [[feedback-prefer-near-affected-area]]),
「作用于侧栏」的语义不再需要靠注释解释
- Ctrl+F 聚焦 / Esc 清空沿用 shortcuts.js,id="search-input" 不变,
app.js / file-list.js 的接线(getElementById + FileList.searchInput)零改动
- 排序工具条下移一行,侧栏首屏变为「搜索 + 排序」两行
-->
<div class="sidebar-search">
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<!-- aria-controls="file-list":让屏幕阅读器在输入时宣告"过滤文件列表",
也让用户知道输入框控制的列表元素 id(audit P2-22)。
仅声明关系,不改变视觉/键盘行为。 -->
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
aria-controls="file-list"
>
</div>
<!-- 排序工具条:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]])。
新建文件 / 新建文件夹已挪到右键菜单(侧栏空白右键、文件夹条目右键),
侧栏首屏只剩「排序工具条」一行,主操作 + 列表工具就近成组,视觉密度更紧凑。
@@ -443,6 +462,15 @@
</div>
<div id="editor-pane" class="editor-pane">
<!-- 「编辑模式 + 未打开文件」时编辑列曾是整片纯黑,用户会以为界面卡死。
viewer 侧的 .empty-state 在 edit 模式下是隐藏的,够不着这里,
所以复用它的外观体系做一个紧凑版(无光晕 icon / 无 CTA)。
显示条件见 05-sidebar.css 的 body[data-editor-mode="edit"]:not([data-file-open]) 规则;
data-file-open 由 markdown-editor.js loadFile/unload 维护。 -->
<div class="empty-state">
<p>选择一个文件开始编辑</p>
<p class="hint hint-secondary">或按 <kbd>Ctrl</kbd>+<kbd>N</kbd> 新建笔记</p>
</div>
<!-- CodeMirror 挂载到这里 -->
</div>

30
main.js
View File

@@ -2510,9 +2510,19 @@ async function _prepareRelocate(event, srcPath, destDir) {
return { error: { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取源状态失败') } };
}
const srcIsDir = srcStat.isDirectory();
// 文件夹不能移入自身或其子孙(防止循环 / 路径穿越)
if (srcIsDir && (destDir === srcPath || destDir.startsWith(srcPath + path.sep))) {
return { error: { ok: false, error: 'TARGET_INSIDE_SOURCE', code: 'TARGET_INSIDE_SOURCE', message: '不能把文件夹移入自身或其子文件夹' } };
// 文件夹不能移入自身或其子孙(防止循环 / 路径穿越)。
// audit #12 惯例(见 file:rename 的 isSamePath):先 resolve、win32 再折叠
// 大小写 —— path 字符串可能以混合拼写到达('d:/notes/A' vs 'D:\notes\A',
// 同一 inode:陈旧 clipboard 跨 data-dir 重选 / 手改 config.json),原始串
// 比对会绕过本守卫,之后 recursiveCopy 把副本套进自身目录无限嵌套直到
// ENAMETOOLONG(move 场景则是 rename 到自身产生静默改名)。
if (srcIsDir) {
const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s);
const srcFolded = fold(path.resolve(srcPath));
const destFolded = fold(path.resolve(destDir));
if (destFolded === srcFolded || destFolded.startsWith(srcFolded + path.sep)) {
return { error: { ok: false, error: 'TARGET_INSIDE_SOURCE', code: 'TARGET_INSIDE_SOURCE', message: '不能把文件夹移入自身或其子文件夹' } };
}
}
// 解析目标名 + 重名避让(文件 / 文件夹走各自 resolver)
const srcBase = path.basename(srcPath);
@@ -3029,7 +3039,11 @@ ipcMain.handle('app:get-settings', (event) => {
// P1-1 fix (audit):过滤 aiApiKey 真值,只露布尔标记。renderer 任何位置
// 通过 window.api.getSettings() 都拿不到完整 Key —— 必须显式调
// app:reveal-ai-key 才能拿真值(settings 对话框回显用)。
return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey };
// hasPendingAiKeyEnvelope:loadConfig 解密失败(密钥库不可用 / 包络损坏)时
// 包络被挪出 appConfig(内存 aiApiKey 恒为明文的契约 → ''),但磁盘上 Key
// 真实存在 —— _hasAiKey 继续报 true,设置对话框走 arm/reveal 流程,reveal
// 显式失败 → _aiKeyRevealFailed 提示 + diff 守卫(空白输入不覆盖磁盘 Key)。
return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey || configStore.hasPendingAiKeyEnvelope() };
});
/**
@@ -3096,6 +3110,14 @@ ipcMain.handle('app:reveal-ai-key', (event) => {
// L4 fix (audit):单行结构、sender + length,去掉任何 key 相关指纹;
// 配 arm 日志一起能看出 arm→reveal 时序关系。
const keyStr = String(cfg.aiApiKey || '');
if (!keyStr && configStore.hasPendingAiKeyEnvelope()) {
// 磁盘有包络但解密失败(密钥库不可用 / 包络损坏):显式报失败。
// 不能回 ok:true + 空串 —— 虽然 renderer Round-11 守卫也会标记失败,
// 但这里显式拒绝保住「成功 reveal」日志的语义(length=0 的记录会误导
// 事后审计「有人偷到了空 Key」)。
console.warn('[reveal-ai-key] rejected: envelope present but undecryptable (keychain unavailable?)');
return { ok: false, error: 'KEY_UNAVAILABLE' };
}
console.log('[reveal-ai-key] revealed by sender', senderId, 'length=', keyStr.length);
return { ok: true, aiApiKey: keyStr };
});

View File

@@ -62,8 +62,18 @@ let appConfig = {};
let DEFAULT_DATA_DIR = null;
let configLoaded = false;
// loadConfig 解密失败(密钥库不可用 / 包络损坏)时,磁盘上的 enc:v1: 包络挪到
// 这里保存:内存 appConfig.aiApiKey 维持「始终是明文」契约(置空),saveConfig
// 本轮没动 Key 字段时从这里原样写回磁盘,避免把仍有效的加密 Key 覆盖成 '' 而
// 丢失。不进 appConfig —— 原实现把包络留在 appConfig.aiApiKey,会经 reveal
// 把密文外漏给 renderer、_hasAiKey / AI 调用把密文当明文(契约违反)。
let pendingAiKeyEnvelope = null;
function loadConfig() {
const cfgPath = getConfigPath();
// 每次读盘重新推导包络暂存(解密成功 → 保持 null / 解密失败 → 置入);
// ENOENT / JSON 损坏 / 解析异常路径也顺带清掉上一轮残留。
pendingAiKeyEnvelope = null;
let raw;
try {
raw = fsSync.readFileSync(cfgPath, 'utf-8');
@@ -87,12 +97,18 @@ function loadConfig() {
// 上层(ai.js / settingsStore / renderer)零感知。懒迁移:旧版明文
// 没前缀,decrypt 原样返回,下一次 saveConfig 自动重新加密。
if (appConfig.aiApiKey) {
const decrypted = secretStore.decrypt(appConfig.aiApiKey);
// 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt 返回 '',
// 保留磁盘上的原始 enc:v1:... 包络,避免后续 saveConfig(如改主题)因内存值为 ''
// 而把仍有效的加密 Key 覆盖成空串、永久丢失。
const rawKey = String(appConfig.aiApiKey);
const decrypted = secretStore.decrypt(rawKey);
// 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt
// 返回 '',把包络挪进 pendingAiKeyEnvelope(见模块顶部注释)、内存置空 ——
// 既保住「内存 aiApiKey 始终明文」契约(reveal / _hasAiKey / ai.js 不会
// 拿到密文),又让后续 saveConfig(如改主题)能把包络原样写回、不丢 Key。
if (decrypted) {
appConfig.aiApiKey = decrypted;
pendingAiKeyEnvelope = null;
} else {
appConfig.aiApiKey = '';
pendingAiKeyEnvelope = rawKey.startsWith(secretStore.ENVELOPE_PREFIX) ? rawKey : null;
}
}
} catch (e) {
@@ -256,6 +272,11 @@ function saveConfig(next) {
const toWrite = { ...merged };
if (toWrite.aiApiKey) {
toWrite.aiApiKey = secretStore.encrypt(toWrite.aiApiKey);
} else if (pendingAiKeyEnvelope && !('aiApiKey' in next)) {
// 内存 aiApiKey 因解密失败被置空、本轮保存没动 Key 字段 →
// 包络从 pendingAiKeyEnvelope 原样写回磁盘,避免无关保存把仍有效
// 的加密 Key 覆盖成空串。
toWrite.aiApiKey = pendingAiKeyEnvelope;
}
await fh.writeFile(JSON.stringify(toWrite, null, 2), 'utf-8');
await fh.sync();
@@ -284,6 +305,8 @@ function saveConfig(next) {
}
// 写盘成功 → 同步到 in-memory cache
appConfig = merged;
// 本轮显式改写了 Key(重填新值 / 主动清空)→ 旧包络作废。
if ('aiApiKey' in next) pendingAiKeyEnvelope = null;
return { ok: true, value: merged };
} catch (e) {
if (fh) { try { await fh.close(); } catch { /* ignore */ } }
@@ -579,6 +602,7 @@ function _reset() {
appConfig = {};
DEFAULT_DATA_DIR = null;
configLoaded = false;
pendingAiKeyEnvelope = null;
// auto-fallback 2026-08:测试间清理缓存,避免上一个 case 残留 customDir
// 命中率污染下一个 case 的首次 stat。
_resolveCache = null;
@@ -619,4 +643,5 @@ module.exports = {
_setApp,
_isQuitting,
_setIsQuitting,
hasPendingAiKeyEnvelope: () => !!pendingAiKeyEnvelope,
};

View File

@@ -26,31 +26,42 @@
* —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头;
* `(?![/\\]{2})` 显式拒绝「协议相对 URL」(`//evil.com/x.png` /
* `\\evil.com\x.png`),避免静默导航到外站
* —— `[a-z+.-]+(?:[\\/][^a-z]*|$)` 覆盖字母开头的相对路径
* —— `[a-z.+#-]+(?:[\\/][^/\\]*)*` 覆盖字母开头的相对路径:首段允许
* `.`/`#`/`+`/`-`(如 `page.md#chapter`),后续段允许 `:` 与 `#`
* (如 `dir/sub:dir/file.png`、`sprite.svg#icon-home`、`foo/bar#baz`
* —— 跨文件锚点跳转的 `文件#章节` 形态由 app.js resolveLocalFile 消费)。
*
* 危险协议 (javascript:/vbscript:/data:text\/html) 走「字母+冒号」分支——因
* scheme 不在前列、不以 `#` 开头、不含 `/`,全部不匹配。
* 危险协议 (javascript:/vbscript:/data:text\/html):顶层 `(?!)` 显式拒已知
* 危险 scheme;其余未授权 scheme(`blob:x` / `chrome:x` / `ms-msdt:x` 等
* 「scheme:无点 token」形态)因分支 A 要求「冒号 + 带点扩展名」不再命中 ——
* 维持白名单的默认拒绝。
*
* Windows 路径分隔符 `\`:用户在 Windows 上常写 `![](img\foo.png)`,
* 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此),
* 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。
*/
// 末尾 $ 锚整串;scheme / data:image / 纯锚点分支用 [^\s]* 兜底后续字符。
// 顶层 `(?!)` 拒未在白名单的 scheme:相对路径分支 A (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`)
// 会让 `about:blank` / `javascript:foo` / `vbscript:foo` / `data:text/html` 同形态通过
// —— 这里在外层把已知危险 / 未授权 scheme 统一拒掉,保留分支 A 的 Mac 文件名修复。
// 顶层 `(?!)` 拒已知危险 scheme(about: / javascript: / vbscript: /
// data:text\/html)—— 它们与相对路径分支同形态,必须在外层先斩。
// 相对路径分支:
// A. `[a-z+.-]+:[a-z\d.\-_]+(?![/\\])` —— Mac/Linux 文件名带单冒号(如
// `image:001.jpg`、`screenshot:1.png`),但 `:` 后不许是 `/` 或 `\`
// (否则会误吃 `data:text/html`、`javascript:foo/bar` 之类)。
// B. `[a-z+.-]+(?:[\\/][^/\\#]*)*` —— 多段路径,支持 `img/a.png` / `../up.md` /
// `/abs/x.png` / Windows 反斜杠 / 路径段内含 `:`(如 `dir/sub:dir/file.png`)
// / 段内 #fragment(如 `sprite.svg#icon-home`、`page.md#chapter`)。
// A. `[a-z+.-]+:[a-z\d.\-_]*\.[a-z\d]+(?![/\\])` —— Mac/Linux 文件名带单冒号
// (如 `image:001.jpg`、`screenshot:1.png`)。要求冒号后是「带点扩展名」
// 形态:`blob:x` / `chrome:x` / `ms-msdt:x` 这类「scheme:无点 token」不再
// 命中(旧版 `[a-z\d.\-_]+` 不要求扩展名点,等于把默认拒绝白名单放宽成
// 只拦 4 个 scheme 的黑名单)。`:` 后仍不许紧跟 `/` 或 `\`(否则会误吃
// `data:text/html`、`javascript:foo/bar` 之类;这些也被顶层 `(?!)` 拦)。
// B. `[a-z.+#-]+(?:[\\/][^/\\]*)*` —— 多段 / 单段路径,支持 `img/a.png` /
// `../up.md` / `/abs/x.png`(经前一分支)/ Windows 反斜杠 / 路径段内含
// `:`(如 `dir/sub:dir/file.png`)/ 段内与末段 `#fragment`(如
// `sprite.svg#icon-home`、`page.md#chapter`、`foo/bar#baz`——旧版段字符类
// `[^/\\#]` 排除 `#`,把跨文件锚点跳转整类误拒,本分支修复)。首段类
// `[a-z.+#-]` 不含 `:` 与分隔符,拼不出「scheme:」形态,与 A 的扩展名
// 要求共同维持默认拒绝。
// `data:image/...` 在顶层 `(?!)` 里只拒 `data:text/html`(白名单分支仍允许
// `data:image/png` 等栅格格式);非 IMG URI 由 isDangerousUriNonImg 兜底
// javascript:/vbscript:/file:/data(?!:image\/),IMG 由 installHooks 的额外检查兜底。
const ALLOWED_URI_REGEXP =
/^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]+(?![/\\])|[a-z+.-]+(?:[\\/][^/\\#]*)*)$/i;
/^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]*\.[a-z\d]+(?![/\\])|[a-z.+#-]+(?:[\\/][^/\\]*)*)$/i;
// data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
@@ -204,14 +215,13 @@ function installHooks(DOMPurifyInstance) {
data.keepAttr = false;
return;
}
// 显式拒未在白名单的 scheme:ALLOWED_URI_REGEXP 的分支 A
// (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`) 允许 Mac/Linux 文件名带单 `:`
// (如 `image:001.jpg`),但形态上同 `javascript:foo` / `vbscript:foo` /
// `about:blank` —— 后者也会通过 ALLOWED。isDangerousUriNonImg 只跑非 IMG
// 路径且只覆盖危险 scheme,不含 `about:`,IMG 必须自己再拦一道。
// data:text/html 在分支 A 里 `:text` 后续遇 `/` 被 `(?![/\\])` 否定、不通过;
// 这里保留是为了防御性深度扫描(与 audit Sec-D 风格一致),同时挡住形如
// `data:text/html;<script>` 等 `;` 后紧接 script payload 的边界情况。
// 防御纵深:ALLOWED 的分支 A 现要求「冒号 + 带点扩展名」(放行 Mac
// 文件名如 `image:001.jpg`),`javascript:foo` / `about:blank` 这类无点
// token 已在正则层被拒、顶层 `(?!)` 也拦了已知危险 scheme;但
// `data:a.b` 等「危险前缀 + 带点尾巴」仍会形态命中分支 A ——
// isDangerousUriNonImg 只跑非 IMG 路径且不含 `about:`,IMG 保留这道
// scheme 前缀检查兜底(同时挡 `data:text/html;<script>` 之类 `;` 拼接
// payload 的边界情况,与 audit Sec-D 风格一致)。
if (/^\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|about\s*:)/i.test(data.attrValue)) {
data.keepAttr = false;
return;

View File

@@ -31,14 +31,18 @@ import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js';
/**
* 取 pendingDiff 的「不可变提交快照」:originalBaseContent 优先,缺时回退
* baseContent。apply / skip 漂移判定统一用此函数 —— 避免每个调用点重复
* `originalBaseContent || baseContent` 表达式,也方便后续把 baseContent
* `originalBaseContent ?? baseContent` 表达式,也方便后续把 baseContent
* 拆为「可变重算基线」/「不可变提交快照」两个独立字段时只改一处。
* 必须用 `??` 而非 `||`:构造处 originalBaseContent 允许是合法空串(空笔记
* 的提交快照 contentSnapshot === ''),`||` 会把空快照当「缺字段」回退到
* baseContent —— baseContent 正是会被 recompute 覆写的可变基线,空笔记上
* 发起 AI 修改期间的外部改动漂移检测就此失效,Apply All 静默覆盖外部修改。
* @param {{originalBaseContent?: string, baseContent?: string} | null | undefined} pd
* @returns {string}
*/
function getImmutableBase(pd) {
if (!pd) return '';
return pd.originalBaseContent || pd.baseContent || '';
return pd.originalBaseContent ?? pd.baseContent ?? '';
}
/**

View File

@@ -223,6 +223,10 @@ const els = {
// 关闭当前文件(仅清空视图,磁盘文件不动):放工具栏「操作组」紧邻保存按钮,
// 语义上「保存」与「关闭」是相邻操作。沿用 .btn-icon,disabled 态走现有规则。
btnCloseFile: document.getElementById('btn-close-file'),
// 撤销 / 重做(2026-10):CodeMirror history 的按钮入口,成组紧贴保存按钮。
// disabled 规则见 syncUndoRedoButtons;HTML 默认 disabled,首帧不闪可点态。
btnUndo: document.getElementById('btn-undo'),
btnRedo: document.getElementById('btn-redo'),
btnModePreview: document.getElementById('btn-mode-preview'),
btnModeEdit: document.getElementById('btn-mode-edit'),
btnModeSplit: document.getElementById('btn-mode-split'),
@@ -366,6 +370,10 @@ function syncSaveButton() {
// syncSaveButton 串到同一出口,openFile / clearCurrentFile 不必各自记得再调一次。
// 这条调用非常便宜(几个 DOM 属性),不影响 hot path。
syncCloseFileButton();
// 撤销 / 重做按钮(2026-10)与 syncCloseFileButton 同款搭车:
// openFile / clearCurrentFile / save / onChange 都会经由 syncSaveButton 到这里,
// 不必各自记得再调一次。模式切换路径不经过 syncSaveButton,由 applyEditorMode 负责。
syncUndoRedoButtons();
}
/**
@@ -392,6 +400,34 @@ function syncCloseFileButton() {
}
}
/**
* 同步「撤销 / 重做」按钮的 disabled 态(2026-10 新增)。
*
* 规则(2026-10 与用户确认):
* - 无打开文件 / 预览模式 → 两个都置灰(编辑器不可编辑,撤销不可见不可用)
* - 编辑 / 双栏 → 跟随 CodeMirror history 深度:无可撤销步 ↶ 置灰,
* 无可重做步 ↷ 置灰
*
* 视觉模式读 body[data-editorMode](editorToolbar._setDOM 写入):非 md 文件
* 已被强制回落到 'edit',与编辑器实际可编辑性一致,这里不必再推导
* state.currentFileIsMarkdown。
*
* 调用方:syncSaveButton 末尾(搭 openFile / clearCurrentFile / onChange / save
* 的统一出口)+ applyEditorMode 末尾(模式切换不触发 syncSaveButton)。
* 成本只有几个 DOM 属性 + 两次 history 深度读取,热路径安全。
*/
function syncUndoRedoButtons() {
if (!els.btnUndo || !els.btnRedo) return;
const mode = document.body.dataset.editorMode || 'preview';
const active = !!state.currentFile && !!editor && mode !== 'preview';
const canUndo = active && editor.canUndo();
const canRedo = active && editor.canRedo();
els.btnUndo.disabled = !canUndo;
els.btnUndo.setAttribute('aria-disabled', canUndo ? 'false' : 'true');
els.btnRedo.disabled = !canRedo;
els.btnRedo.setAttribute('aria-disabled', canRedo ? 'false' : 'true');
}
/**
* audit fix (Round 3 bug 2026-08):同步 state.isDirty 到 editor.isDirty() 的实时真相。
*
@@ -960,26 +996,26 @@ async function readClipboard() {
*/
function _setClipboard(entry, isCut) {
if (!entry || !entry.path) return;
// 切换 clipboard 时先清掉旧源的高亮
_clearClipboardCutMark();
state.clipboard = {
srcPath: entry.path,
srcName: entry.name,
entryType: entry.entryType,
isCut: !!isCut,
};
if (isCut) {
const li = findFileListItem(entry.path);
if (li) li.classList.add('is-clipboard-cut');
// 高亮(含切换时清旧源)交给 FileList 组件:setClipboardCut 先清全部再标记,
// 且高亮态存组件内,重渲染 / windowed 滚动时由 _fillItem 自动补回 ——
// 之前命令式 classList.add 会被任何一次重渲染抹掉。
if (fileList && typeof fileList.setClipboardCut === 'function') {
fileList.setClipboardCut(isCut ? entry.path : null);
}
showToast(isCut ? `已剪切:${entry.name}` : `已复制:${entry.name}`, 'info', 1500);
}
/** 清空 .is-clipboard-cut 高亮(切换 / 粘贴 / 取消时调用)。 */
/** 清空 .is-clipboard-cut 高亮(粘贴成功 / 取消时调用)。 */
function _clearClipboardCutMark() {
if (!els.fileList) return;
const prev = els.fileList.querySelector('.file-item.is-clipboard-cut');
if (prev) prev.classList.remove('is-clipboard-cut');
if (fileList && typeof fileList.setClipboardCut === 'function') {
fileList.setClipboardCut(null);
}
}
function cutEntry(entry) {
@@ -1007,6 +1043,15 @@ async function pasteToCurrentDir() {
showToast('不能粘贴到文件夹自身或其子文件夹内', 'warning', 1500);
return;
}
// 剪切 → 粘回源所在目录:主进程重名避让会把这次「原地移动」变成
// 「name (2)」静默改名(拖拽路径已被 _isInvalidDropTarget 挡住,这里补
// Ctrl+V / 右键菜单入口)。视为无操作并保留 clipboard,用户可再粘去别处。
// pathsEqual:Windows 大小写不敏感,侧栏目录名与 currentDir 的 canonical
// case 可能不一致,=== 会漏判成「不同目录」。
if (clip.isCut && pathsEqual(dirnameOf(clip.srcPath), destDir)) {
showToast('已在当前目录,无需移动', 'info', 1500);
return;
}
const srcEntry = { path: clip.srcPath, name: clip.srcName, entryType: clip.entryType };
if (clip.isCut) {
const res = await fileOps.moveEntry(srcEntry, destDir);
@@ -1060,24 +1105,6 @@ function showSidebarEmptyContextMenu(x, y) {
});
}
/**
* 在文件列表 DOM 里按 path 反查 .file-item 元素(windowed / full 模式都适用)。
* @param {string} path
* @returns {HTMLElement|null}
*/
function findFileListItem(path) {
if (!els.fileList || !path) return null;
return els.fileList.querySelector(`.file-item[data-path="${cssEscapeAttr(path)}"]`);
}
/**
* 给 CSS 属性选择器里的特殊字符做最小转义(path 含引号 / 方括号会破坏选择器)。
* @param {string} s
*/
function cssEscapeAttr(s) {
return String(s).replace(/["\\]/g, '\\$&');
}
/**
* 阅读视图的右键菜单:复制 + 全选
*/
@@ -1520,6 +1547,9 @@ function applyEditorMode(mode, opts) {
updateStatusMode();
updateStatusStats();
updateStatusScroll();
// 模式切换(含切文件后的 applyEditorMode)不走 syncSaveButton,
// 撤销 / 重做按钮的 disabled 态在这里单独同步一次。
syncUndoRedoButtons();
}
/**
@@ -1740,6 +1770,23 @@ if (els.btnSave) {
});
}
/**
* 撤销 / 重做按钮(2026-10):CodeMirror history 的按钮入口。
* editor 是懒实例化的模块级变量(bootstrap 完成前为 null)→ 先守再调。
* undo/redo 生效后走 updateListener → onChange → syncSaveButton →
* syncUndoRedoButtons 自动刷新按钮态,这里无需手动补一次。
*/
if (els.btnUndo) {
els.btnUndo.addEventListener('click', () => {
if (editor) editor.undo();
});
}
if (els.btnRedo) {
els.btnRedo.addEventListener('click', () => {
if (editor) editor.redo();
});
}
/**
* 状态栏 mode chip:直接点击切换视图模式(少一次去工具栏的旅程)
* 与顶栏的视图按钮组是同一份功能的快捷入口 —— 跟设置面板不重复入口原则一致。
@@ -3592,18 +3639,27 @@ function subscribeIpc() {
// - delete:救回一个 `<A>-rescued-<stamp>.md`(内容 = 用户要删的 A
// 内容),与「删除」操作意图完全相反 —— 用户预期的删除 + 多出
// 一个文件。
// 守卫:state.renamingPath / state.deletingPath 在 file-ops 的 rename /
// delete 进入前设,离开时清。命中 → 跳过 rescue,由 file-ops 自己的
// post-await 路径接管(rename 更新 state.currentFile.path;delete
// 调 clearCurrentFile)。
// 守卫:state.renamingPath / state.deletingPath / state.movingPath 在
// file-ops 的 rename / delete / move 进入前设,离开时清。命中 → 跳过
// rescue,由 file-ops 自己的 post-await 路径接管(rename / move 更新
// state.currentFile.path;delete 调 clearCurrentFile)。
const isRenameInFlight = state.renamingPath
&& state.currentFile
&& state.currentFile.path === state.renamingPath;
const isDeleteInFlight = state.deletingPath
&& state.currentFile
&& state.currentFile.path === state.deletingPath;
if (isRenameInFlight || isDeleteInFlight) {
// 飞行中的 rename / delete:由 file-ops 自己接管状态更新,
// move 的守卫多了前缀分支:移动祖先文件夹时,打开文件本身不在
// movingPath 上,但它所在目录的快照会推「文件消失」事件(rename 的
// 主进程侧已 markExpectedWrite 新旧两条路径,move 只标了 dest ——
// 源目录的移除事件本来就要流进来刷新列表,只能靠这里的飞行判定)。
const isMoveInFlight = state.movingPath
&& state.currentFile
&& (state.currentFile.path === state.movingPath
|| state.currentFile.path.startsWith(state.movingPath + '\\')
|| state.currentFile.path.startsWith(state.movingPath + '/'));
if (isRenameInFlight || isDeleteInFlight || isMoveInFlight) {
// 飞行中的 rename / delete / move:由 file-ops 自己接管状态更新,
// 这里只做无操作让 fs-watcher 流程继续(applyEntries 已更新
// state.files,但 state.currentFile 不动)。
return;

View File

@@ -17,6 +17,7 @@ const MENU_ID = 'app-context-menu';
* @property {string} label - 显示文本
* @property {string} value - 点击时回调收到的值
* @property {boolean} [disabled]
* @property {boolean} [danger] - 破坏性操作(删除等):红色文字 + 红色 hover 底
* @property {boolean} [separator] - 若为 true 则渲染为分隔条(忽略其它字段)
*/
@@ -100,6 +101,9 @@ export class ContextMenu {
btn.className = 'context-menu-item';
btn.setAttribute('role', 'menuitem');
btn.textContent = item.label;
if (item.danger) {
btn.classList.add('is-danger');
}
if (item.disabled) {
btn.disabled = true;
}

View File

@@ -112,6 +112,8 @@ export class FileList {
this.files = [];
this.filteredFiles = [];
this.activePath = null;
// 剪切源高亮路径(.is-clipboard-cut),渲染期由 _fillItem 应用;见 setClipboardCut。
this.clipboardCutPath = null;
this.searchTerm = '';
this.sortMode = normalizeSortMode(sortMode);
/** path → file,供事件委托时反查(避免每个 <li> 挂一个闭包) */
@@ -192,7 +194,11 @@ export class FileList {
e.dataTransfer.setData('application/x-notes-path',
JSON.stringify({ path: entry.path, entryType: entry.entryType, name: entry.name }));
} catch { /* 某些浏览器 setData 受限,但 drag 仍可继续 */ }
e.dataTransfer.effectAllowed = (e.ctrlKey || e.metaKey) ? 'copy' : 'move';
// 'all':dragstart 若按当下 Ctrl 态锁死 copy/move,Chromium 会把后续
// dragover 的 dropEffect 夹进 effectAllowed —— 拖行途中再按 Ctrl 时
// 'copy' ⊄ 'move',drop 被静默吞掉(Chromium 实测)。copy/move 意图
// 的实时判定交给下面 dragover / drop 各自的 ctrlKey 分支。
e.dataTransfer.effectAllowed = 'all';
li.classList.add('is-dragging');
}, { signal: this.signal });
@@ -343,11 +349,32 @@ export class FileList {
const selfDesc = src.entryType === 'folder'
&& (target.path === src.path + '/' || target.path === src.path + '\\'
|| target.path.startsWith(src.path + '/') || target.path.startsWith(src.path + '\\'));
const alreadyInside = target.path === src.path
|| target.path.startsWith(src.path + '/') || target.path.startsWith(src.path + '\\');
// 「源已在目标内部」= 把条目放进它自己所在目录(原地移动会被重名避让
// 变成 name (2) 静默改名)。判定方向是 **src 在 target 之下**——此前写反
// 成 target 在 src 之下,那个形态 folder 源已被 selfDesc 覆盖、file 源则
// 整条永不命中,等于没有这个守卫。
const alreadyInside = src.path.startsWith(target.path + '/')
|| src.path.startsWith(target.path + '\\');
return !!(selfDesc || alreadyInside);
}
/**
* 标记 / 清除「剪切源」高亮(.is-clipboard-cut)。path 传 null = 清除。
* 高亮态存在组件里(clipboardCutPath)、由 _fillItem 在每次渲染时应用 ——
* 调用方直接 classList.add 的命令式写法会被任何一次重渲染 / windowed 池
* 复用的 wantClass 重赋值抹掉(滚动一下就丢)。
* @param {string|null} path
*/
setClipboardCut(path) {
this.clipboardCutPath = path || null;
if (!this.element) return;
// 立即同步当前已挂载项(不等下一次重渲染)
for (const li of this.element.querySelectorAll('.file-item')) {
li.classList.toggle('is-clipboard-cut',
this.clipboardCutPath != null && pathsEqual(li.dataset.path, this.clipboardCutPath));
}
}
/**
* 设置排序模式(供设置对话框等调用方热切换)
* @param {'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'} mode
@@ -712,6 +739,9 @@ export class FileList {
const isActive = pathsEqual(file.path, this.activePath);
li.classList.toggle('is-active', isActive);
li.setAttribute('aria-selected', isActive ? 'true' : 'false');
// 剪切高亮随渲染类走(wantClass 重赋值会抹掉命令式 class;见 setClipboardCut)
li.classList.toggle('is-clipboard-cut',
this.clipboardCutPath != null && pathsEqual(file.path, this.clipboardCutPath));
// 子节点:icon / name / meta。首次建后保留引用,scroll 时只改 textContent。
let name = li._cachedName;

View File

@@ -673,38 +673,66 @@ export function createFileOps(ctx) {
*/
async function moveEntry(entry, destDir) {
if (!entry || !entry.path || !destDir) return null;
const result = await api.moveFile(entry.path, destDir);
if (!result || !result.ok) {
showToast(`移动失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return result || null;
}
showToast(`已移动到:${result.name}`, 'success', 1500);
// 移动涉及当前打开文件 → 同步路径 / 状态栏 / viewer 相对图解析
// 覆盖两种情况:
// 1) 移动的就是打开的文件本身(state.currentFile.path === entry.path)
// 2) 移动的是打开文件的祖先文件夹(state.currentFile.path 在 entry.path 之下)——
// audit 修复:之前只覆盖情况 1,导致情况 2 下 state.currentFileRelDir /
// currentFile.path 仍指向旧位置,onFilesChanged 把"旧目录的移除事件"
// 误判为"当前文件被删除",触发 rescue + 写一个孤儿 .md 到根目录。
if (state.currentFile && state.currentFile.path) {
const cfPath = state.currentFile.path;
if (cfPath === entry.path) {
_applyMoveToOpenFile(result);
} else if (result.entryType === 'folder' && cfPath.startsWith(entry.path)) {
// 祖先文件夹被移走 → 重新拼出文件新路径
const newFilePath = destDir + cfPath.slice(entry.path.length);
const newName = newFilePath.replace(/^.*[\\/]/, '');
_applyMoveToOpenFile({
path: newFilePath,
name: newName,
mtimeMs: state.currentFile.mtimeMs,
size: state.currentFile.size,
// destDir 的 relDir 与 result.relDir 一致(祖先移动后,新父 = destDir)
relDir: typeof result.relDir === 'string' ? result.relDir : '',
});
// 飞行守卫(rename / delete 的 renamingPath / deletingPath 同款,app.js
// onFilesChanged 消费):await moveFile(renameWithRetry + stat 最坏 350ms,
// SMB/OneDrive 上更高)可能晚于 fs-watcher 的 300ms 防抖完成 —— watcher 会
// 先推「源没了」的 files:changed。没有守卫,onFilesChanged 会把打开文件
// 误判为「被外部删除」,触发 rescue 孤儿文件 / 清空编辑器。
state.movingPath = entry.path;
try {
const result = await api.moveFile(entry.path, destDir);
if (!result || !result.ok) {
showToast(`移动失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return result || null;
}
showToast(`已移动到:${result.name}`, 'success', 1500);
// 移动涉及当前打开文件 → 同步路径 / 状态栏 / viewer 相对图解析
// 覆盖两种情况:
// 1) 移动的就是打开的文件本身(state.currentFile.path === entry.path)
// 2) 移动的是打开文件的祖先文件夹(state.currentFile.path 在 entry.path 之下)——
// audit 修复:之前只覆盖情况 1,导致情况 2 下 state.currentFileRelDir /
// currentFile.path 仍指向旧位置,onFilesChanged 把"旧目录的移除事件"
// 误判为"当前文件被删除",触发 rescue + 写一个孤儿 .md 到根目录。
if (state.currentFile && state.currentFile.path) {
const cfPath = state.currentFile.path;
if (cfPath === entry.path) {
_applyMoveToOpenFile(result);
} else if (result.entryType === 'folder'
&& (cfPath.startsWith(entry.path + '\\') || cfPath.startsWith(entry.path + '/'))) {
// 祖先文件夹被移走 → 重新拼出文件新路径。remainder 以一个路径分隔符
// 开头('\sub\file.md' / '\file.md')。新路径 = 被移动文件夹的**实际**
// 新位置 result.path(重名避让时会被改名,≠ destDir + 原名)+ remainder。
// 旧实现用 destDir + remainder,丢掉了被移动文件夹自身那一段名字 →
// 路径整个拼错。
const remainder = cfPath.slice(entry.path.length);
const cut = Math.max(remainder.lastIndexOf('\\'), remainder.lastIndexOf('/'));
const newFilePath = result.path + remainder;
const newName = remainder.slice(cut + 1);
// relDir 要指向**文件所在目录**:result.relDir 只是被移动文件夹的新
// 父目录,还须拼上文件夹的新名(result.name)与文件夹内的相对目录段
// ('\sub\file.md' → 'sub')。旧实现直接写 result.relDir,打开文件
// 在子目录里时 onFilesChanged 的 payloadRel 门永远错配。
const innerDirSegs = cut > 0
? remainder.slice(0, cut).replace(/^[\\/]+/, '').replace(/\\/g, '/')
: '';
const relDir = typeof result.relDir === 'string'
? [result.relDir, result.name, innerDirSegs].filter((s) => s).join('/')
: '';
_applyMoveToOpenFile({
path: newFilePath,
name: newName,
mtimeMs: state.currentFile.mtimeMs,
size: state.currentFile.size,
relDir,
});
}
}
return result;
} finally {
// 仿 rename 的顺序:post-await 先更新 currentFile.path / relDir,守卫
// 最后清 —— 晚到的旧目录事件已按新路径走常规 diff。
state.movingPath = null;
}
return result;
}
/**
@@ -790,7 +818,7 @@ export function createFileOps(ctx) {
items.push({ label: '重命名', value: 'rename' });
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
items.push({ label: '删除', value: 'delete', danger: true });
contextMenu.show({
x, y, items,
onSelect: (value) => {

View File

@@ -9,7 +9,7 @@
import { EditorState, Prec, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { history } from '@codemirror/commands';
import { history, undo as cmUndo, redo as cmRedo, undoDepth, redoDepth } from '@codemirror/commands';
import { markdown } from '@codemirror/lang-markdown';
import { getTextStats } from './stats.js';
import { pickEditorTheme } from './editor-theme.js';
@@ -128,15 +128,34 @@ export class MarkdownEditor {
this.lastSavedDoc = content || '';
const insert = content || '';
// 编辑列空态提示(见 index.html #editor-pane / 05-sidebar.css)依赖
// body[data-file-open]:loadFile 成功 = 已打开文件;unload() 移除该属性让提示回来。
document.body.dataset.fileOpen = 'true';
if (this.view) {
// view 已存在 → 复用。dispatch 替换 doc,selection 故意重置到 (0,0)
// view 已存在 → 复用。2026-10 起从「dispatch 替换 doc」改为
// 「setState 重建 state」:dispatch 替换会把「整页换内容」记进 history
// (一次可撤销事件),上一个文件的编辑记录也因此跨文件存活 —— 打开 B 后
// Ctrl+Z 能退回 A 的内容,#btn-undo 还会在刚打开的文件上误导「有可撤销」。
// setState 用同一份 extensions + 新 doc 建 EditorState → history state
// field 全新 init(undo/redo 栈清零),selection 天然落 (0,0)
// (用户主动打开文件 = 导航,光标落文首是合理默认;与 setExternalContent
// 的「保留光标位置」差异是设计意图:setExternalContent 是外部覆盖,
// 不该打断用户当前阅读位置;loadFile 是用户主动切文件,重置是合理的)。
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert },
selection: { anchor: 0, head: 0 },
});
//
// 不能用 historyCompartment.reconfigure 重置:@codemirror/commands 的
// historyField_ 是模块级共享 StateField(index.js 里 history() 直接返回
// 同一个 field 实例),换一个新 history() 配置并不会让字段重新 init,
// 栈纹丝不动 —— 实测无效,故走 setState。
// setState 不是 dispatch transaction → 不 fire updateListener / onChange:
// 加载文件本就不是编辑动作,脏态与预览由 openFile 尾部显式
// (state.isDirty=false → syncSaveButton / viewer.loadContent)负责。
// 旧 dispatch 路径每次切文件反而多跑一次伪 onChange(live preview 重复
// 渲染 + auto-save 空转),去掉无行为损失。view/DOM/IME listener 全复用,
// 无黑闪;themeCompartment / editableCompartment 当前值原样带入新 state。
this.view.setState(
EditorState.create({ doc: insert, extensions: this._buildExtensions() })
);
return;
}
@@ -144,6 +163,80 @@ export class MarkdownEditor {
this._createView(insert);
}
/**
* 构建 EditorState 的 extensions 列表(2026-10 抽出)。
*
* 两个消费方共用这一份,保证「首次建 view」与「loadFile 复用路径 setState」
* 的扩展集完全一致:themeCompartment / editableCompartment 是实例级 Compartment,
* setState 后其当前值(当前主题、只读态)原样生效,不会在切文件时被重置。
*/
_buildExtensions() {
return [
EditorView.lineWrapping,
basicSetup,
// audit P0-3(C-1):basicSetup 默认带 history() 但 history 大小无显式
// 上限(默认 minDepth=100,updateBranch 实际有 +20 buffer,所以 120)。
// 打开 5MB 文件 + 长时间编辑 → history 数组几十~几百 MB,OOM 风险。
// 覆盖配置:minDepth=500 限制 history 长度(老 step 被丢弃,updateBranch
// 内部 +20 buffer,所以 520 上限),newGroupDelay=200ms 把短时间内的连续
// keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千条 history)。
// 注:CM6 API 用 minDepth(虽然语义上是 max),不是 audit 文档误写的
// maxDepth;参数名以 @codemirror/commands 的 HistoryConfig 为准。
Prec.high(history({ minDepth: 500, newGroupDelay: 200 })),
markdown(),
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());
});
}
}),
];
}
/**
* 首次创建 EditorView + IME listener。view 整个 lifetime 只调一次。
* loadFile 在 view 已存在时不会重入这里。
@@ -160,72 +253,11 @@ export class MarkdownEditor {
// 拆解。
const contentSignal = this._viewAbort.signal;
// extensions 列表抽到 _buildExtensions()(2026-10):loadFile 复用路径
// setState 时要用同一份扩展集重建 state 以清空 history,两边必须一致。
const state = EditorState.create({
doc: initialContent,
extensions: [
EditorView.lineWrapping,
basicSetup,
// audit P0-3(C-1):basicSetup 默认带 history() 但 history 大小无显式
// 上限(默认 minDepth=100,updateBranch 实际有 +20 buffer,所以 120)。
// 打开 5MB 文件 + 长时间编辑 → history 数组几十~几百 MB,OOM 风险。
// 覆盖配置:minDepth=500 限制 history 长度(老 step 被丢弃,updateBranch
// 内部 +20 buffer,所以 520 上限),newGroupDelay=200ms 把短时间内的连续
// keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千条 history)。
// 注:CM6 API 用 minDepth(虽然语义上是 max),不是 audit 文档误写的
// maxDepth;参数名以 @codemirror/commands 的 HistoryConfig 为准。
Prec.high(history({ minDepth: 500, newGroupDelay: 200 })),
markdown(),
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());
});
}
}),
],
extensions: this._buildExtensions(),
});
this.view = new EditorView({
@@ -311,6 +343,8 @@ export class MarkdownEditor {
unload() {
this._destroyView();
this.lastSavedDoc = '';
// 与 loadFile 的 dataset.fileOpen 成对:无文件时编辑列显示空态提示
delete document.body.dataset.fileOpen;
// Phase N-2 修复(关键 bug · 保存按钮失灵):
// K2-L10 的旧实现会在这里清掉 onChange/onSave/onScroll 三个 callback。
// 后果:clearCurrentFile() → unload() 把 callback 置空 → 下次 openFile
@@ -443,6 +477,38 @@ export class MarkdownEditor {
if (this.view) this.view.focus();
}
/**
* 撤销 / 重做(2026-10,供 app.js 工具栏 #btn-undo / #btn-redo 调用)。
* app.js 顶层禁止静态 import @codemirror/*(audit P2-9),history 深度读取和
* 命令执行都收敛在本类内部,对外只暴露 can* / undo / redo 四个薄方法。
*
* - view 懒创建(首次 loadFile 才有)→ can* 在无 view 时一律 false
* - undo()/redo() 返回是否真的撤/重做了(CM 命令的布尔返回);
* 成功时把焦点还给编辑器 —— 点按钮会把焦点从 editor 抢走,还回去让用户
* 能继续打字;没东西可撤时不动焦点
*/
canUndo() {
return !!this.view && undoDepth(this.view.state) > 0;
}
canRedo() {
return !!this.view && redoDepth(this.view.state) > 0;
}
undo() {
if (!this.view) return false;
const ok = cmUndo(this.view);
if (ok) this.view.focus();
return ok;
}
redo() {
if (!this.view) return false;
const ok = cmRedo(this.view);
if (ok) this.view.focus();
return ok;
}
/**
* 编辑器的滚动进度(0..1)
*

View File

@@ -504,108 +504,18 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
}
/* ============================================================
Toolbar Search(设计优化 2026-09:从侧栏 header 迁来)
Toolbar Search —— 已迁回侧栏(设计回调 2026-10)
------------------------------------------------------------
把搜索文件框从侧栏上移到底部顶栏首位,让侧栏垂直空间专注于文件列表
(侧栏的新建按钮已挪到右键菜单,首屏只留排序条)。
- flex:0 1 240px:理想宽度 240px,可缩不可涨 —— 窗口变窄时它先缩,
不挤右侧按钮组;不抢占侧栏上方视觉重量
- min-width:160px:极限窄窗口下保留可用宽度(低于此值键盘用户看不清)
- max-width:320px:超宽窗口里也不无止境扩张,避免视觉失衡
- 顶栏背景 + 输入框对比:bg-card 让搜索框"沉下去",与工具栏按钮区分层级
CSS 顺序说明:base 规则必须放在 media query 之前,否则等特异性下后者
会被覆盖。
搜索文件框从工具栏首位移回左侧栏顶部,紧贴它过滤的 .file-list
(见 [[feedback-prefer-near-affected-area]])。样式跟随 HTML 一起
搬到 05-sidebar.css 的 .sidebar-search;id="search-input" 不变,
shortcuts.js / file-list.js 接线零改动。
工具栏腾出的空间:撤销 / 重做按钮组并入操作组(#btn-undo / #btn-redo),
极窄窗口的腾挪策略见文件底部 720px 断点。
============================================================ */
.toolbar-search {
position: relative;
display: flex;
align-items: center;
/* belt-and-suspenders:toolbar-right 已设 no-drag,但显式再标一次,
防止未来重构时把 .toolbar-search 误放进 .drag-region */
-webkit-app-region: no-drag;
flex: 0 1 240px;
min-width: 160px;
max-width: 320px;
height: 30px;
/* 与后面按钮组之间留点呼吸 —— 视觉上把搜索框归到"独立区",不和按钮混在一起 */
margin-right: 2px;
}
.toolbar-search .search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--fg-secondary);
pointer-events: none;
transition: color var(--transition-fast);
}
.toolbar-search .search-input {
width: 100%;
height: 30px;
/* 右侧多 4px 内边距:input[type="search"] 在 Webkit / Chromium 会渲染
内置的「×」清除按钮,避开它和占位文字重叠 */
padding: 0 30px 0 32px;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
color: var(--fg-primary);
transition:
border-color var(--transition-fast),
background var(--transition-fast),
box-shadow var(--transition-fast);
/* 搜索框是输入区,需要能选中文本。工具栏其余区域 user-select:none(避免拖拽时
高亮一片),这里覆盖掉只对输入区解禁 —— 比整个 toolbar 开 selection 干净 */
-webkit-user-select: text;
user-select: text;
}
.toolbar-search .search-input::placeholder {
color: var(--fg-tertiary);
}
.toolbar-search .search-input:hover {
border-color: var(--border-default);
}
.toolbar-search .search-input:focus {
/* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-glow);
background: var(--bg-elevated);
}
@supports (focus-visible) {
.toolbar-search .search-input:focus:not(:focus-visible) {
box-shadow: none;
}
.toolbar-search .search-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
}
/* 输入框 focus 时把左侧放大镜也跟着亮起来 —— 视觉一致性。
用 :focus-within 而不是兄弟选择器(.search-input:focus + .search-icon),
因为 DOM 里 icon 在 input 前面,兄弟选择器匹配不到 */
.toolbar-search:focus-within .search-icon {
color: var(--accent);
}
/*
聚焦模式隐藏整个 toolbar 时,搜索框随之隐藏 —— 此时连侧栏都没有,
搜索框无处可用。这与 09-misc.css 的 `body[data-focus-mode="true"] .toolbar { ... }`
配套,无需单独写规则。
窄窗口(< 820px)下,工具栏按钮文字隐藏但搜索框保留(核心功能)。
*/
/* 窄窗口(< 820px):toolbar 文字标签隐藏,只保留图标。
与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌。
CSS 顺序:media query 必须在 base .toolbar-search 之后,等特异性下后者覆盖前者 */
与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌。 */
@media (max-width: 820px) {
.btn-icon-text .btn-label {
display: none;
@@ -614,36 +524,16 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
/* 没文字时收紧 padding,让按钮保持图标按钮的紧凑感 */
padding: 0 6px;
}
/* 820px 时所有文字按钮已退化为图标按钮,省下约 100px;
搜索框也按比例收紧到 160~200px,给 title 留更多空间 */
.toolbar-search {
flex: 0 1 200px;
min-width: 140px;
}
}
/* 极窄窗口(< 720px):与侧栏窄屏阈值对齐。搜索框最小化到只保留图标宽度,
用户仍能用 Ctrl+F 聚焦输入(即使视觉上更紧凑,键盘可达性不丢)。
::placeholder 无法设 content(浏览器不支持),自然截断即可 —— 输入框太窄时
占位文字会被输入框边缘裁掉,但输入体验(点 / Ctrl+F / Esc 清空)都还在 */
@media (max-width: 720px) {
.toolbar-search {
flex: 0 1 140px;
min-width: 96px;
}
}
/* 极窄窗口(≤ 720px,含 600px 最小窗口):工具栏右簇(搜索 + 外观 + 视图 +
重置布局 + 自动保存 + 保存/关闭/设置 + 窗口控件)在 600px 视口下会溢出约 90px,
把最右侧的窗口控件(最小化/最大化/关闭)挤出视口 —— frameless 窗口下这是唯一
的关闭入口,被裁掉等于「关不掉窗口」。这里只收起最低优先级的控件腾出空间,
保证窗口控件始终可达:
/* 极窄窗口(≤ 720px,含 600px 最小窗口):工具栏右簇(外观 + 视图 +
重置布局 + 自动保存 + 撤销/重做/保存/关闭/设置 + 窗口控件)在 600px
视口下空间紧张,frameless 窗口的关闭入口(最小化/最大化/关闭)一旦被
挤出视口等于「关不掉窗口」。这里只收起最低优先级的控件腾出空间:
- 隐藏「重置布局」按钮组 + 它后面的分隔线(低频操作,仍可经右键菜单/设置恢复)
- 自动保存折叠为纯图标(隐藏文字标签 + iOS 开关,保留按钮本体与 aria-pressed
状态色;完整开关仍在设置里),约省 27px
- 搜索框 min-width 进一步降到 72px(720px 断点晚于 720px 断点,这里后定义以覆盖,
同时兜住 701–719px 这段原始布局仍会轻微溢出的过渡带)
三者合计约省 90px,窗口控件在 600px 最小宽度下不再被裁。 */
撤销 / 重做本就是 icon-only 紧凑按钮组(~60px),不折叠。 */
@media (max-width: 720px) {
#tg-reset-layout,
#tg-reset-layout + .toolbar-divider {
@@ -656,10 +546,6 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
#toolbar-autosave {
padding: 0 6px;
}
.toolbar-search {
flex: 0 1 120px;
min-width: 72px;
}
}
/* ============================================================

View File

@@ -80,8 +80,86 @@
.sidebar-new-btn--inline 及其窄侧栏 container query 规则随之移除。base 类
.sidebar-new-btn 仍被侧栏底部 AI 按钮(#btn-ai)复用) */
/* (search-wrap / search-icon / search-input 已移到 04-toolbar.css —— 2026-09
设计优化:搜索框从侧栏上移到工具栏,样式跟随 HTML 一起走) */
/* ============================================================
Sidebar Search(设计回调 2026-10:从工具栏移回侧栏首行)
------------------------------------------------------------
搜索框紧贴它过滤的 .file-list(中间只隔一行排序工具条),
「作用于侧栏」的语义由位置直接表达,不再需要注释解释。
- 左右 padding 10px 与下方 .sidebar-sort 对齐,两行共享同一列边线
- bg-card 让输入框在 --bg-sidebar 底上"沉下去"一档,与文件列表层级区分
- 行本身不画 border-bottom:分隔线由 .sidebar-sort 的底边统一承担,
避免首屏出现两条平行细线
- 窄侧栏(用户拖窄 / 窄窗口):input 单行自然截断占位文字,
Ctrl+F 聚焦 / Esc 清空的键盘可达性不受宽度影响
============================================================ */
.sidebar-search {
position: relative;
display: flex;
align-items: center;
padding: 8px 10px;
}
.sidebar-search .search-icon {
position: absolute;
left: 20px; /* 10px 行 padding + 10px 框内偏移 —— icon 绝对定位在行级容器,需把 padding 计入 */
top: 50%;
transform: translateY(-50%);
color: var(--fg-secondary);
pointer-events: none;
transition: color var(--transition-fast);
}
.sidebar-search .search-input {
width: 100%;
height: 30px;
/* 右侧多留内边距:input[type="search"] 在 Webkit / Chromium 会渲染
内置的「×」清除按钮,避开它和占位文字重叠 */
padding: 0 30px 0 32px;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
color: var(--fg-primary);
transition:
border-color var(--transition-fast),
background var(--transition-fast),
box-shadow var(--transition-fast);
/* 保险丝:即便祖先容器禁了 selection,输入区始终可选中/剪切粘贴 */
-webkit-user-select: text;
user-select: text;
}
.sidebar-search .search-input::placeholder {
color: var(--fg-tertiary);
}
.sidebar-search .search-input:hover {
border-color: var(--border-default);
}
.sidebar-search .search-input:focus {
/* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-glow);
background: var(--bg-elevated);
}
@supports (focus-visible) {
.sidebar-search .search-input:focus:not(:focus-visible) {
box-shadow: none;
}
.sidebar-search .search-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
}
/* 输入框 focus 时把左侧放大镜也跟着亮起来 —— 视觉一致性。
用 :focus-within 而不是兄弟选择器(.search-input:focus + .search-icon),
因为 DOM 里 icon 在 input 前面,兄弟选择器匹配不到 */
.sidebar-search:focus-within .search-icon {
color: var(--accent);
}
/* ============================================================
Sidebar 排序工具条
@@ -856,6 +934,19 @@ body[data-editor-mode="split"] .editor-pane {
flex-direction: column;
}
/* 编辑列紧凑空态提示(见 index.html #editor-pane 注释):
- 只在 edit 模式显示:split 模式下右侧 viewer 已有完整 .empty-state
(icon + CTA),左右各来一份同义提示是重复
- data-file-open 由 markdown-editor.loadFile() 置 true / unload() 删掉;
用 :not([data-file-open]) 而不是 [data-file-open="false"],
这样「首启从未开过文件」(属性压根不存在)也能命中 */
#editor-pane > .empty-state {
display: none;
}
body[data-editor-mode="edit"]:not([data-file-open]) #editor-pane > .empty-state {
display: flex;
}
/* 让编辑器与预览区域保持一致的宽度处理:共用 --w-reader-max。
默认 token 设为 none(content 完全填满区域,最大限度显示内容),
若未来想恢复"最优阅读宽度"上限,把 01-tokens.css 里的 --w-reader-max

View File

@@ -93,6 +93,22 @@
opacity: 0.55;
}
/* 破坏性操作项(删除):静置态用降彩度的 --danger-text 保持 WCAG AA,
hover 时才上 tint 底 + 纯 --danger,与 .settings-callout-danger 的
「文字降彩度、靠背景传递警示」同一设计语言(见 08-modal.css 注释)。 */
.context-menu-item.is-danger:not(:disabled) {
color: var(--danger-text);
}
.context-menu-item.is-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
}
.context-menu-item.is-danger:active:not(:disabled) {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.context-menu-separator {
height: 1px;
background: var(--border-subtle);

View File

@@ -43,7 +43,11 @@ export function formatDate(mtimeMs) {
* < 24h(且跨日)→ "昨天"
* < 24h(同日) → "N 小时前"
* < 7d → "N 天前"
* ≥ 7d → 回落到 formatDate(绝对时间)
* ≥ 7d → 紧凑绝对时间:同年 "MM-DD HH:mm",跨年 "YYYY-MM-DD"
*
* 不直接用 formatDate 的 "YYYY-MM-DD HH:mm":侧栏 meta 行宽约 190px,
* "2.6 KB · 2026-09-24 16:14" 会被 ellipsis 截成 "2026-09-24 16…",
* 年份对近一年的文件信息量最低,优先砍掉。
*
* @param {number} mtimeMs
* @param {number} [nowMs=Date.now()]
@@ -74,8 +78,21 @@ export function formatRelativeTime(mtimeMs, nowMs = Date.now()) {
if (daysApart === 1) return '昨天';
if (daysApart < 7) return `${daysApart} 天前`;
// ≥ 7d:回落绝对时间,避免"183 天前"这种无意义表达
return formatDate(mtimeMs);
// ≥ 7d:回落绝对时间,避免"183 天前"这种无意义表达。
// 侧栏空间只放得下 16 字符里的前 11 个左右,所以给紧凑版式:
// 同年 → "MM-DD HH:mm"(年份是废话:能同年说明就是今年改的)
// 跨年 → "YYYY-MM-DD"(时间部分退场,保住年份这个关键信息)
if (mtime.getFullYear() === now.getFullYear()) {
const mo = String(mtime.getMonth() + 1).padStart(2, '0');
const da = String(mtime.getDate()).padStart(2, '0');
const h = String(mtime.getHours()).padStart(2, '0');
const mi = String(mtime.getMinutes()).padStart(2, '0');
return `${mo}-${da} ${h}:${mi}`;
}
const y = mtime.getFullYear();
const mo = String(mtime.getMonth() + 1).padStart(2, '0');
const da = String(mtime.getDate()).padStart(2, '0');
return `${y}-${mo}-${da}`;
}
/**

View File

@@ -735,3 +735,63 @@ describe('coerceLoadedSettings(schema 兜底)', () => {
expect(coerceLoadedSettings('garbage')).toEqual({ ...DEFAULT_CONFIG });
});
});
// aiApiKey 包络降级路径(loadConfig 解密失败 → pendingAiKeyEnvelope)。
// 单测环境没有 Electron safeStorage → secretStore.decrypt 对 enc:v1: 包络
// 恒返回 ''(「不可用」契约),天然进入降级分支,无需额外 mock。
describe('aiApiKey 包络降级(decrypt 失败)', () => {
const ENVELOPE = 'enc:v1:Zm9vYmFy';
it('解密失败:内存 aiApiKey 置空(明文契约)+ hasPendingAiKeyEnvelope 为 true', () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE, theme: 'dark' }), 'utf-8');
configStore.init();
expect(configStore.getConfig().aiApiKey).toBe('');
expect(configStore.hasPendingAiKeyEnvelope()).toBe(true);
});
it('saveConfig 未触碰 aiApiKey:包络原样写回磁盘,不被空串覆盖', async () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE, theme: 'dark' }), 'utf-8');
configStore.init();
const res = await configStore.saveConfig({ theme: 'light' });
expect(res.ok).toBe(true);
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
expect(raw.aiApiKey).toBe(ENVELOPE);
expect(raw.theme).toBe('light');
// 内存侧仍守「aiApiKey 是明文或 ''」契约:不把包络塞回去
expect(configStore.getConfig().aiApiKey).toBe('');
});
it('saveConfig 显式重填 Key:写新值、清掉旧包络', async () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE }), 'utf-8');
configStore.init();
const res = await configStore.saveConfig({ aiApiKey: 'sk-new' });
expect(res.ok).toBe(true);
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
// 测试环境 safeStorage 不可用 → encrypt 降级为明文透传
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
expect(raw.aiApiKey).toBe('sk-new');
expect(configStore.getConfig().aiApiKey).toBe('sk-new');
});
it('saveConfig 显式清空 Key:写空串,包络不复活', async () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE }), 'utf-8');
configStore.init();
const res = await configStore.saveConfig({ aiApiKey: '' });
expect(res.ok).toBe(true);
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
expect(raw.aiApiKey).toBe('');
});
it('明文 passthrough(无前缀)正常解密:不留包络', () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: 'sk-plain' }), 'utf-8');
configStore.init();
expect(configStore.getConfig().aiApiKey).toBe('sk-plain');
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
});
it('首次启动(无 config.json)不残留包络状态', () => {
configStore.init();
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
});
});

View File

@@ -129,6 +129,44 @@ describe('FileList 拖拽投放', () => {
expect(fileLi.classList.contains('is-dragging')).toBe(false);
expect(list._dragEntry).toBeNull();
});
it('dragstart 设 effectAllowed=all(拖行中补按 Ctrl 仍可 copy 落地)', () => {
// Chromium 会把 dragover 的 dropEffect 夹进 effectAllowed:dragstart 若按
// 当时 Ctrl 态锁成 'move',途中补按 Ctrl 要 copy 时 'copy' ⊄ 'move',
// drop 被静默吞掉(Chromium 实测)。意图实时判定由 dragover/drop 的
// ctrlKey 分支负责。
const ev = fire(liFor('/d/note.md'), 'dragstart');
expect(ev.dataTransfer.effectAllowed).toBe('all');
});
it('_isInvalidDropTarget:源在目标目录内 → 拒绝(原地移动会变成 name (2) 静默改名)', () => {
// 方向回归守卫:此前条件写反成「target 在 src 之下」,该守卫从未真正生效。
expect(list._isInvalidDropTarget(
{ path: '/d/sub/x.md', entryType: 'file' },
{ path: '/d/sub', entryType: 'folder' },
)).toBe(true);
// 兄弟目录的正常拖放不受影响
expect(list._isInvalidDropTarget(
{ path: '/d/note.md', entryType: 'file' },
{ path: '/d/sub', entryType: 'folder' },
)).toBe(false);
});
it('setClipboardCut 高亮跨 setFiles 重渲染保留,传 null 清除', () => {
list.setClipboardCut('/d/note.md');
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
// fs-watcher 推新快照触发重渲染:命令式加的 class 会被 _fillItem 的
// wantClass 重赋值抹掉 —— 高亮必须由渲染期从 clipboardCutPath 补回。
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
{ name: 'other.md', path: '/d/other.md', entryType: 'file' },
]);
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
expect(liFor('/d/sub').classList.contains('is-clipboard-cut')).toBe(false);
list.setClipboardCut(null);
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(false);
});
});
describe('FileList 空白区域右键菜单', () => {

View File

@@ -150,6 +150,89 @@ describe('moveEntry / copyEntry', () => {
expect(ctx.state.currentFile.path).toBe('/d/note.md');
});
it('moveEntry 飞行中设置 state.movingPath,成功后 finally 清空(fs-watcher 竞态守卫)', async () => {
const ctx = makeCtx();
let seenDuringFlight;
ctx.api.moveFile = vi.fn(async () => {
seenDuringFlight = ctx.state.movingPath;
return { ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10, relDir: 'sub' };
});
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/note.md', name: 'note.md', entryType: 'file' }, '/d/sub');
expect(seenDuringFlight).toBe('/d/note.md');
expect(ctx.state.movingPath).toBeNull();
// 移动文件本身 → currentFileRelDir 同步为 result.relDir
expect(ctx.state.currentFileRelDir).toBe('sub');
});
it('moveEntry 失败同样清空 movingPath 守卫', async () => {
const ctx = makeCtx();
ctx.api.moveFile = vi.fn(async () => ({ ok: false, code: 'EACCES', message: '没有访问权限' }));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/note.md', name: 'note.md', entryType: 'file' }, '/d/sub');
expect(ctx.state.movingPath).toBeNull();
});
it('moveEntry 祖先文件夹被移动(重名避让改名):新路径用 result.path 重拼、relDir 指向文件所在目录', async () => {
const ctx = makeCtx();
ctx.state.currentFile = { path: '/d/A/sub/note.md', name: 'note.md', mtimeMs: 7, size: 42 };
ctx.state.currentFileRelDir = 'A/sub';
// 移动 /d/A → /d/B,重名避让后实际落点 /d/B/A (2)
ctx.api.moveFile = vi.fn(async () => ({
ok: true, path: '/d/B/A (2)', name: 'A (2)', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
}));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
// 旧实现:destDir + remainder → 丢掉文件夹自身段,拼成 /d/B/sub/note.md(错)
expect(ctx.state.currentFile.path).toBe('/d/B/A (2)/sub/note.md');
expect(ctx.state.currentFile.name).toBe('note.md');
// 旧实现:直接用 result.relDir='B'(被移动文件夹的新父目录,错)
expect(ctx.state.currentFileRelDir).toBe('B/A (2)/sub');
expect(ctx.controls.fileList.setActive).toHaveBeenCalledWith('/d/B/A (2)/sub/note.md');
});
it('moveEntry 祖先文件夹被移动(打开文件直接在文件夹下):relDir = 新父/新名', async () => {
const ctx = makeCtx();
ctx.state.currentFile = { path: '/d/A/note.md', name: 'note.md', mtimeMs: 7, size: 42 };
ctx.api.moveFile = vi.fn(async () => ({
ok: true, path: '/d/B/A', name: 'A', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
}));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
expect(ctx.state.currentFile.path).toBe('/d/B/A/note.md');
expect(ctx.state.currentFileRelDir).toBe('B/A');
});
it('moveEntry 祖先判定带分隔符边界:/d/A 移动不误伤打开文件 /d/Abc.md', async () => {
const ctx = makeCtx();
ctx.state.currentFile = { path: '/d/Abc.md', name: 'Abc.md', mtimeMs: 7, size: 42 };
ctx.state.currentFileRelDir = '';
// result.entryType folder 且 cfPath 与被移动文件夹共享前缀但不同目录
ctx.api.moveFile = vi.fn(async () => ({
ok: true, path: '/d/B/A', name: 'A', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
}));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
// 旧实现裸 startsWith('/d/A') 会误命中 '/d/Abc.md' 并把状态改坏
expect(ctx.state.currentFile.path).toBe('/d/Abc.md');
expect(ctx.state.currentFileRelDir).toBe('');
});
it('moveEntry / copyEntry 入参缺字段时直接返回 null', async () => {
const ctx = makeCtx();
const ops = createFileOps(ctx);

View File

@@ -0,0 +1,129 @@
// 2026-10 撤销 / 重做按钮(#btn-undo / #btn-redo)背后的 MarkdownEditor 门面:
// - canUndo / canRedo 跟随 CodeMirror history 深度
// - undo / redo 执行命令并返回是否生效
// - loadFile 复用 view 切文件时 history 重置(每文件一份干净 undo/redo 栈,
// 按钮不会在刚打开的文件上误导「有可撤销」)
// app.js 顶层不静态 import CM6(audit P2-9),按钮接线测不到这里,
// 语义正确性全部收敛在本文件的 MarkdownEditor 方法层。
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownEditor } from '../../src/markdown-editor.js';
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
if (typeof window.requestAnimationFrame === 'undefined') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
if (typeof Range.prototype.getClientRects !== 'function') {
Range.prototype.getClientRects = function () {
const arr = [];
arr.item = (i) => arr[i] || null;
return arr;
};
}
if (typeof Range.prototype.getBoundingClientRect !== 'function') {
Range.prototype.getBoundingClientRect = function () {
return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 };
};
}
function createEditor() {
const el = document.createElement('div');
document.body.appendChild(el);
return new MarkdownEditor({
element: el,
onChange: vi.fn(),
onSave: vi.fn(),
});
}
// 模拟一次用户输入(独立 history 事件:userEvent 不同不会被 newGroupDelay 合并)
function type(editor, text) {
editor.view.dispatch({
changes: { from: editor.view.state.doc.length, insert: text },
userEvent: 'input.type',
});
}
describe('MarkdownEditor 撤销/重做门面(2026-10)', () => {
let editor;
beforeEach(() => {
editor = createEditor();
});
it('view 未创建(还没 loadFile)→ canUndo / canRedo 均 false,undo/redo 返回 false', () => {
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(false);
expect(editor.undo()).toBe(false);
expect(editor.redo()).toBe(false);
});
it('首次 loadFile 后(fresh view)→ 无可撤无可重做', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello');
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(false);
});
it('输入 → canUndo true;undo() 回退 doc 且 canRedo true;redo() 再前滚', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello');
type(editor, ' world');
expect(editor.view.state.doc.toString()).toBe('hello world');
expect(editor.canUndo()).toBe(true);
expect(editor.canRedo()).toBe(false);
expect(editor.undo()).toBe(true);
expect(editor.view.state.doc.toString()).toBe('hello');
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(true);
expect(editor.redo()).toBe(true);
expect(editor.view.state.doc.toString()).toBe('hello world');
expect(editor.canRedo()).toBe(false);
});
it('历史耗尽时 undo 空操作返回 false,不吞事件', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'x');
expect(editor.undo()).toBe(false);
expect(editor.view.state.doc.toString()).toBe('x');
});
it('切文件(复用 view)→ 前一文件的编辑记录被清空,doc 是新文件内容', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA');
type(editor, '!'); // A 有可撤的一步
expect(editor.canUndo()).toBe(true);
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB');
expect(editor.view.state.doc.toString()).toBe('BBB');
// 关键断言:不能把 A 的编辑 history 带进 B(否则 ↶ 在新文件上亮着,
// 按下去会退回 A 的内容 —— 旧实现的跨文件撤销 bug)
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(false);
});
it('切文件后在新文件里编辑 → 撤销只回到新文件初始内容', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA');
type(editor, '!!!');
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB');
type(editor, '?');
editor.undo();
expect(editor.view.state.doc.toString()).toBe('BBB');
});
it('loadFile 切文件不触发 onChange(setState 非 transaction,不 fire updateListener)', () => {
// 2026-10 语义变更:旧实现「dispatch 替换 doc」每次切文件都会伪报一次
// onChange(触发多余的 live preview / auto-save 空转)。setState 重建 state
// 不派发事务 → onChange 不跑;脏态复位由 openFile 尾部显式完成。
const onChange = vi.fn();
const el = document.createElement('div');
document.body.appendChild(el);
const ed = new MarkdownEditor({ element: el, onChange, onSave: vi.fn() });
ed.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA'); // 首次建 view:0 次
ed.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB'); // setState 复用:0 次
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -306,11 +306,16 @@ describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
}
});
it('loadFile 重复同一文件:onChange 触发次数精确等于 dispatch 次数(listener 不重复挂)', () => {
it('loadFile 重复同一文件:不触发 onChange(setState 语义),listener 不重复挂', () => {
// 回归锚点:旧实现每次 loadFile 都 addEventListener 三次(compositionstart
// / end / cancel),但 dispose 才 remove —— contentDOM 上 listener 数量
// 线性增长。N 次切文件 → onChange 被触发 3N 次。新实现下 view 不重建,
// listener 只一份,N 次 loadFile 正好 N 次 onChange。
// 线性增长,N 次切文件 → onChange 被触发 3N 次。单实例化后 listener 只一份,
// 当时的断言是「N 次 loadFile 正好 N 次 onChange」。
// 2026-10 撤销/重做引入时复用路径再进化:从「dispatch 替换 doc」改为
// 「setState 重建 state」——把上一个文件的编辑 history 清零(否则切到 B 后
// Ctrl+Z 会退回 A 的内容,#btn-undo 也会在刚打开的文件上误亮)。
// setState 不派发 transaction → 一次 onChange 都不该有:加载 = 导航不是编辑,
// 脏态复位由 openFile 尾部显式完成。
const { editor, onChange } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const baseCount = onChange.mock.calls.length;
@@ -318,7 +323,7 @@ describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, `A${i}`);
}
const delta = onChange.mock.calls.length - baseCount;
expect(delta).toBe(5); // 精确 5,不是 10 / 15 / ...
expect(delta).toBe(0); // 不是 5 / 10 / 15 —— setState 完全不触发
});
it('A→B→A 场景下 lastSavedDoc 跟踪最终内容', () => {

View File

@@ -93,6 +93,12 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
['image:001.jpg', true],
['screenshot:1.png', true],
['dir/sub:dir/file.png', true],
// 回归(37664cb 重构引入):分支 B 段字符类曾排除 `#`,把「文件#锚点」
// 跨文件跳转形态整类误拒(app.js resolveLocalFile 消费 # 后缀)。
['foo/bar#baz', true],
['page.md#chapter', true],
['sprite.svg#icon-home', true],
['notes/page.md#sec', true],
// 即使危险 scheme 后只接字母(无 `/`),正则仍拒绝:顶层 `(?!)` 拦掉。
['javascript:foo', false],
['vbscript:foo', false],
@@ -101,6 +107,13 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
['vbscript:msgbox(1)', false],
['data:text/html,xxx', false],
['chrome-extension://x/y', false],
// 分支 A 收紧:旧版不要求「冒号后带点扩展名」,`scheme:无点token` 整类
// 放行(blob: / chrome: / ms-msdt: …),白名单被稀释成 4 scheme 黑名单。
// 现在恢复默认拒绝;仅剩主进程导航守卫兜底的状态不复存在。
['blob:x', false],
['chrome:x', false],
['ms-msdt:x', false],
['view-source:x', false],
// 协议相对 URL:// 或 \\ 开头会被浏览器解析为「继承当前 scheme」,
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
// 必须与绝对路径 /abs/x.png 区分开 —— 单 / 放行,双 / 拒绝。

View File

@@ -220,12 +220,13 @@ describe('formatRelativeTime', () => {
expect(formatRelativeTime(now - 3 * 86400_000, now)).toBe('3 天前');
expect(formatRelativeTime(now - 6 * 86400_000, now)).toBe('6 天前');
});
it('≥ 7d → 回落到绝对时间', () => {
const old = new Date(2024, 5, 1, 12, 0, 0).getTime();
const got = formatRelativeTime(old, now);
// 应该跟 formatDate(old) 一致
expect(got).toBe(formatDate(old));
expect(got).toBe('2024-06-01 12:00');
it('≥ 7d 且同年 → 紧凑 "MM-DD HH:mm"', () => {
const old = new Date(2024, 5, 1, 12, 0, 0).getTime(); // now 也在 2024-06
expect(formatRelativeTime(old, now)).toBe('06-01 12:00');
});
it('≥ 7d 且跨年 → "YYYY-MM-DD"(保年份,舍时分)', () => {
const old = new Date(2023, 5, 1, 12, 0, 0).getTime();
expect(formatRelativeTime(old, now)).toBe('2023-06-01');
});
it('未来时间 → 当成 0 差("刚刚")', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('刚刚');