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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,9 @@ import { showToast } from './feedback.js';
import { showPromptDialog } from './prompt-dialog.js';
import { showConfirmDialog } from './confirm-dialog.js';
import { isMarkdownFile } from './utils.js';
// 救援工具:与 src/app.js 共享同一份命名 / toast / friendlyFsError 逻辑,
// 这里只关心「我有 content、想救回」。详见 src/rescue.js 注释。
import { rescueDirtyContent } from './rescue.js';
// 审计修复 (Round 11 deep-fix P1-1):create / delete / rename 三个回调的入口
// 在读 state.isDirty 之前必须先 syncDirtyFromEditor —— 否则 CJK 用户在 IME
// 合成期调「新建 / 删除 / 重命名」时,state.isDirty 是 stale false,跳过
@@ -166,22 +169,15 @@ export function createFileOps(ctx) {
// → 正在编辑的 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);
// 命名 / toast 文案 / friendlyFsError 错误码 vs message 的判断
// 全部收口到 src/rescue.js(之前这里手动传 r?.message || r?.error
// 是 audit Round 16 R2 修过的关键 bug —— 业务码不能给用户看)。
rescueDirtyContent({
api,
originalName: state.currentFile.name,
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
event: '文件已不在',
});
} else {
showToast('文件已不存在', 'warning');
@@ -255,24 +251,16 @@ export function createFileOps(ctx) {
// → 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);
// 与文件其它 IPC 调用对齐用 ctx.api(createFile / renameFile / deleteFile
// 都从 ctx 解构),不再直连 window.api。命名 / 写盘 / toast 收口到
// src/rescue.js,自动处理 friendlyFsError 错误码 vs message 的语义
// (之前这里是漏掉 `r?.message ||` 的 bug —— 用户会看到「救回失败:
// INVALID_NAME」这种原始业务码而不是中文提示)。
rescueDirtyContent({
api,
originalName: state.currentFile.name,
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
event: '原文件已被删除',
});
} else {
showToast('文件已不存在', 'warning');

View File

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

View File

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

88
src/rescue.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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