// 渲染端 bootstrap
// 串联:SettingsStore + Toolbar + FileList + MarkdownViewer + MarkdownEditor + EditorToolbar + 事件订阅
//
// 启动顺序:settings → 静态事件 → IPC → 模式应用 → 文件列表 → 快捷键
import { SettingsStore } from './settings-store.js';
import { FileList } from './file-list.js';
import { MarkdownViewer } from './markdown-viewer.js';
import { MarkdownEditor } from './markdown-editor.js';
import { EditorToolbar, EDITOR_MODES } from './editor-toolbar.js';
import { Toolbar } from './toolbar.js';
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';
import { showConfirmDialog } from './confirm-dialog.js';
import { isOpen as modalIsOpen } from './modal-stack.js';
import { mountKeyboardShortcuts } from './shortcuts.js';
import { syncSaveButton as syncSaveButtonUI } from './save-button-ui.js';
import { ContextMenu } from './context-menu.js';
import { EditorView } from '@codemirror/view';
import { isPathInside, dirnameOf, basenameOf, resolvePath, isMarkdownFile, pathsEqual } from './utils.js';
import { formatStatusStats } from './stats.js';
// 文件操作层(Stage 4c 抽离)
//
// friendlyFsError 不再从 src/file-ops.js 导入 —— Round 4 收尾把三处独立 mapping
// 合并到 shared/friendly-fs-error.js,由 preload 经 contextBridge 暴露成
// window.api.friendlyFsError(见 preload.js#sharedFriendlyFsError require)。src/app.js
// / src/file-ops.js / main/file-ops.js 都走同一份文案。
import { createFileOps } from './file-ops.js';
// AI 修改(OpenAI 兼容 API + 行级/词级 diff + 用户确认式应用)
import { createAiController } from './ai/ai-controller.js';
// 通用拖拽分割条工厂(mountSplitter / mountSidebarSplitter / mountAiSplitter 三段
// 共用的样板抽到一处)。dispose() 一次性解除挂载的所有 listener。
import { createSplitter } from './splitter.js';
// 启动错误兜底:如果 preload 没注入 window.api(极少见),显示错误并退出
if (!window.api) {
const el = document.getElementById('boot-error');
if (el) el.removeAttribute('hidden');
throw new Error('[app] window.api not available; preload.js failed to inject contextBridge');
}
/**
* 把 preload 缓存的 icon.ico PNG data URL 注入到左上角
。
* 同步设 src → load 事件触发后 unhide 并加 body.has-app-icon(CSS 隐藏 ::before 占位)。
* src 拿不到(icon.ico 缺失 / 打包漏文件)→ img 保持 hidden,::before 渐变方块继续占位。
* 不 await:图标是视觉锦上添花,不应阻塞 settings / IPC / 文件列表的初始化。
*/
function mountAppIcon() {
const img = document.getElementById('app-icon');
if (!img || typeof window.api.getAppIconDataUrl !== 'function') return;
const url = window.api.getAppIconDataUrl();
if (!url) return;
img.addEventListener('load', () => {
img.hidden = false;
document.body.classList.add('has-app-icon');
}, { once: true });
// onerror 也走 hidden:避免破图图标 + ::before 同时不存在的尴尬状态
img.addEventListener('error', () => {
img.hidden = true;
}, { once: true });
img.src = url;
}
// schema 经 preload 的 contextBridge 过桥,不能直接 import shared/settings-schema.js(renderer 是
// 浏览器原生 ESM,无 CJS 互操作)。preload 先于 renderer 模块求值,顶层取值安全。
// audit fix (CQ-MED-9):fileListSort 默认值双源 —— schema 在 DEFAULT_SETTINGS.fileListSort
// 与 SETTINGS_UI_OPTIONS.fileListSort[0].value 各有一份真相。renderer 之前把 'name' 字面量
// 硬编码在多处 fallback,schema 改默认或加新枚举时这些地方会悄无声息地继续走旧值。现在只读
// 一处事实源(schema),fallback 用 UI 选项表第一项 —— 与 schema.js#DEFAULT_SETTINGS.fileListSort
// 共享同一个 [0].value。
const FILE_LIST_SORT_OPTIONS = (window.api?.settingsSchema?.SETTINGS_UI_OPTIONS?.fileListSort) || [
{ value: 'name', label: '按名称' },
{ value: 'mtime-desc', label: '按修改时间' },
];
const FILE_LIST_SORT_VALUES = FILE_LIST_SORT_OPTIONS.map((o) => o.value);
const DEFAULT_FILE_LIST_SORT = FILE_LIST_SORT_VALUES[0] || 'name';
// friendlyWriteError 已抽离到 shared/friendly-fs-error.js,preload 暴露成
// window.api.friendlyFsError(Round 4 收尾:消除与 src/file-ops.js /
// main/file-ops.js 的三处独立 mapping 漂移)。下方所有 callsite 改用
// window.api.friendlyFsError;preload 不可用时降级到内置轻量版(与
// shared 模块一致,保证 renderer 自测与生产环境文案统一)。
/**
* 应用状态
*/
const state = {
files: [],
entries: [], // Stage 8:当前目录全部条目(含 folder / editable / binary),与 state.files(仅文件)平行
currentFile: null, // {path, name, size, mtimeMs}
alwaysOnTop: false,
isMaximized: false,
// 编辑相关
// audit fix (Phase L2 M3):init 占位用 schema DEFAULT_SETTINGS.editorMode,
// 之前硬编码 'preview' 与 schema 默认 'split' 不一致;手改 config.json
// 绕过 coerceLoadedSettings 时会落进错误分支。下方 fallback 同样改对齐。
editorMode: (window.api?.settingsSchema?.DEFAULT_SETTINGS?.editorMode) || 'split',
isDirty: false, // 当前编辑器是否有未保存改动
// 保存按钮的「保存中」视觉态用计数器实现,而不只是单 boolean:
// inFlightSave sentinel 让多次 save() 共享同一份 IPC Promise,第二次
// 进入时直接 await 共享结果;如果用单 boolean 第二次进入会闪烁
// (设 true → 立刻被 finally 清 false)。用计数器累加/递减,归零才
// 真正切回非 saving 态,对应 UI 不会抖动。
savingCount: 0,
lastSavedMtimeMs: null, // 当前编辑器内容对应的磁盘 mtime
// P1-2:存的是「上一次写盘后的 mtime」(不是 Date.now() 时间戳)——
// 用于 fs.watch 回声抑制:fs-watcher 回调里拿到的 mtime 等于这个值说明是自身写盘回声,
// 跳过「文件被外部修改」分支;外部真实修改的 mtime 严格大于它。
// 旧名 recentlySavedAt 误导(让人以为是时间戳),改名 lastWriteMtimeMs 以反映
// 真实含义:上一次成功 writeFile 的 mtime(即 lastSavedMtimeMs 的同值镜像,
// 单独存以便「文件被打开」时也能拿到上一次写盘的基线)。
lastWriteMtimeMs: 0,
// 聚焦模式:默认关闭;Ctrl+Shift+F 切换;持久化到 settingsStore.focusMode
focusMode: false,
// 设置对话框开关状态:让 switchDataFolder 与之互斥,避免同时挂 dirty-check hook
settingsDialogOpen: false,
// Stage 8:Folder Browser
// currentDir = 当前显示目录的绝对路径;currentRelDir = 对应 dataRoot 的 POSIX 相对路径
// breadcrumb = [{ name, relDir }] 当前路径上的所有段(不含根),从根向下排
// 根目录时 currentRelDir === '' 且 breadcrumb === []
// 进入子目录后 breadcrumb 记录下钻轨迹,用于面包屑点击跳转
// 切数据文件夹 / 启动时 currentDir 重置为新根,breadcrumb 清空
currentDir: '',
currentRelDir: '',
breadcrumb: [],
// 当前打开的文件所在目录的 relDir(POSIX,相对 dataRoot)。
// 与 currentRelDir(当前显示目录的 relDir)解耦:浏览子目录时
// currentRelDir 变但 currentFileRelDir 保持指向文件所在目录,
// 防止 onFilesChanged 把「子目录事件」误判为「打开的文件被外部删除」。
currentFileRelDir: '',
// 当前打开的文件是否支持 Markdown 预览。
// 只有 .md / .markdown 才走 viewer.loadContent() + preview/split 模式;
// 其它可编辑文件(.txt/.py/.json/...)只走编辑器,viewer 走"不支持预览"空态。
currentFileIsMarkdown: false,
};
/**
* 元素引用
*/
const els = {
searchInput: document.getElementById('search-input'),
fileList: document.getElementById('file-list'),
linkOpenFolder: document.getElementById('link-open-folder'),
linkSwitchFolder: document.getElementById('btn-switch-folder'),
btnResetFolder: document.getElementById('btn-reset-folder'),
markdownBody: document.getElementById('markdown-body'),
emptyState: document.getElementById('empty-state'),
editorPane: document.getElementById('editor-pane'),
splitter: document.getElementById('splitter'),
sidebar: document.getElementById('sidebar'),
sidebarSplitter: document.getElementById('sidebar-splitter'),
btnSidebarResetWidth: document.getElementById('btn-sidebar-reset-width'),
statusPath: document.getElementById('status-path'),
statusMeta: document.getElementById('status-meta'),
statusCount: document.getElementById('status-count'),
statusMode: document.getElementById('status-mode'),
statusStats: document.getElementById('status-stats'),
statusScroll: document.getElementById('status-scroll'),
statusHeading: document.getElementById('status-heading'),
// AI chip:状态栏新增,承载 AI 过程/错误/待确认状态 —— 见 [feedback-ai-tips-into-status-bar]
statusAi: document.getElementById('status-ai'),
statusAiLabel: document.getElementById('status-ai-label'),
statusUnsaved: document.getElementById('status-unsaved'),
toolbarAutosave: document.getElementById('toolbar-autosave'),
btnSortName: document.getElementById('btn-sort-name'),
btnSortMtime: document.getElementById('btn-sort-mtime'),
btnTheme: document.getElementById('btn-theme'),
btnPin: document.getElementById('btn-pin'),
btnSave: document.getElementById('btn-save'),
btnModePreview: document.getElementById('btn-mode-preview'),
btnModeEdit: document.getElementById('btn-mode-edit'),
btnModeSplit: document.getElementById('btn-mode-split'),
btnSettings: document.getElementById('btn-settings'),
btnResetLayout: document.getElementById('btn-reset-layout'),
btnNewFile: document.getElementById('btn-new-file'),
// Stage 8:Folder Browser 面包屑
sidebarBreadcrumb: document.getElementById('sidebar-breadcrumb'),
btnBreadcrumbRoot: document.getElementById('btn-breadcrumb-root'),
sidebarBreadcrumbTrail: document.getElementById('sidebar-breadcrumb-trail'),
btnMin: document.getElementById('btn-min'),
btnMax: document.getElementById('btn-max'),
btnClose: document.getElementById('btn-close'),
appShell: document.querySelector('.app-shell'),
appTitle: document.getElementById('app-title'),
/*
appTitleText:长文件名截断用的内层 span(fix:长文件名导致 toolbar 偏移)。
- updateWindowTitle 把文件名写入这里,再由 .app-title-text 的
overflow:hidden + text-overflow:ellipsis 实现省略号截断
- 没有这个 span(旧版 HTML 兜底)时退回 els.appTitle.textContent
*/
appTitleText: document.getElementById('app-title-text'),
// AI 修改:按钮 + 底部 dock + 中间面板
btnAi: document.getElementById('btn-ai'),
aiChatDock: document.getElementById('ai-chat-dock'),
appAi: document.getElementById('app-ai'),
aiSplitter: document.getElementById('ai-splitter'),
};
/**
* 设置存储
*/
const settingsStore = new SettingsStore(window.api);
// 文件操作实例(Stage 4c 抽离到 src/file-ops.js)
// 用 let 声明:在 fileList / viewer / editor / contextMenu 都实例化后才赋值;
// 这些组件构造时的箭头函数以闭包方式引用 fileOps,到点击 / 实际调用时一定已赋值。
let fileOps;
/**
* 应用持久化的主题到
*
* 只支持 'dark' | 'light' 两态——旧版本写入的 'auto' 由 bootstrap 启动时迁移掉。
*
* audit fix (H5-renderer):editor 守卫 —— bootstrap 早期阶段(settings
* 还在加载、editor 还没挂载)applyTheme 可能先于 editor 赋值,editor.setTheme
* 会抛 TypeError。editor 是 let 在 bootstrap 里才赋值,applyTheme 在多个
* 调用点(initialTheme / settings update)都有可能比 editor 早执行。
*/
function applyTheme(theme) {
const resolved = theme === 'light' ? 'light' : 'dark';
document.body.dataset.theme = resolved;
// 通知编辑器切换主题(oneDark / 浅色)—— 编辑器不在时静默跳过
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(resolved);
}
}
function syncThemeButton(theme) {
const label = theme === 'dark' ? '暗色主题' : '亮色主题';
els.btnTheme.title = `切换主题 (当前: ${label})`;
// audit H/M (a11y):屏幕阅读器不会读 title/可见文字的状态变化,需要同步
// aria-label 让 SR 用户听到「当前: 暗色 / 亮色主题」;theme 是二态循环,
// 用 aria-pressed 比 aria-label 更诚实("按下=当前态"),但这里实际是
// 二选一的状态描述而非 toggle,所以用 aria-label 同步更准。
// Phase M a11y:补 aria-pressed —— 二态按钮「按下=当前态」对 SR 用户
// 更直接,配合 aria-label 双重表达("当前暗色" + "按下=true")。
els.btnTheme.setAttribute('aria-label', `切换主题 (当前: ${label})`);
els.btnTheme.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
}
/**
* 主题风格(palette):与暗/亮正交独立的颜色风格维度。
*
* CSS 选择器形式:`[data-theme-palette="X"][data-theme="Y"]`。
* 编辑器(CodeMirror)通过 CSS 变量自动跟随,无需在此通知。
*
* audit fix (Phase L2 M1):之前是硬编码的 ['default', 'ocean', 'forest',
* 'lavender', 'sunset'] + 中文 label map。schema 在 shared/settings-schema.js
* 里有单一事实源(SETTINGS_UI_OPTIONS.themePalette),加新 palette 只需改
* schema 一处。settings-dialog.js 早已用这套([src/settings-dialog.js:28]),
* 这里对齐同样的 source,避免双份字面量漂移。
*/
const PALETTES = (window.api?.settingsSchema?.SETTINGS_UI_OPTIONS?.themePalette ?? [])
.map((p) => p.value);
const PALETTE_LABEL = (window.api?.settingsSchema?.SETTINGS_UI_OPTIONS?.themePalette ?? [])
.reduce((acc, p) => { acc[p.value] = p.label; return acc; }, {});
function applyPalette(palette) {
const resolved = PALETTES.includes(palette) ? palette : 'default';
document.body.dataset.themePalette = resolved;
}
function syncPinButton(enabled) {
els.btnPin.classList.toggle('is-active', !!enabled);
// audit M (a11y):置顶是 toggle,aria-pressed 让 SR 知道当前是「开」还是「关」
els.btnPin.setAttribute('aria-pressed', String(!!enabled));
}
function syncMaxButton(isMaximized) {
els.btnMax.title = isMaximized ? '还原' : '最大化';
// audit M (a11y):最大化是 toggle,aria-pressed 同步当前态
els.btnMax.setAttribute('aria-pressed', String(!!isMaximized));
}
/**
* 同步保存按钮:始终显示在工具栏,脏时进入 is-dirty 提醒态(warning 色 + 脉动),
* 保存飞行期间进入 is-saving 态(cursor progress + opacity)——
* 见 [[feedback-save-button-always-visible]]。干净时仍可点击,是空操作(save() 内部
* 会因 !isDirty 提前返回);保留按钮可见避免用户去记忆「按钮出现 ⇒ 需要保存」
* 这个时序信号,也避免侧栏在切文件 / 保存成功时按钮位置跳动。
*
* 三态 / 「保存中」counter 语义 / 不设 disabled 等设计要点都在 src/save-button-ui.js
* 里详细说明。这里只是个 thin wrapper:把模块级闭包 (els.btnSave / state / IPC)
* 传给纯函数 helper,方便单测。
*/
// setDirty IPC 去重:savingCount 累加时 syncSaveButton 会被多次调用(共享路径
// 进/出 + 主路径 finally),但 isDirty 值不变 —— 不必每次都发 IPC。仅在
// isDirty 翻转时通知主进程,避免飞行期 IPC 噪音(关闭确认需要这个值)。
let lastSentDirtyToMain = null;
function syncSaveButton() {
syncSaveButtonUI({
btnSave: els.btnSave,
statusUnsaved: els.statusUnsaved,
state,
setDirty: (dirty) => {
if (dirty === lastSentDirtyToMain) return;
lastSentDirtyToMain = dirty;
window.api.setDirty(dirty).catch(() => {});
},
});
// audit fix (save-button audit):syncSaveButton 是 state.isDirty / state.savingCount
// 变化的唯一入口(save() 主路径末尾 + openFile / clearCurrentFile / 编辑器 onChange /
// 共享路径 finally 都会调它)。appTitle 的 dirty marker 「*」依赖 state.isDirty,
// 但 updateWindowTitle 只在 clearCurrentFile / reload / openFile 三处调用,从不在
// isDirty 翻转时调 —— 结果是 dirty 时标题栏一直停在「Notes — test.md」,缺
// 「*」标记,用户看不出文件有未保存改动(在多窗口/多文件场景更割裂)。
// 修复:syncSaveButton 内联调一次 updateWindowTitle,让标题/按钮/状态栏三处
// 反馈始终同步。lastSentDirtyToMain 已在 helper 内部去重 IPC,这里只刷 DOM。
updateWindowTitle();
}
/**
* audit fix (Round 3 bug 2026-08):同步 state.isDirty 到 editor.isDirty() 的实时真相。
*
* state.isDirty 是 markdown-editor 的 onChange 维护的派生状态,但 updateListener 在
* IME 合成期间跳过 onChange(`if (... && !isComposing && onChangeCallback)` 的
* `!isComposing` 守卫)—— 用户用 CJK 拼音 / 日文 IME / 韩文 IME 输入的字符不会触
* 发 state.isDirty 翻转。markdown-editor.js#compositionend 主动补发 onChange 让
* state 跟上 doc,但**前提是 compositionend 事件能 fire**。
*
* 触发 stale 的两条路径:
* - 用户在 IME 期间用键盘 Ctrl+S 触发保存 —— 焦点不离开编辑器,compositionend
* 不立即 fire,state.isDirty 短暂 stale。
* - 用户在 IME 期间用 mousedown 点保存按钮 —— mousedown 抢焦点 → compositionend →
* onChange → state.isDirty 翻 true 的时序可能在 click handler 之后才到(不同
* 浏览器/输入法的派发顺序差异)。
*
* stale 期间的副作用:
* - save() `!state.isDirty` 早退返回「已是最新」,但 editor.doc 里有未保存的
* IME 字符,磁盘没写入。
* - openFile / 切数据目录 / 关闭窗口 / Ctrl+R 的「未保存改动」对话框不弹,
* 切走时 IME 字符随 view 销毁静默丢失(更严重的数据丢失)。
*
* editor.isDirty() 直接比 view.state.doc.toString() 与 lastSavedDoc,是「有未保
* 存字符」的实时真相源(IME 临时字符也算 dirty)。
*
* 单向提升同步(**只**做 false → true)由 src/dirty-sync.js 纯函数实现 —— 本
* 函数只是薄包一层,调 syncSaveButton 同步按钮视觉态 / IPC setDirty。
*
* 调用方:所有用 state.isDirty 做"有未保存改动"判断的入口(save / openFile /
* withDataDirDirtyCheckHook / onSaveRequest / onReload / onFilesChanged rescue /
* mountKeyboardShortcuts.onSave)在读取 state.isDirty 前必须调一次本函数,让
* state 跟上 editor 的实时真相。
*
* @returns {boolean} 是否发生了提升(false→true)
*/
function syncDirtyFromEditor() {
if (syncDirtyFromEditorPure(state, editor)) {
syncSaveButton();
return true;
}
return false;
}
/**
* 同步侧栏底部 AI 按钮的 pressed 态 / hint —— 模块级 helper,被 mountAiPanel
* 的 click handler 与 bootstrap 的 onToggleAi 快捷键 handler 共享。
* 单一入口,避免三处(点击 / 快捷键 / MutationObserver)重复更新导致
* aria-pressed / aria-label / title 错位。
*/
function syncAiButton(open) {
if (!els.btnAi) return;
els.btnAi.setAttribute('aria-pressed', open ? 'true' : 'false');
// P2-5 fix (audit):aria-label 也跟着 open/close 状态切换,否则
// 屏幕阅读器只听到固定的"AI 修改",按钮按下后状态变了但读屏不会变。
els.btnAi.setAttribute('aria-label', open ? '关闭 AI 修改' : 'AI 修改');
els.btnAi.title = open ? '关闭 AI 修改 (Ctrl+Shift+A)' : 'AI 修改 (Ctrl+Shift+A)';
}
/**
* 文件列表组件
*/
const fileList = new FileList({
element: els.fileList,
searchInput: els.searchInput,
// Stage 8:onSelect 收到的是「条目」而非只是文件 —— entryType 决定分发:
// folder → enterFolder() 进入子目录
// editable → openFile() 打开编辑
// binary → toast 提示不支持的类型
onSelect: (entry) => {
if (!entry) return;
if (entry.entryType === 'folder') {
enterFolder(entry);
return;
}
if (entry.entryType === 'binary') {
showToast('不支持的文件类型', 'warning', 1500);
return;
}
openFile(entry);
},
onContextMenu: (entry, x, y) => fileOps.showFileListContextMenu(entry, x, y),
// M-series fix (audit Phase M-H2):移除 fileList.setFiles 触发的 onFileRemoved
// 回调。旧逻辑仅凭「previousActive 不在新 entries 里」就通知上层清空编辑器,
// 把「用户导航到子目录」(Folder Browser:浏览期间不自动关文件)与「文件被
// 外部删除」混淆。删除判定现在统一在 app.js#applyEntries 里用 directory
// 比对(见下方)做精确区分;fileList 只负责渲染。
});
/**
* Markdown 阅读视图
*/
const viewer = new MarkdownViewer({
bodyElement: els.markdownBody,
emptyElement: els.emptyState,
statusPath: els.statusPath,
statusMeta: els.statusMeta,
// 无打开文件时,状态栏左侧 chip 用 state.currentDir 展示当前浏览目录路径
// (替代原来的「—」占位),让用户随时知道自己在哪一层目录。
// 走 callback 而非缓存值:state.currentDir 切目录时会变。
getCurrentDir: () => state.currentDir,
// audit C3 安全修复:resolveRelativeImages 异步取数据目录做边界检查,
// 防止恶意 markdown 通过 `
` 直接 file:// 读外部文件。
// 用 callback 而非缓存值,因为 dataDir 会随用户切换设置而变。
getDataDir: () => {
if (window.api && typeof window.api.getDataDir === 'function') {
return window.api.getDataDir();
}
return null;
},
onExternalLink: async (url) => {
// 相对路径:尝试解析为数据目录内的另一个 .md / .markdown 文件,在 app 内打开
const resolved = resolveLocalFile(url);
if (resolved && resolved.file) {
const opened = await openFile(resolved.file);
// 跨文件跳转保留 fragment:渲染完后让 viewer 滚到对应锚点
// (同文件内的 #锚点 走 markdown-viewer.js 的 click handler,不经过这里)
if (opened && resolved.fragment && viewer && typeof viewer.scrollToAnchor === 'function') {
// 给 viewer 一次 rAF 让它先完成 innerHTML 写入再 scroll
requestAnimationFrame(() => viewer.scrollToAnchor(resolved.fragment));
}
return;
}
// http(s) / mailto:交给主进程在系统默认浏览器打开。
// openExternal 是 invoke(返回 Promise),必须 await 才能拿到真正的 envelope ——
// 直接 if (window.api.openExternal(url)) 永远为真(旧版 boolean),失败提示成了死代码。
// audit fix (Round 7 IPC-10):主进程已切到 `{ ok, code, message }` envelope,
// 这里按 ok + 优先 message / 兜底业务码 'INVALID_URL' 处理。
const result = await window.api.openExternal(url);
if (!result || !result.ok) {
const msg = result && result.message
? result.message
: '无法打开该链接';
showToast(msg, 'warning');
}
},
});
/**
* 解析 markdown 中的相对链接到数据目录里的另一个文件。
* 只接受相对路径,绝对路径与已知协议交给外部浏览器。
*
* Stage 8:白名单放宽到 EDITABLE_EXTS 对应的扩展名(与主进程 file-ops.js 同步);
* `.md` / `.markdown` 之外也允许 `.txt` / `.json` / `.csv` 等纯文本,让用户
* 在 md 里可以点击跳转到这些文件。
*
* 白名单从 shared/extension-lists.js 经 preload contextBridge 拿 Array
* (preload 内 Array.from 转一次,原结构是 Set),单一事实源 —— 与
* main/file-ops.js 的 EDITABLE_EXTS 自动同步,改白名单只改一处。
*
* @param {string} url
* @returns {{path:string,name:string}|null}
*/
function hasEditableExt(lowerPath) {
const exts = window.api && window.api.EDITABLE_EXTS;
if (!exts || !Array.isArray(exts)) return false;
const dot = lowerPath.lastIndexOf('.');
if (dot < 0 || dot === lowerPath.length - 1) return false;
// 取最后一个点之后到末尾的扩展名(basename 末段,路径里的 '.' 不算)
const after = lowerPath.slice(dot + 1);
const slashIdx = Math.max(after.lastIndexOf('/'), after.lastIndexOf('\\'));
if (slashIdx === 0) return false;
const ext = slashIdx > 0 ? after.slice(0, slashIdx) : after;
return exts.includes(ext);
}
function resolveLocalFile(url) {
if (!url || typeof url !== 'string') return null;
// 锚点或带协议 → 不是本地文件
if (url.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(url)) return null;
if (!state.currentFile) return null;
// 拆出锚点:先剥掉 ?query(query 不影响文件定位),再读 #fragment。
// 之前 url.split('#')[0] 把 fragment 默默丢掉了 —— 点 `notes.md#section-2`
// 打开后没法跳到对应标题。fragment 在跨文件跳转时一起返回给 openFile 调用方。
let target = url.split('?')[0];
const hashIdx = target.indexOf('#');
let fragment = '';
if (hashIdx >= 0) {
fragment = target.slice(hashIdx + 1);
target = target.slice(0, hashIdx);
}
if (!target) return null;
try {
target = decodeURIComponent(target);
} catch {
// 非法编码就按原文当路径
}
// 解析:当前文件所在目录 + url。
// resolvePath 会正确保留 POSIX 的根 '/' —— 之前这里用 split+过滤空串+join,
// 在 macOS/Linux 上会把开头的 '/' 吃掉,生成 'home/u/...' 这种无效路径。
const baseDir = dirnameOf(state.currentFile.path);
const resolved = resolvePath(baseDir, target);
if (!resolved) return null; // 越过根目录
// Stage 8:白名单放宽 —— 与 EDITABLE_EXTS 对齐(不仅限 .md/.markdown)
const lower = resolved.toLowerCase();
if (!hasEditableExt(lower)) return null;
// 在已知列表里找(O(n) 但列表通常很小)
const file = state.files.find(f => f.path === resolved || f.path.toLowerCase() === lower);
if (file) return { file, fragment };
// 不在列表里:构造一个临时文件对象(readFile 时再做存在性检查)
return { file: { path: resolved, name: basenameOf(resolved) }, fragment };
}
/**
* Markdown 编辑器(CodeMirror 6 包装)
*
* onChange: 内容变化时设 isDirty + 高亮保存按钮 + 双栏实时刷新预览(debounced)
* onSave: Ctrl/Cmd+S 触发;调用 app.save()
* onScroll: 编辑器滚动 / 光标移动 → 同步右侧预览滚动(仅 split 模式)
*/
// audit fix (Renderer F1 / scheduleLivePreview IME guard):
// IME 合成期间 onChange 会被每次 keystroke 触发(composition 阶段 content
// 包含临时选词),200ms debounce 内的最后一次 keystroke 仍然会 fire
// → 预览渲染中显示「选词中的临时字符」造成闪烁。最简方案:合成期直接
// 跳过预览,compositionend 强制渲染一次。
//
// 实现思路:把「是否正在 IME 合成」状态挂到模块级 _imeComposing,setTimeout
// fire 时若在合成期,延后到下一个 200ms;compositionend 强制同步一次预览。
// 不与 editor 自己的 composition 跟踪冲突 —— MarkdownEditor 不管 IME,
// 这里独立维护。
let livePreviewTimer = null;
let _imeComposing = false;
function scheduleLivePreview() {
if (livePreviewTimer) clearTimeout(livePreviewTimer);
livePreviewTimer = setTimeout(() => {
livePreviewTimer = null;
if (state.editorMode !== 'split' || !state.currentFile) return;
// audit fix (Renderer F1):合成期不渲染预览。重新调度 200ms 之后再看。
if (_imeComposing) {
scheduleLivePreview();
return;
}
viewer.loadContentLive(editor.getContent());
}, 200);
}
// 把 IME 状态绑定到 editorPane —— compositionstart / end 是 DOM 标准事件,
// 任何 contenteditable / input 元素都派发,CodeMirror 6 也走标准事件。
// signal: els.editorPane 的 abortSignal 用于卸载时解绑(如果未来引入)。
if (els.editorPane) {
els.editorPane.addEventListener('compositionstart', () => { _imeComposing = true; });
els.editorPane.addEventListener('compositionend', () => {
_imeComposing = false;
// 合成结束 → 强制刷新一次预览:把刚刚 commit 的中文 commit 进 viewer。
// 复用 scheduleLivePreview 而非直接 loadContentLive,与正常编辑路径
// 完全一致(debounce + 守卫)。
scheduleLivePreview();
});
}
// AI 聊天面板显隐观察器(模块级持有,便于 unload/卸载时 disconnect)
let aiObserver = null;
const editor = new MarkdownEditor({
element: els.editorPane,
onChange: () => {
state.isDirty = editor.isDirty();
syncSaveButton();
updateStatusStats();
scheduleLivePreview();
// 编辑动作 → 重新调度自动保存(debounce 语义:每次键入重置计时器)
// 仅当用户实际输入(isDirty=true)时才有意义;isDirty=false 的回声
// (如 setExternalContent)也会调用本函数,但 scheduleAutoSave 里
// save() 在 isDirty=false 时直接返回,所以是安全的 no-op。
scheduleAutoSave(currentAutoSaveDebounceMs());
// AI 应用了部分 region / 用户继续编辑:让 AI 控制器重算剩余 diff。
// 由 controller 内部判断是否有 pendingDiff,没有就 no-op。
ai && ai.onContentChanged();
},
onSave: () => save(),
onScroll: (progress) => {
// 任何模式下都先刷新状态栏(rAF 内 coalesce,多次触发无开销);
// split 模式再额外把 viewer 同步过去。syncScrollGuard 用来避免
// 反向同步(A→B→A)的循环。
updateStatusScroll();
if (state.editorMode !== 'split') return;
if (syncScrollGuard) return;
viewer.setScrollProgress(progress);
},
});
/**
* AI 修改控制器(自然语言 → diff → 应用)。
*
* 持有挂载点 + 编辑器 + state + 反馈回调,在 bootstrap 末尾完成 mount。
* 此处先声明变量,bootstrap 里再 new —— 因为 createAiController 需要
* settingsStore.getAll() 作为 getSettings 回调(settings 还没加载完时
* 引用不到),所以延迟到 bootstrap 里。
*
* 编辑器 onChange 已经能回调到这里;openFile / clearCurrentFile / 文件外部
* 修改覆盖本地 都通过 ai.onFileChanged 同步清掉 pendingDiff。
*/
let ai = null;
/**
* 双栏反向同步:用户滚动阅读视图时,让编辑器跟随。
*
* 互斥锁 syncScrollGuard 防止「A→B→A→B」循环:
* 编辑器触发 onScroll → viewer.setScrollProgress → 不会再次触发 viewer.onScroll
* (我们的反向监听只在 user scroll 时触发,不在程序 set 时触发,因为
* setScrollProgress 是直接赋值 scroller.scrollTop,不会触发 scroll 事件
* —— 但某些浏览器仍然派发,所以加一道时间窗防护最稳)。
*/
let syncScrollGuard = false;
// audit fix (K2-M5):长行 markdown(base64 / 内嵌 HTML / 未换行链接)下 editor /
// viewer 的 scrollHeight 不一致,progress 会算出不同值;rAF 1 帧就释放
// syncScrollGuard,会出现 A滚→B跟→B算progress推A→A跟→B再跟……的双栏
// 抖动。叠加 0.005 阈值(≈ 0.5% 滚动差)后,反向同步跳过「微抖」轮次,
// 双栏稳定下来。
let lastSyncedToEditor = -1;
viewer.onScroll((progress) => {
if (state.editorMode !== 'split') return;
if (syncScrollGuard) return;
if (Math.abs(progress - lastSyncedToEditor) < 0.005) return;
lastSyncedToEditor = progress;
syncScrollGuard = true;
editor.setScrollProgress(progress);
// P1-9:用 rAF 而不是 setTimeout 50ms。
// 50ms 是猜测值 —— 慢机器可能比 50ms 长,反向同步还没解除锁就被下一波 scroll 顶住;
// 快机器 + 持续滚动时 50ms 又太短,guard 解除后再次触发循环导致双栏滚动来回抖动。
// rAF 在浏览器下一次绘制前释放:足以让程序触发的 scroll event 被消化掉,
// 又比硬编码时间短更贴合实际渲染节奏。
requestAnimationFrame(() => { syncScrollGuard = false; });
});
/**
* 右键菜单:统一为阅读视图和编辑器提供 复制/剪切/粘贴/全选
*
* 阅读视图(preview / split):仅 复制 + 全选(视图是只读 HTML)
* 编辑器(edit / split):复制 / 剪切 / 粘贴 / 全选,按当前选区/可写状态启用禁用
*/
const contextMenu = new ContextMenu();
/**
* 文件操作实例化(Stage 4c)
*
* 依赖 state(共享)+ controls(fileList / viewer / editor / contextMenu)
* + api(window.api)+ callbacks(openFile / updateWindowTitle / updateStatusStats
* / syncSaveButton)。
*
* 函数声明(function xxx)在模块顶部被 hoist,所以这里传引用完全合法
* —— 调用时一定已定义。
*/
fileOps = createFileOps({
state,
controls: { fileList, viewer, editor, contextMenu },
api: window.api,
callbacks: {
openFile,
updateWindowTitle,
updateStatusStats,
syncSaveButton,
clearCurrentFile,
// Phase N 修复:rename 扩展名变化(md↔非md)后必须重跑 applyEditorMode
// 同步工具栏 preview/split 按钮可用性 + setReadOnly。
// file-ops.js 不直接依赖 app.js 模块,所以走 callback 注入。
applyEditorMode,
// audit fix (Round 9):rename 后通知 AI controller 清 pendingDiff。
// 路径变化 → baseContent / forFilePath 脱节 → 防 stale apply。
// 用 ai.onFileChanged 而非直接传 ai 模块,保持 file-ops 无 app.js 依赖。
onFileChanged: (file) => {
if (ai && typeof ai.onFileChanged === 'function') {
try { ai.onFileChanged(file); } catch (e) {
console.warn('[app] ai.onFileChanged (rename) failed:', e);
}
}
},
},
});
/**
* 当前文本选区是否非空(用于动态启用「复制 / 剪切」)
*/
function hasTextSelection() {
const sel = window.getSelection();
return !!(sel && sel.toString().length > 0);
}
/**
* 把文本写入剪贴板。失败时给用户反馈(避免默默吞错)。
* @param {string} text
*/
async function writeClipboard(text) {
if (!text) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch (e) {
console.warn('[app] navigator.clipboard.writeText 失败:', e);
}
// 兜底:execCommand 在 Electron 内仍可用
try {
const ok = document.execCommand('copy');
if (!ok) throw new Error('execCommand 失败');
} catch (e) {
console.error('[app] 复制到剪贴板失败:', e);
showToast('复制失败', 'error', 1500);
}
}
/**
* 从剪贴板读文本
* @returns {Promise}
*/
async function readClipboard() {
try {
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
} catch (e) {
console.warn('[app] navigator.clipboard.readText 失败:', e);
}
showToast('粘贴失败:无法访问剪贴板', 'error', 1500);
return '';
}
/**
* 阅读视图的右键菜单:复制 + 全选
*/
function mountViewerContextMenu() {
if (!els.markdownBody) return;
els.markdownBody.addEventListener('contextmenu', (e) => {
// 当前焦点不在 viewer 内、或 view 隐藏时不弹出
if (els.markdownBody.hidden) return;
e.preventDefault();
// 把当前选区存起来 —— 用户右键时选区已经形成
const selected = hasTextSelection();
contextMenu.show({
x: e.clientX,
y: e.clientY,
items: [
{ label: '复制', value: 'copy', disabled: !selected },
{ separator: true, value: '' },
{ label: '全选', value: 'select_all' },
],
onSelect: async (value) => {
if (value === 'copy') {
const sel = window.getSelection();
await writeClipboard(sel ? sel.toString() : '');
} else if (value === 'select_all') {
// 让 viewer 容器内的所有文本被选中
const range = document.createRange();
range.selectNodeContents(els.markdownBody);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
},
});
});
}
/**
* 编辑器的右键菜单:复制 / 剪切 / 粘贴 / 全选
*
* 与阅读视图共用同一个 ContextMenu 实例,但选项集不同。
* 复制 / 剪切 / 粘贴都走 CodeMirror 的事务,保证 undo history 与选区一致。
*/
function mountEditorContextMenu() {
if (!els.editorPane) return;
els.editorPane.addEventListener('contextmenu', (e) => {
// 编辑器被设为只读时(preview 模式时虽然 panel 隐藏,仍做一次防御)
if (state.editorMode === 'preview') return;
e.preventDefault();
// 在用户右键时同步一次编辑器焦点,否则点菜单后操作的是文档其他位置
if (editor.view && document.activeElement !== editor.view.contentDOM) {
editor.view.focus();
}
const view = editor.view;
const editable = !view || view.state.facet(EditorView.editable);
// 取主选区(CM6 支持多选区,右键菜单只针对主选区)
const sel = view ? view.state.selection.main : null;
const hasSelection = !!sel && sel.from !== sel.to;
const items = [
{ label: '复制', value: 'copy', disabled: !hasSelection },
{ label: '剪切', value: 'cut', disabled: !editable || !hasSelection },
{ label: '粘贴', value: 'paste', disabled: !editable },
{ separator: true, value: '' },
{ label: '全选', value: 'select_all' },
];
contextMenu.show({
x: e.clientX,
y: e.clientY,
items,
onSelect: async (value) => {
if (!view) return;
if (value === 'copy') {
const text = view.state.sliceDoc(sel.from, sel.to);
await writeClipboard(text);
} else if (value === 'cut') {
const text = view.state.sliceDoc(sel.from, sel.to);
await writeClipboard(text);
view.dispatch({
changes: { from: sel.from, to: sel.to },
selection: { anchor: sel.from },
userEvent: 'input.delete',
});
} else if (value === 'paste') {
const text = await readClipboard();
if (!text) return;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: text },
selection: { anchor: sel.from + text.length },
userEvent: 'input.paste',
});
} else if (value === 'select_all') {
const len = view.state.doc.length;
view.dispatch({
selection: { anchor: 0, head: len },
userEvent: 'select.pointer',
});
view.focus();
}
},
});
});
}
/**
* 清空当前打开的文件(视图回到 empty-state)。
*
* 这条逻辑之前被复制在 4 处(openFile 的 FILE_NOT_FOUND 分支、
* onFilesChanged 的「文件已删」、file-ops.deleteFile、
* settings-dialog 改 dataDir),其中 lastSavedMtimeMs 的重置
* 值还不一致(null / 0)—— 集中到一处后所有路径都走同一份。
*
* 不发「文件已关闭」toast:调用方各自负责"为什么清空"的提示
* (文件被删 / 用户主动删除 / 切换目录)。
*/
function clearCurrentFile() {
state.currentFile = null;
state.currentFileIsMarkdown = false;
state.isDirty = false;
state.lastSavedMtimeMs = null;
state.currentFileRelDir = '';
// P2-4:重置抑制窗口基点。lastWriteMtimeMs 在 P1-2 后存的是 mtime,不是时间戳,
// 但语义仍是「最近一次写盘的 mtime」;clear 后这个 mtime 已经没意义了,
// 留着会让下一个打开的文件在第一秒内把外部修改误判为「自身回声」。
state.lastWriteMtimeMs = 0;
// 取消挂起的自动保存 —— editor 被 unload 不会触发 onChange,旧的 timer 会
// 带着陈旧的闭包一直挂到触发点(虽然 timeout 里的 state 守卫让它 no-op,
// 但闭包长期持有 state/editor/save 引用不释放)。
cancelScheduledAutoSave();
// audit fix (H155):清掉挂起的 live preview。scheduleLivePreview 200ms 后
// 会调 viewer.loadContentLive(editor.getContent()) —— 但 editor 已被 unload
// (getContent 返回 '')→ viewer 仍会渲染空字符串、把刚 clear 的 empty state
// 覆盖掉。用户看到的现象:点了别的文件,刚 clear 完 viewer 又闪一下变白。
if (livePreviewTimer) {
clearTimeout(livePreviewTimer);
livePreviewTimer = null;
}
syncSaveButton();
viewer.clear();
editor.unload();
fileList.setActive(null);
updateWindowTitle();
updateStatusStats();
// 关闭文件:清掉挂起的 AI 修改(避免误应用到下一个打开的文件)
if (ai) ai.onFileChanged(null);
}
/**
* 从磁盘重新读取并替换当前文件的内容(外部修改覆盖本地)。
*
* 把读盘 + 替换编辑器内容 + 替换 viewer 内容 + 同步 mtime 基线
* 集中到一处 —— 「外部修改」脏/干净两个分支共用,避免两边任一被改
* 后另一处遗忘同步。
*
* Bug #2 fix (audit):reload 完成后必须调 ai.onFileChanged() —— 否则
* 用户在 AI 请求飞行期间被外部修改当前文件,AI 拿着「旧 baseContent」
* 算出 diff 后 apply,会把外部的修改静默覆盖掉(数据丢失风险)。
* ai.onFileChanged 内部递增 requestGeneration + 清 pendingDiff + 重置
* chat 状态,让「应用此处」按钮在 AI 重算完之前不再响应任何点击。
*
* @param {{path:string,name:string,mtimeMs?:number,size?:number}} file
* @returns {Promise} 是否成功替换(失败时调用方应保持原状态)
*/
async function reloadCurrentFileFromDisk(file) {
if (!file || !file.path) return false;
// 防 stale reload:用户可能在 await 之前已经切换 / 关闭文件。
// 这种情况不能再用 state.currentFile 重新装载 —— 否则会把 A 的 mtime/size
// 写到 B(或空)的 state 上,导致「切到 C 后状态栏又跳回 A」之类的诡异 UX。
const targetPath = file.path;
// audit fix (C5):记录 reload 启动时的脏状态。await readFile 期间用户可能敲了
// 几下键盘(state.isDirty 已经 true)。如果磁盘版本和本地无差异还好 —— 但我们
// 仍然会调 setExternalContent 把它覆盖掉,用户输入丢失。调用方在「外部修改
// 覆盖本地」分支已经在 reload 之前检查了 isDirty 并弹出冲突对话框,但「无本地
// 修改、外部无变化」的静默 reload 路径里仍然有窗口:readFile 期间用户恰好输入
// 了一个字符。下面这道「启动时脏 → 拒绝 reload」是更稳的兜底,代价是极少数
// 情况下用户要多走一次冲突对话框(概率低、语义也合理)。
//
// audit fix (Phase O-H4):用 editor.isDirty() 而非 state.isDirty。
// state.isDirty 由 markdown-editor 的 updateListener 维护,IME 合成期间
// (CJK 拼音输入)刻意跳过 onChange,state.isDirty 不刷新,但 view.state.doc
// 已含 IME 临时字符。如果此时外部修改触发 reload,state.isDirty 仍是上一帧的
// false → 穿过 dirtyAtStart/state.isDirty 守门 → setExternalContent 全量替换
// doc → IME buffer 静默丢失。editor.isDirty() 直接比 view.state.doc.toString()
// 与 lastSavedDoc,反映编辑器内部实时真相(IME 临时字符也算 dirty)。
const dirtyAtStart = editor.isDirty();
const result = await window.api.readFile(targetPath);
// 第二次 await 后再次校验 —— 用户可能在 readFile 期间切走。
if (!state.currentFile || state.currentFile.path !== targetPath) {
return false;
}
if (!result.ok) {
// audit fix (Phase L3-FS 1A):二进制内容误命名为 .md 时不静默。
// 外部 reload 路径下用户已经在编辑该文件,弹 toast 提示「已阻止」,
// 不替换当前内容(避免用户继续编辑并保存污染版本)。
if (result.error === 'NOT_TEXT') {
showToast(result.message || '该文件不是有效的文本文件,已阻止加载', 'error');
}
return false;
}
// audit fix (C5):await 期间用户开始输入 → 直接拒绝覆盖,把决定权留给调用方。
// 调用方应该已经看过 isDirty=false 才进来的,但这是冗余兜底,成本低。
// audit fix (Round 4 P1-3):readFile await 期间可能触发 IME 合成(用户开始输入
// 中文拼音)—— state.isDirty 由 updateListener 维护,IME 期间刻意跳过 onChange,
// state.isDirty 仍为 false 但 editor 内部 view.state.doc 已含 IME 临时字符。
// 这里改用 editor.isDirty()(与 dirtyAtStart 同源),覆盖 IME 这条 race 路径,
// 否则 setExternalContent 全量替换会摧毁 IME buffer。
if (dirtyAtStart || editor.isDirty()) {
return false;
}
// 重新计算 currentFileIsMarkdown(理论上 reload 是同一路径,但 path 可能改变 —— 如重命名)
state.currentFileIsMarkdown = isMarkdownFile(targetPath);
// audit fix (Round 9):readFile 期间用户可能进入 IME 合成(上面 906 行的
// dirtyAtStart / editor.isDirty 守门用的是同步快照,到这里又过了一个同步
// 路径,理论上 IME 还没起,但 editor.setExternalContent 自己内部又会再
// 检一次 _composing)—— 抛 EDITOR_COMPOSING 时不能让 unhandled rejection
// 冒上来。catch 后保持原状态(不替换 view,让用户 IME 结束后再点一次
// 「重新加载」按钮)。
try {
editor.setExternalContent(result.content);
} catch (e) {
if (e && e.message === 'EDITOR_COMPOSING') {
showToast('正在输入中文,请稍后再试', 'warning', 2000);
return false;
}
throw e;
}
if (state.currentFileIsMarkdown) {
viewer.loadContent(file, result.content);
} else {
viewer.showUnsupportedFile(file);
}
state.lastSavedMtimeMs = result.mtimeMs;
state.currentFile = { ...state.currentFile, ...file, mtimeMs: result.mtimeMs, size: result.size };
state.isDirty = false;
syncSaveButton();
updateWindowTitle();
// reload 也按当前文件的 markdown 状态重新约束视觉模式 + 按钮可用性
// audit fix (Phase 7 M2):{ focus: false } 防止外部 reload 偷焦点 —— 用户
// 可能正在侧栏 / 状态栏操作,强行 focus 到编辑器会打断交互。
// 只重置 setReadOnly / 按钮可用性,不主动抢键盘焦点。
applyEditorMode(state.editorMode, { focus: false });
// Bug #2 fix:告诉 AI controller 文件内容已变更 —— 增量 generation、
// 丢弃当前 pendingDiff(防 stale apply 覆盖外部修改)。
if (ai && typeof ai.onFileChanged === 'function') {
try { ai.onFileChanged(state.currentFile); } catch (e) {
// 不让 ai 异常阻断 reload —— 但留 console 便于排查回归
console.warn('[app] ai.onFileChanged failed:', e);
}
}
return true;
}
/**
* 更新顶部 app-title 文案。
* 没有打开文件 → "Notes";打开了 → "Notes — 文件名";
* 文件脏 → 追加星号后缀。
*
* 长文件名截断(fix:长文件名导致 toolbar 偏移):
* - 写入 #app-title-text(内层 span),CSS 负责 ellipsis 截断
* - 同步写 title 属性,鼠标悬停可看到完整文件名(截断后丢失的信息补救)
* - 没有 #app-title-text(兜底)时退回 #app-title.textContent
*/
function updateWindowTitle() {
if (!els.appTitle) return;
const target = els.appTitleText || els.appTitle;
const base = 'Notes';
if (!state.currentFile) {
target.textContent = base;
target.removeAttribute('title');
return;
}
const dirtyMark = state.isDirty ? ' *' : '';
const fullText = `${base} — ${state.currentFile.name}${dirtyMark}`;
target.textContent = fullText;
// 长文件名截断后丢失信息;title 属性给鼠标用户补回完整文本
target.title = fullText;
}
/**
* 视图模式切换工具栏
*
* 重要:setMode 写 dataset + 高亮按钮;cycle 走 onChange 让 app 同步持久化。
* 启动时直接调用 setMode() 不触发 onChange —— 因为此时 app 还没初始化完成,
* 而且持久化已经在 settings-store.load() 时完成。
*/
const editorToolbar = new EditorToolbar({
elements: {
btnPreview: els.btnModePreview,
btnEdit: els.btnModeEdit,
btnSplit: els.btnModeSplit,
},
onChange: (mode) => {
settingsStore.update({ editorMode: mode });
applyEditorMode(mode);
},
});
/**
* 双栏左侧占比:clamp 到 0.2..0.8(与主进程 app:save-settings 的校验一致),
* 非法值回落到 0.5。
* @param {unknown} value
* @returns {number}
*/
function clampSplitRatio(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 0.5;
return Math.max(0.2, Math.min(0.8, n));
}
/**
* 应用视图模式到 DOM 和编辑器
*
* Stage 8:非 Markdown 文件(.txt/.py/.json/...)只支持编辑 —— 即使 state.editorMode
* 持久化的是 'preview' 或 'split',DOM 视觉模式也强制回落到 'edit';toolbar 的
* preview/split 按钮 disabled(hover 提示原因)。
*
* 注意:state.editorMode 本身保持不变 —— 用户回到 Markdown 后能恢复上次选的模式。
*/
/**
* 应用视图模式到 DOM 和编辑器
*
* Stage 8:非 Markdown 文件(.txt/.py/.json/...)只支持编辑 —— 即使 state.editorMode
* 持久化的是 'preview' 或 'split',DOM 视觉模式也强制回落到 'edit';toolbar 的
* preview/split 按钮 disabled(hover 提示原因)。
*
* 注意:state.editorMode 本身保持不变 —— 用户回到 Markdown 后能恢复上次选的模式。
*
* @param {string} mode - 'preview' | 'edit' | 'split'
* @param {object} [opts]
* @param {boolean} [opts.focus=true] - 是否在切到 edit/split 时聚焦编辑器。
* audit fix (Phase 7 M2):外部 reload(fs-watcher 触发的 reloadCurrentFileFromDisk)
* 不应该偷焦点 —— 用户可能正在侧栏 / 状态栏操作,强行 focus 到编辑器会
* 打断交互。reload 路径显式传 { focus: false }。默认 true 保持用户主动切模式时的行为。
*/
function applyEditorMode(mode, opts) {
if (!EDITOR_MODES.includes(mode)) return;
state.editorMode = mode;
// 非 md 文件:把视觉模式强制回落到 'edit',但不写 state.editorMode。
const isMd = state.currentFileIsMarkdown;
const visualMode = isMd ? mode : 'edit';
// editorToolbar.setMode 内部已 _setDOM 写 body[data-editor-mode] + 高亮按钮;
// 不要再单独 dataset,否则两边写同一属性会产生「两次 setAttribute」的
// MutationObserver 噪音,调试时易误以为有别的代码在动 DOM。
editorToolbar.setMode(visualMode); // 同步按钮高亮(disabled 状态由下方负责)
// 只读:仅在 md 文件 + 视觉模式为 preview 时才把编辑器设为只读;
// 非 md 文件强制可写(预览本来就不渲染,setReadOnly(true) 会让用户失去写入能力)。
editor.setReadOnly(visualMode === 'preview' && isMd);
// 切到 edit / split 时聚焦编辑器 —— 但外部 reload 路径不希望被 focus 偷走
const wantFocus = opts ? opts.focus !== false : true;
if (wantFocus && (visualMode === 'edit' || visualMode === 'split')) {
// 异步聚焦:等 DOM 更新后再 focus,否则可能焦点丢失
requestAnimationFrame(() => editor.focus());
}
// 模式按钮可用性:非 md 文件禁用 preview / split(edit 仍可用)
if (els.btnModePreview) {
els.btnModePreview.disabled = !isMd;
els.btnModePreview.title = isMd
? '预览模式'
: '预览模式(仅 Markdown 支持)';
els.btnModePreview.setAttribute('aria-disabled', isMd ? 'false' : 'true');
}
if (els.btnModeSplit) {
els.btnModeSplit.disabled = !isMd;
els.btnModeSplit.title = isMd
? '双栏模式'
: '双栏模式(仅 Markdown 支持)';
els.btnModeSplit.setAttribute('aria-disabled', isMd ? 'false' : 'true');
}
updateStatusMode();
updateStatusStats();
updateStatusScroll();
}
/**
* 状态栏:模式 chip(预览 / 编辑 / 双栏)
*
* 切换 icon 跟着 mode 变:preview=eye / edit=pencil / split=split,
* 让 chip 本身看着像个"切换器",而不只是一个状态指示。
*/
function updateStatusMode() {
if (!els.statusMode) return;
const mode = state.editorMode
|| window.api?.settingsSchema?.DEFAULT_SETTINGS?.editorMode
|| '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;
els.statusMode.dataset.mode = mode;
// audit M (a11y):SR 只读 aria-label 不读 textContent;同步让屏幕阅读器用户
// 听到「切换视图模式(当前:预览)」,与可见 chip 文字一致。
els.statusMode.setAttribute('aria-label', `切换视图模式(当前:${labels[mode] || mode})`);
}
/**
* 状态栏:AI chip —— 承载 AI 过程/错误/待确认状态。
*
* controller + panel 通过 notify(text, type) 回调进来,本函数负责:
* - 把 type 写到 data-ai-state(CSS 据此切换 running 脉冲 / danger 色 / accent-soft 高亮)
* - 把 text + "AI · " 前缀写到 #status-ai-label(text 为空时只显示 "AI")
* - 更新 title(鼠标悬停看完整文案 —— label 60 字截断)
*
* 见 [feedback-ai-tips-into-status-bar]:状态贴近作用区域,AI 提示不再占
* AI dock 底部单独一行,而是收进底部状态栏这个高频状态展示区。
*
* @param {string} text - chip 显示文案(不含 "AI · " 前缀)
* @param {'idle'|'running'|'cancelled'|'error'|'success'|'diff'|'info'} [type]
*/
function updateStatusAi(text, type) {
if (!els.statusAi || !els.statusAiLabel) return;
const safeType = type || 'idle';
els.statusAi.dataset.aiState = safeType;
// text 为空 → 只显示 "AI";有内容 → "AI · {text}"。60 字截断保护
// 状态栏窄时不至于把 count / scroll 挤出去。
const label = text && text.length > 0 ? `AI · ${text}` : 'AI';
els.statusAiLabel.textContent = label.length > 60 ? `${label.slice(0, 59)}…` : label;
els.statusAi.title = text || 'AI 修改';
// audit M (a11y):状态栏 chip 文字变化(idle/running/diff/error)对屏幕阅读器
// 不可见 —— 只读 aria-label。把 state 拼进 aria-label 让 SR 用户能感知 AI 状态。
// 同时 chip 也是 toggle(点击开/关 AI 面板),aria-pressed 反映 dock 状态。
const stateHint = safeType === 'idle' ? '' : ` - ${text || safeType}`;
els.statusAi.setAttribute('aria-label', `AI 修改${stateHint}`);
// dock open state 由 syncAiButton 维护;这里只补 aria-label。
}
/**
* 状态栏:字数 / 行数(编辑与查看共用)
* - 编辑模式:直接读编辑器 view(实时反映用户输入)
* - 预览模式:读 viewer 渲染后的纯文本
*/
let statsDebounceTimer = null;
function updateStatusStats() {
if (statsDebounceTimer) {
clearTimeout(statsDebounceTimer);
// audit fix (K2-L9):cancel 后立刻置 null —— 与 markdown-editor.js 的
// _scrollFrame 同款风格。中途 cancel 到下一次 schedule 之间的窗口期,
// 变量不再指向已 cancel 的旧句柄;后续若有「如果还挂着就 fire cancel
// toast」之类的检查也不会踩坑。
statsDebounceTimer = null;
}
statsDebounceTimer = setTimeout(() => {
statsDebounceTimer = null;
if (!els.statusStats) return;
// 没有打开任何文件时,把字数 chip 直接藏掉,避免显示「0 词 · 0 行」误导用户
if (!state.currentFile) {
els.statusStats.hidden = true;
els.statusStats.textContent = '';
return;
}
let stats = { words: 0, lines: 0 };
try {
if (state.editorMode === 'preview') {
stats = viewer.getStats();
} else {
stats = editor.getStats();
}
} catch (e) {
// getStats 抛错(常发生在 pagehide / file clear 中途 view 已 dispose)
// —— 不重置 textContent 让 chip 保留上次值,同时 console.warn 留排查线索
console.warn('[app] updateStatusStats getStats failed:', e);
}
// Phase N M-2 fix:空文件(chars=0 / lines=0)不显示「0 词」chip。
// 旧实现 formatStatusStats({words:0, lines:0}) 返回 '0 词',状态栏中间一直
// 显示一个误导 chip("我打开了一个真的文件,但字数是 0")。
// viewer 已用「(空文件)」占位文案提示,这里让 stats chip 也不出现,双重反馈一致。
const isEmpty = stats.chars === 0 && stats.words === 0 && stats.lines === 0;
if (isEmpty) {
els.statusStats.hidden = true;
els.statusStats.textContent = '';
} else {
els.statusStats.textContent = formatStatusStats(stats);
els.statusStats.hidden = false;
}
}, 200);
}
/**
* 状态栏:滚动进度
* - preview:viewer 的进度(阅读到哪儿)
* - edit:editor 的进度(编辑器里滚到哪儿)
* - split:viewer 的进度(双栏同步滚动,两边值一致,读者视角)
*/
let scrollFrame = null;
function updateStatusScroll() {
if (!els.statusScroll) return;
els.statusScroll.hidden = false;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
// audit fix (K2-L9):cancel 后立刻置 null,与 statsDebounceTimer 同款。
scrollFrame = null;
}
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
const progress =
state.editorMode === 'edit'
? editor.getScrollProgress()
: viewer.getScrollProgress();
els.statusScroll.textContent = `${Math.round(progress * 100)}%`;
});
}
/**
* 聚焦模式:把可见性写到 DOM(不触发持久化)。
*
* 启动时一次应用、避免无意义写盘。
* 副作用:focus 模式下 viewer 占据整个窗口,需要重新计算 grid 列。
*/
function applyFocusMode(target) {
state.focusMode = !!target;
document.body.dataset.focusMode = state.focusMode ? 'true' : 'false';
}
/**
* 切换聚焦模式(同时持久化)。
*/
function setFocusMode(next) {
const target = typeof next === 'boolean' ? next : !state.focusMode;
if (target === state.focusMode) return; // 状态没变 → 跳过 DOM 与写盘
applyFocusMode(target);
// audit #1:持久化失败必须告知用户,否则下次启动状态丢失无迹可循。
settingsStore.update({ focusMode: state.focusMode }).catch((e) => {
showToast(`聚焦模式状态保存失败:${e?.message || '未知错误'}`, 'error', 3500);
});
}
/**
* 工具栏
*
* 实例不需保留:构造函数里已经把所有按钮的 click 事件挂好,外部不需要再调用任何方法。
*/
new Toolbar({
elements: {
btnTheme: els.btnTheme,
btnPin: els.btnPin,
btnSettings: els.btnSettings,
btnResetLayout: els.btnResetLayout,
btnMin: els.btnMin,
btnMax: els.btnMax,
btnClose: els.btnClose,
},
onThemeClick: () => cycleTheme(),
onPinClick: async () => {
const next = !state.alwaysOnTop;
try {
await window.api.setAlwaysOnTop(next);
state.alwaysOnTop = next;
syncPinButton(next);
} catch (e) {
console.error('[app] setAlwaysOnTop 失败:', e);
showToast(`置顶切换失败:${e.message}`, 'error');
}
},
onSettingsClick: () => openSettingsDialog(),
onResetLayoutClick: () => resetLayout(),
onMinimize: () => window.api.minimizeWindow(),
onToggleMaximize: () => window.api.toggleMaximizeWindow(),
onClose: () => window.api.closeWindow(),
});
/**
* 保存按钮(手动保存入口)
*/
if (els.btnSave) {
els.btnSave.addEventListener('click', () => {
return save();
});
}
/**
* 状态栏 mode chip:直接点击切换视图模式(少一次去工具栏的旅程)
* 与顶栏的视图按钮组是同一份功能的快捷入口 —— 跟设置面板不重复入口原则一致。
*/
if (els.statusMode) {
els.statusMode.addEventListener('click', () => editorToolbar.cycle());
}
/**
* 状态栏路径 chip:点击进入对应位置
*
* - 有打开文件 → reveal 文件(macOS Reveal / Win·Linux 选中,等同于侧栏右键「在文件夹中显示」)
* - 没打开文件但有浏览目录 → 在文件管理器中打开该目录
* - 都没有 → 静默 no-op(保持按钮 hover 视觉一致,但不做任何 IPC 调用)
*
* 旧版本无文件时点击完全没反应(注释里也写"静默无副作用"),但用户期望点击路径
* 必须能去到一个对应位置 —— 让无文件状态也"活"起来。
*/
if (els.statusPath) {
els.statusPath.addEventListener('click', () => {
if (!fileOps) return;
if (state.currentFile && state.currentFile.path) {
fileOps.revealFile(state.currentFile);
} else if (state.currentDir && window.api && typeof window.api.openDir === 'function') {
// 走专门的 shell:open-dir IPC:dataDir 边界 + 目录 stat 检查都在主进程,
// renderer 只负责把当前浏览目录透传过去。
window.api.openDir(state.currentDir).then((result) => {
if (!result || !result.ok) {
showToast(`打开目录失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
}
});
}
});
}
/**
* AI 修改入口:
* - 侧栏底部 btn-ai:打开/关闭 AI 输入面板(与「数据目录」二级链接分组,主操作用 .sidebar-new-btn 描边样式)
* - createAiController:状态机实例,持有 chat/diff 两个面板
* - 同步 btn-ai 的 aria-pressed 视觉态(与 chatPanel.isOpen 联动)
*
* AI 是「点开就弹,关闭就退」的命令式入口,不进 settingsStore。
*/
function mountAiPanel() {
if (!els.btnAi) return;
// 创建控制器(在 settings 加载完后)
ai = createAiController({
api: window.api,
editor,
state,
toast: showToast,
getSettings: () => settingsStore.getAll(),
// preview 模式修复:AI apply region / all 后立即让 viewer 重新渲染,
// 否则 viewer 仍显示旧内容 → 用户感觉"点了应用,文件没反应"。
// split 模式下 scheduleLivePreview 也会跑(双触发幂等无害)。
onAfterApply: () => {
if (state.currentFile) viewer.loadContentLive(editor.getContent());
},
// 未配置时:直接打开设置并定位到 AI 段。比 toast 更直观 —— 见
// [[feedback-modal-collapses-secondary-panels]](开设置会自动收 dock,关后还原)。
onOpenSettings: () => openSettingsDialog({ focusSection: 'ai' }),
// AI 过程/错误/待确认状态 → statusbar 上的 #status-ai chip。
// 详见 [feedback-ai-tips-into-status-bar] —— dock 底部不再单开一行。
notify: updateStatusAi,
// audit fix (Phase L3-AI 4):AI dock 与设置对话框双向互斥。
// openSettingsDialog 已在打开时主动 close dock、close 后还原;但用户在设置打开
// 期间手动点 AI 入口(btn-ai / 状态栏 chip / 快捷键)会让 dock 透出 modal 背景后,
// 视觉重叠 + 误点。controller 用这个 getter 在 togglePanel 里把住开关。
isSettingsOpen: () => state.settingsDialogOpen,
});
// 挂载 chat dock + diff 面板到各自的 DOM 容器
if (els.aiChatDock && els.appAi) {
ai.bindMountPoint(els.aiChatDock, els.appAi, els.aiSplitter);
}
// 侧栏底部按钮:toggle。controller 内部已经判断"未配置时给提示"
els.btnAi.addEventListener('click', () => {
syncAiButton(ai.togglePanel());
});
// 状态栏 AI chip:toggle dock(与 btn-ai 同款入口;未配置时 controller 会跳设置对话框)。
// btn-ai pressed 态由下方 aiObserver 跟随 aiChatDock.hidden 自动同步 —— 无需手工设置。
// 沿用 [[feedback-save-button-always-visible]] 的原则:chip 始终可见,
// idle 态也保留 "AI" 标签(不要 hidden 切换),让用户随时能找到入口。
if (els.statusAi) {
els.statusAi.addEventListener('click', () => {
syncAiButton(ai.togglePanel());
});
}
// 监听 chat 面板显隐变化 → 同步按钮 pressed 态(被 dock 自身的 × 按钮关闭时)
// 用 MutationObserver 监听 aiChatDock 的 hidden 属性变化
// 持有在模块级变量上,window unload 时统一 disconnect(避免 listener 泄漏)
//
// audit fix (renderer-M8):旧实现没断开旧 observer 就直接 new —— 单实例
// 生产路径下不会触发,但 HMR / 测试 / 任何未来的 re-mount 路径会让旧 observer
// 闭包持续引用 syncAiButton + aiChatDock,造成 mutation 回调重复触发。
// mountAiPanel 是幂等保证:每次进入先 dispose 旧的,再创建新的。
if (aiObserver) {
aiObserver.disconnect();
aiObserver = null;
}
aiObserver = new MutationObserver(() => {
if (!els.aiChatDock) return;
syncAiButton(!els.aiChatDock.hidden);
});
aiObserver.observe(els.aiChatDock, { attributes: true, attributeFilter: ['hidden'] });
}
/**
* 订阅 viewer 的 heading 变化事件:
* - 同步状态栏中央的「当前章节」面包屑
*
* viewer.onHeadingChange 会立刻用当前 heading 触发一次回调(即使为 null),
* 所以订阅后无需手动同步一次。
*/
function watchViewerHeading() {
viewer.onHeadingChange((heading) => {
if (els.statusHeading) {
if (heading && heading.text) {
// 多于 32 字截断,避免长 heading 撑爆状态栏
const text = heading.text.length > 32
? `${heading.text.slice(0, 31)}…`
: heading.text;
els.statusHeading.textContent = text;
els.statusHeading.title = heading.text; // 鼠标悬停查看完整
els.statusHeading.hidden = false;
} else {
els.statusHeading.textContent = '';
els.statusHeading.title = '';
els.statusHeading.hidden = true;
}
}
});
}
/**
* 主题切换:dark ↔ light
*/
async function cycleTheme() {
const current = settingsStore.get('theme');
const next = current === 'dark' ? 'light' : 'dark';
try {
await settingsStore.update({ theme: next });
} catch (e) {
showToast(`主题切换失败:${e.message}`, 'error');
return;
}
applyTheme(next);
syncThemeButton(next);
showToast(`主题:${next === 'dark' ? '暗色' : '亮色'}`, 'info', 1500);
}
/**
* 保存当前文件
*
* 流程:取编辑器内容 → 写盘 → 更新 mtime → markSaved → 通知文件列表(mtime 变化)
*
* 外部修改竞态防护(audit #2):writeFile 传 expectedMtimeMs = state.lastSavedMtimeMs;
* 主进程会比对磁盘 mtime,若文件已被外部修改则返回 FILE_CHANGED_EXTERNALLY,
* 此时弹冲突对话框让用户选择「使用外部版本 / 强制覆盖 / 取消」。
*
* audit fix (Phase 3 C1):两处竞态保护
* 1. 入口 cancelScheduledAutoSave() —— 防止 auto-save 防抖定时器在 writeFile
* await 飞行期内 fire 触发第二次 save。第二次 save 捕获的是「旧
* lastSavedMtimeMs」(markSaved 还要等 writeFile 解析后才跑),磁盘 mtime
* 已经变成第一次写入后的值 → mtime 校验失败 → 触发「文件外部修改」冲突
* 对话框 —— 实际是用户自己的自动保存把自己弹红了。
* 2. inFlightSave sentinel —— save#2 在 save#1 飞行期内到达,直接 await 同一
* 个 Promise,避免重复 IPC + 重复写盘 + 重复 mtime 抖动。
*
* @param {object} [opts]
* @param {boolean} [opts.silent=false] - true 时不弹「已保存」toast(自动保存路径用)
* 失败无论 silent 与否都弹 error toast
* @param {boolean} [opts.skipMtimeCheck=false] - true 时跳过 mtime 校验(用于「强制覆盖」二次保存)
* @returns {Promise} true 表示保存成功(或没有需要保存的内容)
*/
async function save(opts = {}) {
const silent = !!opts.silent;
const skipMtimeCheck = !!opts.skipMtimeCheck;
// 审计修复 (Round 11 P0-1):IME 合成期拒绝手动保存。
// updateListener 的 !isComposing 守卫屏蔽 IME 期 onChange(避免每字触发 scheduleLivePreview /
// scheduleAutoSave),但 editor.getContent() 仍会返回 IME buffer 里的半截内容。
// 用户按 Ctrl+S / 点保存按钮 / Ctrl+R / 关窗手动选「保存」走 save() 路径,
// 不守门的话会把 "niha" / "nihあ" 这种半截 IME 字符写盘。
// 早退返回 { ok: false, reason: 'IME_COMPOSING' },主进程已落盘的内容安全;
// 静默路径(Ctrl+R、关窗 onSaveRequest)不弹 toast —— 后者已经会走主进程原生框提示。
// auto-save 已在 scheduleAutoSave 里守门,这里再守一次兜底(手动入口的 IME 守门由这里统一)。
if (typeof editor !== 'undefined' && typeof editor.isComposing === 'function' && editor.isComposing()) {
if (!silent && typeof showToast === 'function') {
showToast('正在输入,请稍后再试', 'warning', 1800);
}
return false;
}
// audit fix (C1):入口先取消挂起的 auto-save 定时器,防止它在 writeFile
// await 期间 fire 触发第二次 save 把磁盘 mtime 提前用掉。
cancelScheduledAutoSave();
// 所有调用方都累加 savingCount,syncSaveButton 据此显示「保存中…」态。
// 共享路径(inFlightSave 已存在)也计数 —— 否则 Call#1 退场清 0 时
// Call#2 还在飞行,is-saving 会过早熄灭。归零才清,UI 不抖。
state.savingCount++;
syncSaveButton();
// audit fix (Round 3 bug 2026-08):IME 合成期 onChange 被 markdown-editor.js
// updateListener 跳过,state.isDirty 可能 stale(editor.doc 有 IME 字符但
// state.isDirty 还是 false)。在 save() 入口同步一次,让下面的 !state.isDirty
// 早退检查可靠 —— 否则用户 IME 期间点保存会走早退返回「已是最新」,IME 字符
// 不会落盘。详见 syncDirtyFromEditor 注释块。
syncDirtyFromEditor();
// 关键修复(savingCount 泄漏):用 try/finally 包整个 save() 函数体,保证
// savingCount 与 inFlightSave 哨兵在任何早退 / 异常 / 共享路径下都能平衡。
// 之前在内部共享路径 / 主路径各放一个 finally,会漏掉 `!currentFile` /
// `!isDirty` 等 5 个早退 return —— savingCount 永远卡在 >0,保存按钮
// 永久显示「保存中…」且后续 save() 永远命中共享分支(共享检查始终命中 +
// inFlightSave 被未完成路径泄漏)。教训:内部嵌套 try/finally 处理多个
// 计数器时代码脆弱,必须用一个外层 try/finally 一站式管理。
// 见 [[project-settings-saving-flag]]。
try {
// audit fix (Phase N Q-fix):toast 决策权统一收归 save() outer。
//
// IIFE 内部不弹「已保存」toast —— 改为 save() outer 在 silent=false + ok 时弹。
// IIFE 内部继续按原样弹错误 toast(FILE_CHANGED_EXTERNALLY 各分支 / FILE_NOT_FOUND /
// PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED / errno 兜底 / catch IPC_ERROR),
// 这些与 silent 无关,所有 silent 都弹。
//
// 修四个真 bug:
// 1. silent=false manual caller 共享 IIFE + IIFE stale → 旧逻辑 silent=false
// 但 success=false → 不弹 toast,用户按 Ctrl+S 切文件后完全无反馈。
// 现在 silent=false + reason='STALE' → 弹「文件已切换,未保存到磁盘」。
// 2. auto-save silent=true 飞行期 user 按 Ctrl+S 写盘成功 → 旧逻辑 IIFE
// silent=true 不弹「已保存」,shared manual silent=false 也不弹,用户无反馈。
// 现在 manual silent=false → save() outer 弹「已保存」,与 IIFE silent
// 无关(IIFE 自己不弹)。
// 3. onSaveRequest silent=false 与 main.js confirmDiscardIfDirty 错误对话框
// 双重反馈。修复点不在 save() 内,由调用方改 silent=true(见 line ~2768)。
// 4. file:write catch message 字段直回 e.message(英文 + 路径),与
// _friendlyWriteError 对齐由 main.js 修。
//
// 共享路径不弹「已保存」是错的:silent=false manual caller 必须有反馈。让
// owner 与 shared 都各自决定 silent 行为:owner 路径 silent=false 弹一次,
// shared 路径 silent=false 也弹一次 —— force_overwrite 递归 inner save() +
// shared manual 会出现双重「已保存」toast(这是 Phase N 之前已有的边缘 case,
// 取舍让所有 silent=false caller 都看到反馈更重要)。
if (inFlightSave && !skipMtimeCheck) {
const result = await inFlightSave;
decideSaveToast(silent, result, showToast);
return !!(result && result.ok);
}
if (!state.currentFile) {
// audit fix (Round 7 S-3):区分「没有打开文件」与「文件已是最新」。
// 旧版用 NO_CHANGES → toast 「已是最新」,但用户视角是「我什么都没打开,
// 按 Ctrl+S 当然没有东西需要存」——「已是最新」暗示「有文件且未改动」,
// 语义错位。改用 NO_FILE → toast 「没有打开文件」,给用户准确的反馈。
// auto-save silent=true 路径被 decideSaveToast 头部 `if (silent) return`
// 拦下,不弹。
decideSaveToast(silent, { ok: true, reason: 'NO_FILE' }, showToast);
return true;
}
if (state.editorMode === 'preview') {
// Bug #1 fix (audit):预览模式正常情况下没有脏内容(编辑器只读、用户输入被屏蔽),
// 但 AI applyUserChange 是通过 view.dispatch() 改 doc,会绕过 EditorView.editable
// 的只读检查。所以「preview 模式 + AI 已 apply + 切文件/关闭」路径上,
// 早退 return true 会让主进程以为保存成功,关闭程序时 AI 改动就丢了。
// P3 fix:之前这里调 applyEditorMode('edit') 永久把 state.editorMode 改成 'edit',
// 用户的 preview 偏好丢了;改为只解锁编辑器让 writeFile 走通,保存后再恢复 readOnly。
if (!state.isDirty) {
decideSaveToast(silent, { ok: true, reason: 'NO_CHANGES' }, showToast);
return true;
}
editor.setReadOnly(false);
}
if (!state.isDirty) {
// fix(audit 2026-08):auto-save 静默写盘后 state.isDirty=false,用户手动点
// 保存按钮落入此路径。旧逻辑静默 return 让用户觉得「按钮没反应 / 没保存
// 上」,实际文件已最新。给一个简短「已是最新」info 让点击有反馈。
// auto-save silent=true 路径被 decideSaveToast 头部 `if (silent) return` 拦下,不弹。
decideSaveToast(silent, { ok: true, reason: 'NO_CHANGES' }, showToast);
return true;
} // 没有改动,无需保存
// audit fix (C1):把「实际写盘 + 冲突处理 + 状态更新」整段包进 IIFE 赋值给
// inFlightSave,try/finally 确保异常路径也清掉 sentinel。force_overwrite 的
// 二次保存走 skipMtimeCheck=true,上面早退判断会放它进来重新写一次新 Promise。
inFlightSave = (async () => {
// L1 fix (audit):标记本 save 调用是否解锁了 preview 模式的只读,
// finally 统一还原 —— 之前只在成功路径 + catch 路径 2 处还原,
// discard_local 完成 / 文件外部修改后切走 / 各类早退 return false 路径
// 都会漏掉,导致用户切回编辑器发现只读没恢复。
// 条件用「调用 save 时的 editorMode 快照」,不用 await 后的 state,
// 因为 save 飞行期间用户可能切文件 / 切回 edit 模式,state.editorMode
// 已变 —— 我们只关心「这次 save 是否动过只读」。
const restoredReadOnly = state.editorMode === 'preview' && state.currentFileIsMarkdown;
const content = editor.getContent();
// 锁定 save 入口到当前文件路径:用户可能在 await 期间切文件,必须用快照
// 而不是 state.currentFile.path(避免 reload 时把 A 的 mtime 写到 B 上)。
const targetPath = state.currentFile.path;
const targetName = state.currentFile.name;
// expectedMtimeMs 让主进程比对磁盘 mtime,挡住「fs:changed 还没到 → Ctrl+S 静默覆盖」
const expectedMtimeMs = skipMtimeCheck ? null : state.lastSavedMtimeMs;
try {
const result = await window.api.writeFile(targetPath, content, expectedMtimeMs);
// 防 stale save:writeFile 期间用户切走了 → 不能继续往下走(会把脏状态
// 标干净、把 lastSavedMtimeMs 写到新文件的 state 上)。
//
// Phase 2 audit 修复 (P1 #3):原代码无条件 `{ok:false, reason:'STALE'}`,
// 但 writeFile 这一行**已经 resolve**了 —— 数据其实已经落盘到 targetPath
// (result.ok=true)。关窗路径(onSaveRequest silent=true → reportSaveResult
// → main.js confirmDiscardIfDirty)会把 ok=false 误判为「保存失败」并弹
// 原生对话框挡住用户退出,即使 A 的字符已经安全写盘。修复:按 writeFile
// 实际结果区分 —— result.ok=true(writeFile 写盘成功)→ ok=true 关窗可
// 以安全退出;result.ok=false(writeFile 返回 FILE_CHANGED_EXTERNALLY 等
// 错误)→ ok=false 关窗必须拦截。两种情况都标 reason:'STALE' 让 silent=false
// caller 拿到「文件已切换」反馈。
if (!state.currentFile || state.currentFile.path !== targetPath) {
return { ok: !!(result && result.ok), reason: 'STALE' };
}
if (!result.ok) {
// 文件在外部被修改(audit #2 触发)—— 弹冲突对话框
if (result.error === 'FILE_CHANGED_EXTERNALLY') {
const choice = await showConfirmDialog({
title: '文件在外部被修改',
message: `「${targetName}」在你编辑期间被外部修改。\n\n如何处理?`,
icon: 'conflict',
buttons: [
{ value: 'cancel', label: '取消(保留本地)', variant: 'secondary' },
{ value: 'discard_local', label: '放弃本地,使用外部版本', variant: 'secondary' },
{ value: 'force_overwrite', label: '强制覆盖(保留我的版本)', variant: 'primary' },
],
});
// dialog 期间用户切走 → 不再写任何 state(已经是新文件的 state)
if (!state.currentFile || state.currentFile.path !== targetPath) {
return { ok: false, reason: 'STALE' };
}
if (choice === 'discard_local') {
// 用外部版本覆盖本地
const reloadResult = await window.api.readFile(targetPath);
// 再次校验(reloadResult 又是一次 await)
if (!state.currentFile || state.currentFile.path !== targetPath) {
return { ok: false, reason: 'STALE' };
}
if (reloadResult.ok) {
// audit fix (Round 9):和 reloadCurrentFileFromDisk 一致,readFile
// 期间用户可能触发 IME 合成 → setExternalContent 抛 EDITOR_COMPOSING
// 会让 unhandled promise rejection 冒上来。catch 后给用户「请稍后再试」
// 的 toast,状态保持原样(不替 user setExternalContent,也不清 dirty)。
let setOk = false;
try {
editor.setExternalContent(reloadResult.content);
setOk = true;
} catch (e) {
if (e && e.message === 'EDITOR_COMPOSING') {
showToast('正在输入中文,请稍后再试', 'warning', 2000);
} else {
throw e;
}
}
if (setOk) {
// P2 fix:非 md 文件(.txt / .json / ...)viewer 走的是 showUnsupportedFile 空态,
// 这里再调 loadContent 会破坏空态 + 触发 markdown 渲染(plain text 里也可能出现
// 「# section」被识别成 heading 而误渲染)。按 currentFileIsMarkdown 分支处理。
if (state.currentFileIsMarkdown) {
viewer.loadContent(state.currentFile, reloadResult.content);
} else {
viewer.showUnsupportedFile(state.currentFile);
}
state.lastSavedMtimeMs = reloadResult.mtimeMs;
state.currentFile = { ...state.currentFile, mtimeMs: reloadResult.mtimeMs, size: reloadResult.size };
state.isDirty = false;
syncSaveButton();
// audit fix (Round 9):和 reloadCurrentFileFromDisk 一致,丢弃本地
// 切换到外部版本后必须通知 AI controller 清 pendingDiff —— 否则 AI
// 可能基于 baseContent(本地)算 diff 后 setExternalContent 把本地内容
// 又盖回去。
if (ai && typeof ai.onFileChanged === 'function') {
try { ai.onFileChanged(state.currentFile); } catch (err) {
console.warn('[app] ai.onFileChanged failed (discard_local):', err);
}
}
showToast('已切换到外部版本', 'info', 1800);
}
} else if (reloadResult.error === 'NOT_TEXT') {
// audit fix (Phase L3-FS 1A):外部版本已变成二进制(同上)
showToast(reloadResult.message || '外部版本不是有效文本,已取消切换', 'error');
} else {
showToast(`读取外部版本失败:${reloadResult.message || reloadResult.error}`, 'error');
}
return { ok: false, reason: 'CONFLICT_DISCARDED' };
}
if (choice === 'force_overwrite') {
// 二次保存,跳过 mtime 校验(finally 仍会还原只读 —— 不需要 preserve)
// audit fix (Phase N-B6):强制覆盖是用户主动决策,必须给「已保存」反馈。
// 原 silent=true 路径只在 auto-save 静默场景下合理;用户点完「强制覆盖」
// 按钮后看到毫无反馈会困惑。强制 silent=false 让递归 save 走到「已保存」toast。
//
// Phase N Q-fix:递归 inner save() 走 save() outer silent=false 弹「已保存」,
// 返回 {ok, reason}。这里直接透传 inner 结果 —— 当前 outer IIFE 把它当作
// 本次写盘的结果返回(错误 toast 由 inner IIFE 内部已弹,shared path 不会
// 再弹错误 toast,避免重复;force_overwrite 自身的「已保存」由 inner 触发)。
// audit fix (Round 8 S-1):必须把 inner 的 boolean 还原成 {ok, reason}
// 信封。save() 自身返回的是 boolean(line ~1909 `return !!(result && result.ok)`),
// 旧代码 `return save({...})` 让 IIFE 解析成 boolean `true` →
// outer 的 `!!(result && result.ok)` 变成 `!!(true && undefined)` = **false**。
// 后果:强制覆盖明明写盘成功,outer save() 却上报失败 ——
// · 关窗路径 onSaveRequest 收到 ok=false → 主进程弹「保存失败,已取消退出」
// · save_and_open 收到 false → 目标文件不再打开,用户只看到「已保存」toast
// reason='DELEGATED' 让 decideSaveToast 知道 inner 已弹过反馈,outer 不重复弹。
const innerOk = await save({ silent: false, skipMtimeCheck: true });
return { ok: innerOk, reason: 'DELEGATED' };
}
// cancel / 默认:什么都不做,保留本地脏状态
//
// audit fix (Phase N-A3):**不**推进 state.lastSavedMtimeMs 是有意为之。
// - onFilesChanged 的 cancel 分支会推进 mtime(见 app.js:2572-2573 附近),
// 因为下一次外部修改才会再触发 fs:changed;推进 mtime 避免重复弹窗。
// - save() 的 cancel 分支**不**推进,因为下次 Ctrl+S 会再次调 writeFile,
// 推进 mtime 会让本次未决冲突被静默绕过 —— 用户没选「保留本地」就
// 直接写盘覆盖外部版本,外部改动丢失。
// - 所以 save() cancel 必弹「每次 Ctrl+S 都问一次」,直到用户选
// force_overwrite 或 discard_local。这是有意 UX:未决冲突不能
// 让自动跳过。
// - 不要「修复」成对称行为(也推 mtime),那会引入静默覆盖。
showToast('已保留本地版本', 'info', 1800);
return { ok: false, reason: 'CONFLICT_KEPT' };
}
if (result.error === 'FILE_NOT_FOUND') {
// audit fix (Phase O-H6):先 clearCurrentFile 再异步救回脏内容,
// 修复 race:之前直接 clearCurrentFile() 把 state.currentFile 置 null,
// 但当前路径没有任何 rescue。紧接着 fs-watcher 把删除事件以
// files:changed 推给 renderer,onFilesChanged 入口 `if (!state.currentFile)
// return;` 提前返回,line 2580 的 rescued-*.md 救回路径永远不触发,
// 用户的脏内容静默丢失。
//
// 修复策略:先 clearCurrentFile(让后续 fs-watcher 事件提前返回,避
// 免双 rescue),再 fire-and-forget 调 file:create 把 content(IIFE
// 顶部 line 1594 已取)救回到 `<原名>-rescued--.md`。
// 即便 createFile 期间用户切到其它文件或关闭窗口,rescue 文件也已
// 在数据目录里,下次启动可见。
//
// 注意:先 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 期间用户输入的字符都在
// editor view 里,必须现在取。content 闭包变量是 IIFE 入口的
// 旧快照,不含飞行期新增字符。
const latestContent = (typeof editor.getContent === 'function')
? editor.getContent()
: content;
// 先 clear,让 fs-watcher race 路径 entry 提前返回
clearCurrentFile();
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);
});
return { ok: false, reason: 'FILE_NOT_FOUND' };
}
if (result.error === 'PATH_NOT_ALLOWED') {
showToast('保存失败:路径不在数据目录内', 'error');
return { ok: false, reason: 'PATH_NOT_ALLOWED' };
}
if (result.error === 'SYMLINK_NOT_ALLOWED') {
showToast('保存失败:不允许写入符号链接', 'error');
return { ok: false, reason: 'SYMLINK_NOT_ALLOWED' };
}
// P1-7:权限 / 只读 / 磁盘满 / 文件被另一进程独占
// 用 result.code(原始 errno)给中文提示,不再把英文错误码甩给用户
// audit fix:fallback 走 result.message(主进程已翻译为中文),
// 之前用 result.error 会把英文 errno 串(如 'EACCES')直接吐给用户。
const friendly = window.api.friendlyFsError(result.code, result.message || result.error);
showToast(`保存失败:${friendly}`, 'error');
return { ok: false, reason: 'WRITE_FAILED' };
}
// 成功:更新基线 + UI
// P0-2 真正生效修复(关键):必须以「写盘时刻的 content 快照」为基准判断
// dirty,而不是 editor.isDirty()(后者依赖 lastSavedDoc,markSaved 一旦
// 跑过就被同步成当前 doc,永远返回 false)。
// 之前 `state.isDirty = !!editor.isDirty()` 在 markSaved 之前调用,本意
// 是想保留「飞行期用户新敲的字符」对应的 dirty 状态,结果在普通路径(用
// 户先敲字再点保存,doc === content)下也把 state.isDirty 设成 true →
// markSaved 跑完 is-dirty class 不消失、按钮永远亮着,用户以为保存按钮
// 失灵(实测:window.api.writeFile 真的被调、磁盘文件真的写成功、但 UI
// 上「已保存」反馈缺失 + is-dirty 残留)。
//
// 正确做法:拿 editor 当前 doc 跟刚才 writeFile 用的 content 快照对比
// - 相等(普通路径)→ state.isDirty = false,UI 立刻熄灭
// - 不等(飞行期用户敲了新字符,半步保存)→ 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();
// 更新当前文件的 size/mtime(让侧边栏和状态栏反映最新值)
state.currentFile = { ...state.currentFile, mtimeMs: result.mtimeMs, size: result.size };
viewer.updateStatusBar(state.currentFile);
// audit fix (Phase O-M4):success toast 不在 IIFE 内部弹,由 save() outer
// 统一在 silent=false + ok 时弹(line 1803-1805 之后)。
// 否则用户连按两次 Ctrl+S:Call#1 走主路径 → IIFE 内部 toast;Call#2
// 走 shared path → outer toast;同一次磁盘保存弹出两次「已保存」。
// 错误 toast 仍在 IIFE 各分支(FILE_CHANGED_EXTERNALLY / FILE_NOT_FOUND /
// PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED / errno 兜底 / catch IPC_ERROR),
// 与 silent 无关,所有 silent 都弹(用户必须感知失败)。
return { ok: true };
} catch (e) {
console.error('[app] save 异常:', e);
// IPC 协议层异常(Electron 包装 message 形如
// `Error invoking remote method 'file:write': Error: ...`)含 IPC channel 名 +
// 底层 errno + 路径,原样 toast 会泄露技术细节。
// audit fix (K1-H3):从包装 message 里抽出原 errno(形如 `EPERM: ...`、
// `ENOSPC: ...`、`EACCES: ...`),传给 friendlyWriteError 做中文翻译。
// 之前传 undefined 永远走 default 分支 = 「与主进程通信异常」,EACCES/EPERM/
// ENOSPC 等具体错误都被掩盖,用户看不到「磁盘满 / 只读 / 权限」这种能立刻
// 判断原因的提示。注意:e.code 是 undefined(IPC 层异常没有 errno.code 字段),
// 只能从 e.message 抠字面量。
const wrapped = (e && e.message) || '';
const codeMatch = wrapped.match(/\b(EACCES|EPERM|ENOSPC|EROFS|EIO|EBUSY|ENAMETOOLONG|ENOTDIR|EISDIR|ENOTEMPTY|ENOENT)\b/);
const code = codeMatch ? codeMatch[1] : undefined;
showToast(`保存失败:${window.api.friendlyFsError(code, '与主进程通信异常')}`, 'error');
return { ok: false, reason: 'IPC_ERROR' };
} finally {
// L1 fix (audit):统一还原 preview 模式只读,覆盖成功 / 异常 / 早退 / 切文件
// 所有路径。force_overwrite 递归调 save 时本 finally 也会跑,但新 save 内部
// 自己也会再调 setReadOnly(false) 再 + finally 还原,幂等且无副作用。
// audit fix (K2-M7):finally 里重新拿当前 snapshot —— save 飞行期间用户
// 可能已经点了 mode 按钮切到 edit / 切到另一个文件 / 触发 applyEditorMode,
// 入口快照 restoredReadOnly 已陈旧。无脑 editor.setReadOnly(true) 会把新状态
// 的编辑器强行锁只读,造成「切了文件后编辑器打不出字」的诡异现象
// (setReadOnly 是 compartment 重配不会破坏 doc,但模式按钮态会跟实际
// 只读态错位)。统一走 applyEditorMode 让 state.editorMode 决定。
// audit fix (Phase O-L13):save() finally 还原只读态非用户主动,
// 传 focus:false 不抢焦点(save 飞行期用户的焦点不该被偷走)。
if (typeof applyEditorMode === 'function') {
applyEditorMode(state.editorMode, { focus: false });
} else if (restoredReadOnly) {
editor.setReadOnly(true);
}
}
})();
// audit fix (Phase O-M4):主路径也走 outer toast 决策,与 shared path 对称。
// IIFE 内部不弹 success toast(line 1773 注释已说明),save() outer 在
// silent=false + result.ok 为真值时统一弹一次「已保存」。shared path(line 1569-1574)
// 已有同款 toast 处理,主路径之前只 `return await inFlightSave` 把 IIFE
// 内部 toast 当作 owner 反馈 —— 与 shared path 不对称导致双重反馈。
//
// Phase N Q-fix:契约统一为 IIFE 返回 {ok, reason}(line 1798 / 各 catch 分支)。
// 主路径 + silent=false + reason='STALE'(飞行期切文件)→ 弹「文件已切换,
// 未保存到磁盘」提示用户,silent=false manual caller 不再被吞反馈。
const result = await inFlightSave;
decideSaveToast(silent, result, showToast);
return !!(result && result.ok);
} finally {
// 一站式清理:覆盖所有路径(共享路径等待 / 早退 / IIFE 内部 try/finally / 异常),
// 保证 savingCount 与 inFlightSave 哨兵永远配对平衡。位置必须在最外层 try 的 finally
// 里 —— 见 [[project-settings-saving-flag]] 教训:嵌套内部 finally 会被异常路径漏掉。
state.savingCount--;
syncSaveButton();
// 主路径自己产生的 inFlightSave 在这里清;共享路径只 await 不创建新 IIFE,
// 也走这里统一清空 —— 共享路径完成后这次 save() 结束,下次 save() 自然走主路径
// 建新的 inFlightSave。重复置 null 无害(已是 null 时保持 null)。
inFlightSave = null;
}
}
/**
* 打开一个文件
*
* 流程:
* 1. 如果是切换到不同文件且当前脏 → 弹窗确认
* 2. 读取文件
* 3. 加载到 editor + viewer
* 4. 重置 dirty / lastSavedMtimeMs
*
* 防重入:双击同一个文件、或快速切换文件时,会触发两次 openFile 并发。
* 串行化保证同一时刻只有一个加载在进行;in-flight 期间重复调用复用
* 同一 Promise,更晚到达的「新」调用自动抢占(epoch 守卫)。
*/
/**
* openFile 并发控制(解决 #3:双击 / 快速切换同一文件时的双重 readFile + 双重 load):
*
* - inFlight = { path, promise }:最近一次尚未完成的加载。
* - 同一 path 第二次进入 await 之前 → 复用 promise(不发第二次 readFile)
* - 不同 path 第二次进入 → 抢占;旧调用在 await 之后检测 epoch 失效,主动放弃状态变更
*
* - epoch 单调递增:每次 openFile 进入 +1;任何 await 之后比较 myEpoch !== openEpoch 即视为被抢占。
*/
let openEpoch = 0;
let openInFlight = null; // { path: string, promise: Promise }
async function openFile(file) {
if (!file || !file.path) return;
// 复用同一文件的 in-flight(同步命中,必须在第一个 await 之前)。
// audit C1 修复:命中时**不**递增 epoch —— 否则 in-flight 内部的
// `myEpoch !== openEpoch` 检查会因 openEpoch 已被新调用顶到 N+1 而失败,
// 导致整个加载链在中途放弃状态变更(双击同一个文件,editor/viewer 不更新)。
// 直接返回原 promise,等它正常走完;新调用方复用同一结果。
if (openInFlight && openInFlight.path === file.path) {
return openInFlight.promise;
}
const myEpoch = ++openEpoch;
// 防御性:检查目标文件是否在数据目录内(避免从 md 链接越权读文件)
try {
const dataDir = await window.api.getDataDir();
if (dataDir && !isPathInside(file.path, dataDir)) {
showToast('该链接指向数据文件夹之外的文件', 'warning');
return;
}
} catch {
// 获取数据目录失败:让主进程兜底
}
// audit fix (Round 3 bug 2026-08):同步 state.isDirty 到 editor 实时真相,
// 防止 IME 期间切文件 / 打开同名文件时不弹「未保存改动」对话框,让用户
// 编辑的 IME 字符随 view 销毁静默丢失。详见 syncDirtyFromEditor 注释块。
syncDirtyFromEditor();
// 切换到不同文件且当前脏 → 提示
if (state.isDirty && state.currentFile && state.currentFile.path !== file.path) {
const choice = await showConfirmDialog({
title: '未保存的改动',
message: `当前文件「${state.currentFile.name}」有未保存的改动。\n\n打开「${file.name}」之前请选择如何处理。`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消' },
{ value: 'discard', label: '放弃改动', variant: 'danger' },
{ value: 'save_and_open', label: '保存并打开', variant: 'primary' },
],
});
if (myEpoch !== openEpoch) return; // dialog 期间被新调用抢占
if (choice === 'cancel' || choice == null) return;
if (choice === 'save_and_open') {
let ok = await save();
if (myEpoch !== openEpoch) return; // 保存途中被新调用抢占
if (!ok) return;
// 「半步保存」保护:save 飞行期内用户继续输入 → save 写盘内容是飞行期前
// 的旧版,但 editor 已是新字符(state.isDirty=true)。下面 loadFile(B)
// 会用 B 的内容覆盖编辑器,A 的新字符就此丢失。最多补 3 刀把新内容也
// 写盘(silent=true 避免连续 toast 噪音)。
//
// audit fix (Phase 2):3 刀后仍脏不能「兜底让 loadFile 继续」—— 那是
// 切文件路径不是关窗路径,没有 main.js confirmDiscardIfDirty re-check
// 兜底;fallthrough 会让 loadFile(B) 用 B 的内容覆盖编辑器,A 的最新字
// 符永久丢失。改成「3 刀后仍脏 → 中止 open、提示用户先 Ctrl+S」。
// 普通路径(saveAndOpen 之外)由用户主动 Ctrl+S 补刀;关窗路径由
// main.js confirmDiscardIfDirty 的 rendererDirty re-check 兜底 —— 那两
// 条路径已有保护,这里是第三条路径的补丁。
//
// audit fix (Phase O-H5):break 条件原本是 `!== file.path` —— 永远成立
// (currentFile 是 A,file 是 B),第一轮立刻退出,注释宣称的「最多 3
// 次重试救回半步内容」从未执行,A 文件未保存字符静默丢失。改成 `===`
// 才是正确的语义:currentFile 已是新文件时(被别的 openFile 抢占)才停。
let stillDirtyAfterRetry = false;
for (let i = 0; i < 3; i++) {
if (!state.isDirty) break;
if (!state.currentFile || state.currentFile.path === file.path) break;
ok = await save({ silent: true });
if (myEpoch !== openEpoch) return;
if (!ok) break;
}
// Phase 2 防御:3 刀后仍脏(用户持续输入)→ 中止 open,绝不让
// loadFile(B) 覆盖 A 的未保存字符。用户可 Ctrl+S 后再打开新文件。
if (state.isDirty && state.currentFile && state.currentFile.path !== file.path) {
stillDirtyAfterRetry = true;
}
if (stillDirtyAfterRetry) {
showToast('「' + state.currentFile.name + '」还有未保存字符,请 Ctrl+S 保存后再打开', 'warning', 3000);
return;
}
} else if (choice === 'discard') {
// audit fix (K2-H1):选「放弃改动」立刻复位脏态 —— 不要等到下面
// entry.promise 里 line 1678 那一行(readFile 之后才 reset)。中间
// 窗口期 auto-save 定时器若 fire,会以 A 的 lastSavedMtimeMs 把 B 的
// 内容写盘,主进程比 mtime 必然不匹配 → 返回 FILE_CHANGED_EXTERNALLY,
// 用户在刚打开的干净文件上看到红色「文件在外部被修改」冲突框,体验割裂。
// 同样地,state.isDirty 在 reset 前若 auto-save 撞进来,editor.markSaved
// 会把 isDirty 拍回 false 但 lastSavedMtimeMs 还是 A 的,副作用相同。
cancelScheduledAutoSave();
state.isDirty = false;
state.lastSavedMtimeMs = null;
// 同步 editor 的保存基线 —— 不调的话 editor.isDirty() 仍会基于旧 baseline
// 返回 true(用户之前的输入相对 baseline 是「有改动」),state.isDirty=false
// 与 editor 内部状态错位。下次 onChange 触发时 state.isDirty = editor.isDirty()
// 会重新对齐到 true(用户继续输入),按钮会再翻 dirty 态;但中间「放弃后
// 未输入」窗口里,按钮干净 + editor 内部 dirty 这种状态错位会让调试困惑。
editor.markSaved();
// 保存按钮同步:state.isDirty 已翻 false,但视觉态、IPC setDirty 都还没刷。
// 之前没在这里调,下方 readFile 失败(FILE_TOO_LARGE / NOT_TEXT / 其它错误
// 走 !result.ok 早退 return result,不走 line 1848 的 syncSaveButton)会让
// 工具栏保存按钮**永久卡在 is-dirty 态**,用户编辑时也按 Ctrl+S:
// save() 内 state.isDirty=false 早退 → 没真存盘,但按钮依旧显示「有待保存」,
// 关窗时主进程的 rendererDirty 还是 true,会要求用户保存一个他们已经放弃的版本。
syncSaveButton();
}
} else if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
// Phase 2 audit 修复 (P0 BUG #1):点同一文件 + dirty → 也走「保存 / 放弃 / 取消」对话框。
// 原代码 `path !== file.path` 把同文件情况短路,编辑器直接被磁盘版本覆盖 → 未
// 保存字符静默丢失(用户高频误点 / focus 切换都会触发)。提示文案改成「重新加载会丢
// 弃本地改动」,让用户明白这是 reload 而非切文件。
const choice = await showConfirmDialog({
title: '未保存的改动',
message: `当前文件「${state.currentFile.name}」有未保存的改动。\n\n重新加载会丢弃本地改动,如何处理?`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消' },
{ value: 'discard', label: '放弃改动', variant: 'danger' },
{ value: 'save', label: '保存', variant: 'primary' },
],
});
if (myEpoch !== openEpoch) return;
if (choice === 'cancel' || choice == null) return;
if (choice === 'save') {
const ok = await save({ silent: true });
if (myEpoch !== openEpoch) return;
if (!ok) return;
// 保存成功:让 reload 流程继续走下方 readFile
} else {
// 'discard':复位脏态 + editor 基线(同上方 save_and_open discard 分支)
cancelScheduledAutoSave();
state.isDirty = false;
state.lastSavedMtimeMs = null;
editor.markSaved();
syncSaveButton();
}
} else if (state.currentFile && state.currentFile.path === file.path) {
// Phase 2 audit 修复:同文件 + 干净 → no-op,避免无谓的 readFile + editor.loadFile
// 重新加载(编辑器内容 = 磁盘内容,刷新没意义,徒增 IO)。如果用户是想 reload
// 外部修改过的文件,应该走工具栏「重新加载」按钮(reloadCurrentFileFromDisk),
// 那是显式 intent。
return null;
}
// 开始真正的加载
const entry = { path: file.path, promise: null };
openInFlight = entry;
entry.promise = (async () => {
try {
const result = await window.api.readFile(file.path);
// 抢占:被更新调用取代,跳过所有状态变更(不弹错误 toast,避免误导)
if (myEpoch !== openEpoch) return result;
if (!result.ok) {
if (result.error === 'FILE_NOT_FOUND') {
// Phase 2 audit 修复 (P0 BUG #2):openFile 同路径 readFile 撞 FILE_NOT_FOUND
// 时不能只 clearCurrentFile —— 用户的 dirty 内容还在编辑器里,
// 清掉后随 view 销毁丢失。复用 onFilesChanged 的 rescue 模式(line 2737-2761
// 的 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);
});
} else {
showToast('文件已被删除', 'warning');
}
// audit fix (Round 8 S-2):只有「消失的正是当前打开的文件」才能 clear。
// 旧代码无条件 clearCurrentFile() —— 用户在 A(脏)时点了侧栏里一个已被
// 外部删除、fs-watcher 还没刷掉的条目 B,readFile(B) 撞 FILE_NOT_FOUND,
// 却把 A 一起清了:clearCurrentFile 会 editor.unload() 并把 isDirty 置 false,
// A 的未保存内容既没救回也不会在关窗时告警 → 静默丢失。
if (!state.currentFile || state.currentFile.path === file.path) {
clearCurrentFile();
}
} else if (result.error === 'FILE_TOO_LARGE') {
showToast(result.message || '文件过大', 'error');
} else if (result.error === 'NOT_TEXT') {
// audit fix (Phase L3-FS 1A):二进制内容误命名为 .md,UI 直接
// 拒绝编辑而不是静默接受(一旦保存会污染文件)。toast 给提示,
// 不切换当前文件(避免侧栏双击切换走两次相同路径)。
showToast(result.message || '该文件不是有效的文本文件,无法编辑', 'error');
} else {
// audit fix:result.message 在主进程非 ENOENT 路径下可能是
// `EACCES: permission denied, open 'C:\\Users\\Alice\\foo.md'` 这种
// 含完整路径的英文原文 —— 不应直接 toast。
// 走 friendlyWriteError(与 save 路径同源)映射 EACCES/EPERM/EBUSY
// 等常见 errno 到中文,unknown 时落 fallback 的 result.message(主进程
// 已翻译为中文),与 save 路径保持一致。
showToast(`读取失败:${window.api.friendlyFsError(result.code, result.message || result.error || '未知错误')}`, 'error');
}
return result;
}
// audit fix (Round 4 S-1):合并 readFile 返回的 mtimeMs / size 到 currentFile。
// 之前只存 entry.mtimeMs / entry.size,readFile 之后 stat 重新计算可能差几 ms,
// fs-watcher 2s polling 推 files:changed 时新 entry 的 mtimeMs 比
// lastSavedMtimeMs 大几 ms → 误判「外部修改」弹 rescue 提示。把 readFile 的真实
// mtimeMs / size 回写到 currentFile,与 reload 路径 (line 927) 行为对齐。
state.currentFile = { ...file, mtimeMs: result.mtimeMs, size: result.size };
state.currentFileIsMarkdown = isMarkdownFile(file.path);
// 打开文件时记下它所在目录的 relDir —— 与当前显示目录可能不同
// (例如用户从根目录点开 notes/2026/foo.md 后跳到子目录 notes/2026/03 浏览)。
// onFilesChanged 用这个字段判断「事件是否针对打开的文件所在目录」,
// 防止浏览子目录时被误判为「打开的文件被外部删除」。
// audit fix (Round 4 P1-6):写到 readFile 之前。openFile 是异步流程:
// state.currentFile 在入口设了,但 currentFileRelDir 写到 readFile await 之后,
// 这中间 onFilesChanged 可能 fire(payloadRelDir vs currentFileRelDir 判断时
// currentFileRelDir 还没值,可能误判跳过)。提到 readFile 之前立刻写好,
// 与 currentFile 同步进入「已绑定 relDir」状态。
state.currentFileRelDir = state.currentRelDir;
fileList.setActive(file.path);
editor.loadFile(file, result.content);
if (state.currentFileIsMarkdown) {
viewer.loadContent(file, result.content);
} else {
// 其它可编辑格式(txt/py/json/...)→ viewer 走「不支持预览」空态,
// 状态栏仍展示文件名/大小/mtime(由 showUnsupportedFile 内部完成)。
viewer.showUnsupportedFile(file);
}
// 重新应用模式:applyEditorMode 会按 state.currentFileIsMarkdown 自动
// 把非 md 文件的视觉模式强制回落到 edit,并禁用 preview/split 按钮
// (同时设置 editor.setReadOnly / updateStatusMode)。
// state.editorMode 本身不变 —— 用户回到 md 后能恢复上次选的模式。
// audit fix (Phase O-L13):切文件流程非用户主动模式切换,传 focus:false
// 不偷焦点(用户在 preview 阅读长笔记时被切到 md → split → edit 焦点跳变)。
applyEditorMode(state.editorMode, { focus: false });
// 切到新文件:清掉旧文件的 AI pendingDiff + 把 chat 面板恢复可输入态
if (ai) ai.onFileChanged(file);
state.isDirty = false;
state.lastSavedMtimeMs = result.mtimeMs;
// audit fix (Phase N-B6):注意此处**不**重置 state.lastWriteMtimeMs。
// lastWriteMtimeMs 是「上次自身写盘的 mtime」,与当前打开文件无关。
// 若切文件时把它清零,新打开的文件的 fs:changed 事件在第一次 writeFile
// 之前到达 → 回声抑制的 lastWriteMtimeMs > 0 守卫未生效 → 真实外部修改
// 被误判为「自身写盘回声」吞掉。所以必须保留跨文件的写入历史。
// clearCurrentFile 会清零(line 758),是因为它彻底销毁 editor/view,
// 之后没有任何「自身写盘」语义可言;openFile 只是换文件对象,全局历史有效。
syncSaveButton();
updateFileCount();
updateStatusStats();
updateStatusScroll();
// audit fix (Phase N+ / save-button audit):openFile 路径需要刷新 app-title 文案。
// 之前只有 clearCurrentFile (line ~801) 和 reload 路径 (line ~876) 调
// updateWindowTitle;openFile 设了 state.currentFile 但没调它,导致用户打开
// 文件后标题栏一直停在 "Notes",看不出来打开了哪个文件 —— 与状态栏
// (已显示 "C:\...\test.md")+ 工具栏保存按钮(已切到干净态)的反馈不同步,
// 「保存按钮不起作用」类错觉就是这么来的(用户以为按了没反应,其实是状态
// 已经更新但标题栏那块 UI 漏刷)。
updateWindowTitle();
return result;
} catch (e) {
console.error('[app] openFile 异常:', e);
if (myEpoch === openEpoch) {
showToast(`读取失败:${e.message}`, 'error');
}
throw e;
} finally {
// 只清自己的 in-flight slot;如果期间被新调用接管,slot 已是新 entry
if (openInFlight === entry) openInFlight = null;
}
})();
return entry.promise;
}
/**
* 更新文件计数(侧边栏显示 X 个文件 / Y 个文件夹)
*
* Stage 8:Folder Browser 同时显示文件夹与文件。计数拆成「X 个文件 / Y 个文件夹」,
* 让用户一眼看到当前目录的结构密度,避免隐藏「我点进了一个 0 文件的空文件夹」。
* 顶层状态栏 chip 宽度有限,0 项折叠(「0 个文件」「0 个文件夹」)。
*/
function updateFileCount() {
if (!els.statusCount) return;
let files = 0;
let folders = 0;
for (const entry of state.entries) {
if (entry.entryType === 'folder') folders += 1;
else files += 1;
}
const parts = [];
parts.push(`${files} 个文件`);
if (folders > 0) parts.push(`${folders} 个文件夹`);
els.statusCount.textContent = parts.join(' / ');
}
/**
* 把 IPC 返回的 entries 列表落地到 state + 侧栏 + 计数 + 面包屑。
* 启动加载 / 子目录导航 / onFilesChanged 三条路径共用,集中在这里避免漏更新。
*
* Stage 8:
* - state.entries 是当前目录的全部条目(folder + editable + binary)
* - state.files 只保留 entryType !== 'folder' 的项(兼容老代码:resolveLocalFile、搜索等)
*
* M-series fix (audit Phase M-H2):新增「打开的文件被外部删除」判定。
* 旧实现把这个判定放在 file-list.setFiles 里(凭 previousActive 不在新 entries
* 就清空),会把「用户导航到子目录」误判成「文件被删」,导致 Folder Browser
* 模式下切子目录编辑器被清空。新判定:用「文件所在目录 === 当前浏览目录」
* 区分两者 —— 只有文件在自己目录里但条目列表里没有,才是真的被删了。
*/
function applyEntries(entries) {
const list = Array.isArray(entries) ? entries : [];
state.entries = list;
state.files = list.filter((e) => e.entryType !== 'folder');
fileList.setFiles(list);
updateFileCount();
// 检测「打开的文件被外部删除」:仅当文件所在目录就是当前浏览目录时
// 不在新 entries 才视为删除;用户在浏览别的目录时不应被误清。
//
// P0 fix (audit 2026-08):脏情况下**不**调 clearCurrentFile —— 否则
// state.currentFile 变 null → onFilesChanged line 2901 `if (!state.currentFile)
// return` 早退 → line 2917 syncDirtyFromEditor 永远到不了 → line 2944
// rescue 分支永远到不了 → 用户的脏内容既没被 clearCurrentFile 救回也没
// 被 rescue 救回,state.isDirty 被清零 → 用户关闭窗口时 dirty=false 不弹
// 未保存确认 → 内容随 DOM 销毁静默丢失。
// 脏情况下让 onFilesChanged 的 rescue 接管:fileList.setFiles 之后侧栏
// 高亮会自然消失(currentFile 仍在但新 entries 不含它),用户视觉上
// 看到「文件从侧栏消失」+ rescue toast「原文件已被删除,未保存内容已
// 救回到 xxx」+ clearCurrentFile,三步一致。
//
// IME race (audit Round 9):必须用 `editor.isDirty()`(实时真相)而不是
// `state.isDirty`(IME 合成期间被 updateListener 的 !isComposing 守卫
// 屏蔽,可能 stale-false)。否则用户 IME 拼音期间外部删除文件 → state.isDirty
// 是 stale-false → 守卫条件满足 → clearCurrentFile() → onFilesChanged
// 走早退路径 → syncDirtyFromEditor/rescue 永远不到 → IME 字符随编辑器
// view 销毁而丢失,且窗口关闭时 dirty=false 不弹保存提示。
// 用 editor.isDirty() 是「编辑器里有未保存字符就不能销毁 view」的语义
// 直译,绕开 state 同步时序问题。
if (state.currentFile && state.currentFile.path && !editor.isDirty()) {
const fileDir = dirnameOf(state.currentFile.path);
const sameDir = fileDir && state.currentDir && pathsEqual(fileDir, state.currentDir);
const stillThere = list.some((e) => pathsEqual(e.path, state.currentFile.path));
if (sameDir && !stillThere) {
clearCurrentFile();
}
}
}
/**
* 渲染面包屑:根目录时整段 hidden;否则显示「根 / seg1 / seg2 / ...」
* 每一段是按钮,点击调用 goToBreadcrumb(idx);最后一段是当前目录(非可点击)。
*
* 与 enterFolder 一起维护 state.breadcrumb —— 该数组记录从根到当前目录的路径,
* 每段是 { name, relDir }。空数组表示根目录。
*/
function renderBreadcrumb() {
if (!els.sidebarBreadcrumb || !els.sidebarBreadcrumbTrail) return;
if (!state.breadcrumb || state.breadcrumb.length === 0) {
els.sidebarBreadcrumb.setAttribute('hidden', '');
els.sidebarBreadcrumb.removeAttribute('data-rel-dir');
els.sidebarBreadcrumbTrail.replaceChildren();
return;
}
els.sidebarBreadcrumb.removeAttribute('hidden');
els.sidebarBreadcrumb.setAttribute('data-rel-dir', state.currentRelDir || '');
const frag = document.createDocumentFragment();
state.breadcrumb.forEach((seg, idx) => {
if (idx > 0) {
const sep = document.createElement('span');
sep.className = 'sidebar-breadcrumb-sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '/';
frag.appendChild(sep);
}
if (idx === state.breadcrumb.length - 1) {
// 当前段:非可点击,仅文本
const span = document.createElement('span');
span.className = 'sidebar-breadcrumb-current';
span.textContent = seg.name;
span.title = seg.relDir;
frag.appendChild(span);
} else {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sidebar-breadcrumb-btn';
btn.textContent = seg.name;
btn.title = `跳转到 ${seg.relDir || '根'}`;
btn.addEventListener('click', () => goToBreadcrumb(idx));
frag.appendChild(btn);
}
});
els.sidebarBreadcrumbTrail.replaceChildren(frag);
}
/**
* 进入一个子目录(点击 folder 条目或面包屑某段时调用)。
*
* 流程:
* 1. 计算新的 relDir = (currentRelDir ? currentRelDir + '/' : '') + folder.name
* 2. 调 window.api.scanDir(relDir) 拉取该子目录的条目
* 3. 调 window.api.watchDir(relDir) 切换 fs-watcher 监听目标(payload.relDir 跟着变)
* 4. 更新 state.currentDir / currentRelDir / breadcrumb + applyEntries + renderBreadcrumb
*
* 设计要点:
* - scanDir / watchDir 并行不等待(互相不依赖),但本函数 await scanDir 用于错误处理;
* watchDir 即便失败(理论上不应该)也不阻塞 UI,监听器只是失去该目录的实时变化。
* - 切到子目录后,state.currentFile 仍指向老目录下的旧文件 —— 这正是 Folder Browser
* 的语义:浏览期间不自动关掉已打开的文件;用户切回原目录或主动 close 才清。
*/
async function enterFolder(folder) {
if (!folder || folder.entryType !== 'folder' || !folder.path) return;
const nextRel = state.currentRelDir ? `${state.currentRelDir}/${folder.name}` : folder.name;
try {
// 并行:扫条目 + 切换 fs-watcher 目标(互不依赖)。
// M-series fix (audit Phase M-H5):原来 watchDir 失败被 .catch 吞掉只
// 留 console.warn,sidebar 已显示条目但 fs-watcher 没附上,外部修改永远
// 不显示 —— 用户毫无察觉的静默退化。改为捕获 watchDir 结果,失败时
// 在进入文件夹后单独 toast 告知用户「侧栏不会自动刷新」。
const [scanResult, watchResult] = await Promise.all([
window.api.scanDir(nextRel),
window.api.watchDir(nextRel)
.then((r) => ({ ok: !!(r && r.ok), error: r && r.error, message: r && r.message }))
.catch((e) => ({ ok: false, error: 'IPC_FAILED', message: e?.message })),
]);
if (!scanResult || !scanResult.ok) {
showToast(`进入「${folder.name}」失败:${scanResult?.message || scanResult?.error || '未知错误'}`, 'error');
return;
}
state.currentDir = scanResult.dir || folder.path;
state.currentRelDir = scanResult.relDir || nextRel;
state.breadcrumb = buildBreadcrumb(state.currentRelDir);
applyEntries(scanResult.entries || []);
renderBreadcrumb();
// 无打开文件时让状态栏路径跟随当前目录刷新
viewer.refreshCurrentDir();
// M-H5:watchDir 失败时显式告知用户。浏览本身没问题(条目已显示),
// 但 fs-watcher 失效 = 外部修改不会自动出现在侧栏,用户需要手动重进。
if (!watchResult.ok) {
console.warn('[app] watchDir 失败(侧栏不会自动刷新外部修改):', watchResult.message || watchResult.error);
showToast(`已进入「${folder.name}」,但文件系统监听启动失败,外部修改不会自动刷新`, 'warning', 3500);
}
} catch (e) {
console.error('[app] enterFolder 异常:', e);
showToast(`进入「${folder.name}」失败:${e.message}`, 'error');
}
}
/**
* 回到根目录(面包屑的「根」按钮或调用方)。
*/
async function goToRoot() {
if (!state.currentRelDir) return; // 已经在根
await navigateToRelDir('');
}
/**
* 跳转到面包屑某段(idx 是 state.breadcrumb 的下标)。
* breadcrumb[i] 对应 relDir = breadcrumb[0..i] 拼起来。
*/
async function goToBreadcrumb(idx) {
if (!state.breadcrumb || idx < 0 || idx >= state.breadcrumb.length) return;
// 当前段直接 no-op(已经是它)
if (idx === state.breadcrumb.length - 1) return;
const targetRel = state.breadcrumb.slice(0, idx + 1).map((s) => s.name).join('/');
await navigateToRelDir(targetRel);
}
/**
* 公共导航:切到某个 relDir(root = '')。出错时不修改 state,回退到上一次的列表。
*/
async function navigateToRelDir(relDir) {
try {
const [scanResult] = await Promise.all([
window.api.scanDir(relDir),
window.api.watchDir(relDir).catch((e) => {
console.warn('[app] watchDir 失败(不影响浏览):', e?.message);
}),
]);
if (!scanResult || !scanResult.ok) {
showToast(`进入目录失败:${scanResult?.message || scanResult?.error || '未知错误'}`, 'error');
return;
}
state.currentDir = scanResult.dir;
state.currentRelDir = scanResult.relDir || relDir;
state.breadcrumb = buildBreadcrumb(state.currentRelDir);
applyEntries(scanResult.entries || []);
renderBreadcrumb();
viewer.refreshCurrentDir();
} catch (e) {
console.error('[app] navigateToRelDir 异常:', e);
showToast(`进入目录失败:${e.message}`, 'error');
}
}
/**
* 从 relDir 反算 breadcrumb 数组(每段对应一层)。
* 例:'notes/2026' → [{name:'notes', relDir:'notes'}, {name:'2026', relDir:'notes/2026'}]
* '' → []
* 'a' → [{name:'a', relDir:'a'}]
*/
function buildBreadcrumb(relDir) {
if (!relDir) return [];
const parts = relDir.split('/').filter(Boolean);
return parts.map((name, i) => ({
name,
relDir: parts.slice(0, i + 1).join('/'),
}));
}
/**
* 加载文件列表(仅启动时调用一次)
* 后续更新走 onFilesChanged IPC handler。
*
* Stage 8:启动时重置 currentDir / breadcrumb(每次启动从根目录开始)。
* 实际「恢复上次子目录」超出 v1 范围 —— 用户切目录时子目录状态丢失是可接受的;
* 后续要做再加 settingsStore 持久化。
*/
async function loadFileList() {
try {
const result = await window.api.listFiles();
if (!result || !result.ok) {
showToast(`加载文件列表失败:${result?.error || '未知错误'}`, 'error');
return;
}
state.currentDir = result.dir || '';
state.currentRelDir = result.relDir || '';
state.breadcrumb = buildBreadcrumb(state.currentRelDir);
applyEntries(result.entries || result.files || []);
renderBreadcrumb();
viewer.refreshCurrentDir();
} catch (e) {
console.error('[app] loadFileList 异常:', e);
showToast(`加载文件列表失败:${e.message}`, 'error');
}
}
/**
* dataDir 切换完成后的渲染端副作用:
* - 重载文件列表
* - 若当前打开的文件不在新列表里 → 关闭它
* 与 SettingsDialog.onChanged 里 dataDir 那段同语义(见 audit #1),
* 抽出来让 openSettingsDialog / switchDataFolder 共用。
*/
async function onDataDirChanged() {
await loadFileList();
if (state.currentFile && !state.files.some(f => f.path === state.currentFile.path)) {
clearCurrentFile();
}
}
/**
* 把 dataDir 脏检查 hook 装到 settingsStore 上跑 fn,结束自动还原。
* 调用方无需关心 try/finally 与上一钩子的保存/恢复。
* 命名带 DataDir 是因为目前只有 dataDir 需要这种拦截;
* 如果以后其他字段也要拦截,再抽通用的 withPreUpdateHook。
*/
async function withDataDirDirtyCheckHook(fn) {
const previous = settingsStore.getPreUpdateHook();
settingsStore.setPreUpdateHook(async (partial) => {
// 只对 dataDir 变化做脏检查;其它设置直接放行
if (!('dataDir' in partial)) return true;
// audit fix (Round 3 bug 2026-08):同步 state.isDirty —— IME 期间切数据
// 目录若不弹「未保存改动」对话框,用户 IME 字符会随 view 销毁丢失。
syncDirtyFromEditor();
if (!state.isDirty || !state.currentFile) return true;
const choice = await showConfirmDialog({
title: '未保存的改动',
message: `当前文件「${state.currentFile.name}」有未保存的改动。\n\n切换数据文件夹之前请选择如何处理。`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'discard', label: '放弃改动,继续', variant: 'danger' },
{ value: 'save_first', label: '保存并继续', variant: 'primary' },
],
});
if (choice === 'cancel' || choice == null) return false;
if (choice === 'discard') return true;
if (choice === 'save_first') {
const ok = await save();
// audit fix (2.2):保存失败时给个 toast —— 不然 dialog 消失、dataDir 也不变,
// 用户不知道发生了什么。
if (!ok) {
showToast('保存失败,已取消切换数据文件夹', 'warning', 2500);
}
return ok;
}
return true;
});
try {
return await fn();
} finally {
settingsStore.setPreUpdateHook(previous);
}
}
/**
* 打开设置对话框
*
* 设置对话框只暴露「无即时触发入口」的偏好项:
* - 配色(palette)和阅读排版(无即时按钮)
* - AI(无按钮)
*
* 已经有即时入口的功能不进设置对话框:
* - 数据文件夹 → 侧栏底部「打开 / 切换数据文件夹」
* - 主题循环 / 置顶 / 视图模式 → 顶部工具栏或托盘菜单
* - 文件列表排序 → 左侧栏工具条
* - 自动保存 → 顶部工具栏按钮
* 见 [[feedback-prefer-near-affected-area]] / [[feedback-no-toolbar-duplicate-in-settings]]。
*/
async function openSettingsDialog(options = {}) {
state.settingsDialogOpen = true;
// 模态期间临时收起 AI 输入 dock —— dock 在 body 网格的 row 4(状态栏下方),
// 设置对话框(max-height 80vh)居中渲染到底部时会与 dock 的视觉区域重叠,
// 即便 modal-overlay z-index 更高、dock 在半透明背景后仍透出来,视觉上像挡住对话框。
// 只收起 chat dock,diff 中间面板不动(diff 在 app-shell 第 6 列,不与设置对话框重叠)。
// close 路径 finally 里有恢复,关了设置会自动把 dock 还原。
const aiWasOpen = ai && typeof ai.isChatOpen === 'function' && ai.isChatOpen();
if (aiWasOpen) ai.togglePanel();
try {
await withDataDirDirtyCheckHook(async () => {
const dialog = new SettingsDialog({
api: window.api,
settingsStore,
onChanged: async (changes) => {
if ('dataDir' in changes) {
await onDataDirChanged();
}
if ('themePalette' in changes) {
applyPalette(changes.themePalette);
const label = PALETTE_LABEL[changes.themePalette] || changes.themePalette;
showToast(`配色:${label}`, 'info', 1500);
}
// 阅读排版:立即生效(主题切换不会重置用户的字号/行距)
if ('readerFontSize' in changes || 'readerLineHeight' in changes) {
applyReaderSettings();
}
// fileListSort / autoSaveDebounceMs 已搬出设置对话框,由 sidebar-sort 工具条 /
// toolbar 自动保存按钮自己处理;这里不再重复 apply,settingsStore.change 事件统一同步。
// 见 [[feedback-prefer-near-affected-area]]。
},
});
try {
await dialog.open(options);
} catch (e) {
// preUpdateHook 拒绝 → settingsStore.update 抛 SETTINGS_UPDATE_CANCELLED,
// SettingsDialog 已经捕获并显示 toast;这里静默吞掉避免双弹
if (e && e.message !== 'SETTINGS_UPDATE_CANCELLED') throw e;
}
});
} finally {
state.settingsDialogOpen = false;
// 还原 AI dock 状态:之前是开 → 现在 togglePanel() 一次即可(关 → 开)
if (aiWasOpen && ai && typeof ai.togglePanel === 'function') {
ai.togglePanel();
}
}
}
/**
* 应用阅读字号 / 行距到 CSS 变量(挂在 documentElement,独立于主题)
* 这样切主题不会重置用户的字号 / 行距
*/
function applyReaderSettings() {
const fs = settingsStore.get('readerFontSize');
const lh = settingsStore.get('readerLineHeight');
if (typeof fs === 'number' && Number.isFinite(fs)) {
document.documentElement.style.setProperty('--reader-font-size', `${fs}px`);
}
if (typeof lh === 'number' && Number.isFinite(lh)) {
document.documentElement.style.setProperty('--reader-line-height', String(lh));
}
}
/**
* 把「行为类偏好」(文件列表排序 + 自动保存)同步到 UI 与运行时状态。
*
* 单一入口:所有改这两个设置的路径(sidebar-sort 按钮、顶部工具栏自动保存按钮、
* 未来的其他入口、外部 IPC)都会 emit 'change',这里统一负责:
* 1. 高亮对应的 sidebar-sort 按钮 + 更新 aria-pressed
* 2. 重排 fileList(仅当 sortMode 真变化时才走 setSort)
* 3. 更新 toolbar-autosave 按钮文案(label)+ active 态
* 4. 重启 autoSave 定时器
*
* 设计动机:见 [[feedback-prefer-near-affected-area]]。
* 之前这段逻辑散在 settings-dialog.onChanged 里,行为项搬出后必须有一个
* 唯一的「settings → UI」同步点,否则多个入口各管一摊会出竞态。
*
* 缓存:settingsStore.on('change') 在无关变更(theme / palette / dataDir ...)下
* 也会触发;用 lastSortApplied / lastAutosaveMs 缓存上次实际
* 应用的值,让非行为类变更变成 O(1) no-op。
*/
let lastSortApplied = null;
let lastAutosaveMs = null;
function syncBehaviorPrefsUI(settings) {
// 1. 排序按钮态 + fileList 重排
// audit fix (CQ-MED-9):未知 fileListSort 走 schema 第一项作为兜底,不再硬编码 'name'。
const sort = FILE_LIST_SORT_VALUES.includes(settings.fileListSort)
? settings.fileListSort
: DEFAULT_FILE_LIST_SORT;
// 用 schema 第一项作为「按名称」按钮的值、第二项作为「按修改时间」按钮的值 —— 与
// SETTINGS_UI_OPTIONS.fileListSort 的排列顺序保持一致;加新排序选项时只要在
// schema 末尾追加即可,无需再改 renderer 字面量。
const nameSortValue = FILE_LIST_SORT_VALUES[0] || 'name';
const mtimeSortValue = FILE_LIST_SORT_VALUES[1] || 'mtime-desc';
if (sort !== lastSortApplied) {
lastSortApplied = sort;
if (els.btnSortName) {
const isName = sort === nameSortValue;
els.btnSortName.classList.toggle('is-active', isName);
els.btnSortName.setAttribute('aria-pressed', isName ? 'true' : 'false');
}
if (els.btnSortMtime) {
const isMtime = sort === mtimeSortValue;
els.btnSortMtime.classList.toggle('is-active', isMtime);
els.btnSortMtime.setAttribute('aria-pressed', isMtime ? 'true' : 'false');
}
fileList?.setSort(sort);
}
// 2. 自动保存 chip 文案 + (关 → 取消 pending;开 → 等下次 onChange 重新调度)
const ms = Number.isFinite(settings.autoSaveDebounceMs) ? settings.autoSaveDebounceMs : 500;
if (ms !== lastAutosaveMs) {
lastAutosaveMs = ms;
// 用状态点 dot 的 dim/highlight 替代旧「自动保存: 开/关」冗余文案 —— 开启态
// 点为 accent 高亮 + 微微脉动,关闭态点为 fg-muted dim;title 同时更新。
if (els.toolbarAutosave) {
const isOn = ms !== 0;
// audit fix:移除 classList.toggle('is-active', isOn) —— 新 toolbar-toggle
// CSS 走 aria-pressed 单一驱动;保留 is-active 反而会和 .btn-icon-text.is-active
// 的旧规则叠加(accent 高亮 + bg-elevated + font-weight 600 + box-shadow),
// 与纯 aria-pressed 设计冲突。
els.toolbarAutosave.setAttribute('aria-pressed', isOn ? 'true' : 'false');
// P3 fix:title 文案必须用真实 ms —— 之前硬编码 500,但 settings 默认是 500
// 一致所以无感;用户调成 1500 后,title 还显示 500 造成误解。
els.toolbarAutosave.title = isOn
? `自动保存:开启(停打后 ${ms}ms 保存)· 点击关闭`
: '自动保存:关闭 · 点击开启';
}
// 用户主动切到「关」→ 立即取消任何 pending 保存;切到「开」→ 等下次 onChange 自然调度
if (ms === 0) cancelScheduledAutoSave();
}
}
/**
* 自动保存:debounce 模式(用户停打 X ms 后保存一次)。
*
* 与老版 setInterval 的区别:
* - 不再每隔 X 秒无脑轮询;只在每次编辑后调度一次定时器
* - 连续键入会反复重置定时器,真正停手时才落盘
* - 没人打字时完全静默(0 IO)
*
* 调用方:
* - editor.onChange:每次编辑后调一次
* - syncBehaviorPrefsUI:用户切换到「关」时调 cancelScheduledAutoSave
* - 用户点 toolbar 按钮:set → 0 / 500,下一次编辑自然走新值
*/
let autoSaveTimeout = null;
// audit fix (C1):save 入口的 in-flight sentinel —— 防止 save#1 在 writeFile await
// 飞行期内 save#2 又被触发(auto-save 防抖定时器 / 冲突对话框内再次按 Ctrl+S 等),
// 走第二次 IPC 把磁盘 mtime 提前用掉、弹「文件外部修改」红框把自己吓到。
let inFlightSave = null;
function currentAutoSaveDebounceMs() {
const v = settingsStore.get('autoSaveDebounceMs');
return Number.isFinite(v) ? v : 500;
}
// audit fix (renderer-M5):用户曾在设置里把 autoSaveDebounceMs 调成 1500ms
// 这种「自定义非零值」,点工具栏按钮关掉(→ 0)后再开启,原来会硬塞 500ms
// 默认值,自定义值悄悄丢失。这里记一份「上一次非零值」—— 关掉时不动,开启
// 时优先复用上次值,schema 默认 500 只在「从未开过」时兜底。
let lastNonZeroAutoSaveMs = 500;
function cancelScheduledAutoSave() {
if (autoSaveTimeout) { clearTimeout(autoSaveTimeout); autoSaveTimeout = null; }
}
function scheduleAutoSave(debounceMs) {
cancelScheduledAutoSave();
if (!debounceMs || debounceMs <= 0) return;
autoSaveTimeout = setTimeout(() => {
autoSaveTimeout = null;
// 审计修复 (Round 11 deep-fix P2-2):定时器 fire 时不要直接读 state.isDirty。
// IME compositionend 与 setTimeout 之间存在 micro-window:compositionend 触发
// dirty-sync 更新 state,但定时器已经在 microtask 队列里、抢在前面跑 → state 是
// 旧的 false → auto-save 跳过 → 用户立刻关窗 → IME buffer 永久丢失。
// 改用 editor.isDirty() 取真值(CM6 内部按 doc 与 lastSavedDoc 比较,是
// 同步真值),并显式调一次 syncDirtyFromEditor 让下游路径(toast、save
// 链)的 state 与真值对齐。详见 src/dirty-sync.js 注释。
syncDirtyFromEditor();
if (
state.isDirty
&& state.currentFile
&& editor.view
// fix(audit 2026-08):IME 合成期间跳过 auto-save。
// - editor.view.state.doc 包含 IME buffer(未确认字符)
// - save() → IPC 写盘 → 磁盘文件含半截 pinyin / 日文假名
// - 用户按空格确认 IME → editor 内容变化,但磁盘已是「半截 IME」状态
// - 用户感知:保存成功,但文件里有一行怪字符
// 合成期 scheduleAutoSave 仍会被 onChange 反复 clearTimeout(updateListener
// 内已有 isComposing 跳过),但 setTimeout 已经 firing 这一次(用户停手 500ms
// 触发)仍会跑。补一道守卫:compositionend 时 scheduleAutoSave 会被下一次
// onChange 重新触发,自然延后到 IME 结束后保存。
&& !editor.isComposing()
) {
// audit fix #1:不再短路 `editorMode === 'preview'` —— AI applyUserChange 会绕过
// EditorView.editable 只读检查在 preview 模式下写 doc,导致 isDirty=true 但编辑器不可见。
// save() 内部会判断:真脏了就切到 edit 模式落盘,没脏才早退。
// silent: 不弹「已保存」toast,失败仍然弹
//
// 这里显式 .catch 兜底 —— save() 内部已 try/catch 捕获所有 IPC 异常并弹 toast,
// 理论上不会 reject;但若未来重构时 save() 加异步逻辑漏了 try 包,
// 会变成 unhandledrejection 直接进 console 噪音。留一道防线。
save({ silent: true }).catch((e) => {
console.error('[app] auto-save unhandled rejection:', e);
});
}
}, debounceMs);
}
/**
* 主进程事件订阅
*
* Phase 6 audit (H1) fix:window.api.on* 返回 unsubscribe 函数(preload.js
* 内部用 ipcRenderer.on 注册),但之前所有调用点都丢弃返回值。Ctrl+R 触发
* window.location.reload 后,preload 重新求值会再注册一份新 listener,
* 旧 listener 不会被自动拆 —— N 次 reload 后每次主进程 send 触发 N 次
* callback 链(最显形的副作用是 menu:toggle-theme 被点 N 次主题翻 N 次、
* menu:settings 打开 N 个 dialog 堆栈)。
*
* 修法:把 unsubscribe 收集到 ipcUnsubscribes 数组,pagehide 时统一拆。
* 这与 fileList / viewer / editor 的 .dispose() 同款「统一释放入口」模式。
*/
const ipcUnsubscribes = [];
function subscribeIpc() {
// 文件列表变化(外部添加 / 删除 / 修改)
// 用签名缓存去重:内容真正变化才 setFiles + updateFileCount,
// 避免 fs.watch 抖动 + 2 秒 readdir 轮询触发的反复重渲。
let lastFilesSignature = '';
// audit fix (renderer-M2):fs.watch 在一次用户操作(save / rename)里常
// burst 出 3-5 个事件(create+modify+rename)。callback 是 async,会 await
// showConfirmDialog —— 旧实现没有 re-entrance 守卫,第 2/3 次并发回调里
// showConfirmDialog 撞到 modal-stack 单槽返回 null;choice === null 落到
// else 分支(见 M6)把 state.lastSavedMtimeMs 静默推进,污染 dirty 检测
// 并吞掉用户真正选择。
//
// audit fix (Round 7 FS-F3):用 fsHandlerToken + inFlightToken 双 token
// 模式取代旧布尔锁 inFlightFilesChanged。旧布尔锁的问题是:8s watchdog
// 释放的是「别人的锁」 —— 原 handler 仍在 await showConfirmDialog,watchdog
// 把 inFlight 置 false → 新事件可进入 → 第二个 handler 的 finally 会把
// 第二个 handler 刚拿到的锁释放掉(2998 行 `if (inFlightFilesChanged)
// inFlightFilesChanged = false` 无法识别「锁是不是我的」),长对话框场景
// 下「锁」实际退化为无保护。token 模式下:入口拿 myToken = ++fsHandlerToken
// 推进全局戳;inFlightToken 只在确认是当前「飞的 handler」时才被赋;
// watchdog 与正常路径的 finally 都校验「myToken === inFlightToken」才释放。
// 旧 handler finally 校验失败 noop。
//
// audit fix (Round 7 FS-F2):被 inFlight 挡掉时记下 pendingRescanRel,当前
// handler 在 finally 里若 token 仍是自己的、且有待补扫的 relDir,调
// scanDir 补一次再释放锁。旧版只 console.debug 就 return —— main 端 fs-watcher
// 已经把 lastFilesSnapshot 推进到新值(详见 main/fs-watcher.js updateSnapshot
// line ~113),下一次 2s 轮询会判定「无变化」不再 emit,侧栏永久性 stale,
// 直到发生另一次无关的磁盘变化才恢复。
let fsHandlerToken = 0;
let inFlightToken = 0;
let pendingRescanRel = null;
const FS_HANDLER_WATCHDOG_MS = 8000;
ipcUnsubscribes.push(window.api.onFilesChanged(async (payload) => {
const myToken = ++fsHandlerToken;
// 入口解构 payload —— 即便被挡也记下 relDir 用于补扫。
const { entries, error, relDir } = payload || {};
const payloadRel = typeof relDir === 'string' ? relDir : '';
// 上一 handler 仍在飞?记下要补扫的 relDir 后直接 return。
if (inFlightToken !== 0) {
console.debug('[app] fs:changed burst dropped (handler in flight)');
if (payloadRel) pendingRescanRel = payloadRel;
return;
}
inFlightToken = myToken;
pendingRescanRel = payloadRel;
let watchdogTimer = null;
try {
watchdogTimer = setTimeout(() => {
// 看门狗:仅当当前飞的仍是「我的」时强制放行;新 handler 已经进入
// 的话不动它的锁。
if (inFlightToken !== myToken) return;
console.warn('[app] fs-watcher 回调看门狗触发,强制放行(handler 卡住超过 8s)');
// 审计修复 (Round 11 P0-2):释放锁 + 主动补扫。
// 旧实现只 warn 不释放 —— 在 dialog 单槽永久卡死等罕见情况下,
// 当前 handler 的 finally 永远跑不到 → inFlightToken 永远 > 0 →
// 后续所有 fs:changed 都被入口 if (inFlightToken !== 0) 吞掉。
// 现在 watchdog 直接解锁 + 记下补扫;如果原 handler finally 后来又跑,
// token 已被覆盖,原 finally 的 if (inFlightToken === myToken) 守卫 noop,
// 不会释放新 handler 的锁。
if (inFlightToken === myToken) {
const relToRescan = pendingRescanRel;
pendingRescanRel = null;
inFlightToken = 0;
if (relToRescan && relToRescan === state.currentRelDir) {
window.api.scanDir(relToRescan).then((r) => {
if (r && r.ok && Array.isArray(r.entries)) applyEntries(r.entries);
}).catch((e) => {
console.warn('[app] watchdog rescan failed:', e && e.message);
});
}
}
}, FS_HANDLER_WATCHDOG_MS);
// Stage 8:fs-watcher 只盯「当前显示目录」,payload.relDir 字段标识事件来源;
// 与 state.currentRelDir 不匹配的事件(理论不会发生 —— 我们已经切了 watchDir,
// 但 polling 期间用户可能在另一目录触发变化)一律忽略,避免子目录事件把根目录列表刷掉。
const payloadRel = typeof relDir === 'string' ? relDir : '';
if (payloadRel !== state.currentRelDir) {
// audit fix:原版静默 return,未来如果出现「切目录 debounce 错位」类 bug
// 会让侧栏停在旧视图而用户毫无线索。给一条 console.debug 让 DevTools 能
// 看到事件流,调试期打开 console 即可捕获。
if (typeof console !== 'undefined' && console.debug) {
console.debug('[app] 忽略 fs-watcher 事件(relDir 不匹配)', {
payloadRel,
currentRelDir: state.currentRelDir,
});
}
return;
}
// 仅推送错误(主进程扫描失败 —— audit #3):保留上次的列表 + 显式提示用户。
if (error && !entries) {
// audit fix (Round 4 P0-2):旧版 payload.code === 'ENOENT' 检查永远为 false
// —— fs-watcher 自扫分支(fs.watch 触发后自己又扫)发送的 payload 只含
// { error, relDir } 无 code 字段,绝大部分路径走自扫分支,用户把数据目录
// 整个删掉时只能看到普通「扫描失败」没有「切换数据文件夹」指引。直接判 error
// 字符串,与 fs-watcher 两条错误路径的字段无关。
if (error === 'DATA_DIR_NOT_FOUND' || error === 'ENOENT') {
// auto-fallback 2026-08:数据目录被外部删了 / 移动到移动硬盘未挂载 / 网盘掉线 —
// 比普通扫描错误严重得多。runtime fallback 已在 main.js 兜底走默认(侧栏会显示
// 默认目录的文件),但用户必须知道发生了什么 + 有 3 个恢复出口(恢复默认 /
// 切换其他 / 稍后处理)。原版只是一个 toast 提示点击「切换数据文件夹」,
// 错过即丢;升级到 confirm dialog 显式弹窗,行为与启动时的 promptDataDirFallback 对齐。
// 用闭包持有 savedDataDir 在弹窗里显示「之前设置的是哪个路径」。
// 注意:savedDataDir 拿的是 settingsStore 内存里的值 —— 主进程 fallback
// 没改它,所以这里读到的还是用户当初设置的自定义路径(哪怕磁盘上已不存在)。
const defaultDir = await window.api.getDefaultDataDir();
promptDataDirFallback({
saved: settingsStore.get('dataDir') || '',
dir: defaultDir,
defaultDir,
});
} else {
showToast(`扫描文件失败:${error}`, 'error', 3500);
}
return;
}
const nextEntries = entries || [];
// 签名:name | path | size | mtimeMs | isFolder —— 与 fs-watcher 的 snapshot 同步
const signature = nextEntries.map((e) => {
if (e.isFolder || e.entryType === 'folder') return `d:${e.name}`;
return `f:${e.name}|${e.size}|${e.mtimeMs}`;
}).join('\n');
const filesChanged = signature !== lastFilesSignature;
if (filesChanged) lastFilesSignature = signature;
if (filesChanged) {
applyEntries(nextEntries);
}
if (!state.currentFile) return;
// P0 fix:仅当事件来自打开文件所在目录时,才检查「文件是否被外部删除」。
// 之前用 state.files.some(...) 检查 —— 而 state.files 是当前显示目录的
// 文件列表;用户从根目录打开 notes/2026/foo.md 后跳进子目录 notes/2026/03
// 浏览,state.files 变成子目录条目(不含 foo.md),会被误判为「文件被外部
// 删除」触发 rescue / clearCurrentFile,编辑器内容被清空。
// 用事件 payload 自己的 entries(来自文件所在目录)来检查,
// 并加 relDir 守门避免处理无关目录的事件。
if (payloadRel !== state.currentFileRelDir) {
return;
}
const stillExists = nextEntries.some((e) => pathsEqual(e.path, state.currentFile.path) && e.entryType !== 'folder');
if (!stillExists) {
// audit fix (Round 3 bug 2026-08):IME 期间文件被外部删除 —— state.isDirty stale
// 会绕过 rescue 路径直接 clearCurrentFile,editor.doc 里的 IME 字符随 view 销毁丢失。
syncDirtyFromEditor();
// Phase 2 audit 修复 (P1 BUG #3 / #4):rename / delete 飞行期 fs-watcher
// 先推 files:changed(A 不在 entries),但此时 currentFile.path 仍是
// 旧值(A)。如果不识别「正在 rename / delete」就会误触发 rescue:
// - rename:救回一个 `-rescued-.md`(内容 = editor 内容 =
// 与重命名后的 B 完全相同),产生孤儿文件 + 后续 rename IPC 返回
// 时 state.currentFile 已被 rescue 分支清 null,post-await 更新
// 跳过 → 编辑器清空。
// - delete:救回一个 `-rescued-.md`(内容 = 用户要删的 A
// 内容),与「删除」操作意图完全相反 —— 用户预期的删除 + 多出
// 一个文件。
// 守卫:state.renamingPath / state.deletingPath 在 file-ops 的 rename /
// delete 进入前设,离开时清。命中 → 跳过 rescue,由 file-ops 自己的
// post-await 路径接管(rename 更新 state.currentFile.path;delete
// 调 clearCurrentFile)。
const isRenameInFlight = state.renamingPath
&& state.currentFile
&& state.currentFile.path === state.renamingPath;
const isDeleteInFlight = state.deletingPath
&& state.currentFile
&& state.currentFile.path === state.deletingPath;
if (isRenameInFlight || isDeleteInFlight) {
// 飞行中的 rename / delete:由 file-ops 自己接管状态更新,
// 这里只做无操作让 fs-watcher 流程继续(applyEntries 已更新
// state.files,但 state.currentFile 不动)。
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);
});
} else {
showToast('当前文件已被删除', 'warning');
}
clearCurrentFile();
return;
}
// 当前文件被外部修改 → 根据脏状态决定行为
// audit fix (Phase 7 M1):原来用 state.files.find(...) 取当前文件的
// 最新 entry,但 state.files 只在 filesChanged=true 时通过 applyEntries
// 更新;如果 fs-watcher 在两次 polling 之间 burst 触发但 content
// 不变(e.g. mtime + size 之外的时间戳变了,签名仍是 f:name|size|mtime
// 所以 filesChanged=false),state.files 持有旧 mtime,下面的
// `updated.mtimeMs > state.lastSavedMtimeMs` 永远不会 true,外部修改
// 被静默吞掉。改成用本次事件的 nextEntries 查找,与上面 stillExists
// 检查保持一致 —— 同一份 fresh data。
//
// P0 fix (audit 2026-08):save() 飞行期间直接 return,不弹「外部修改」
// 对话框。场景:用户按 Ctrl+S → writeFile 在 await → 飞行期 fs-watcher
// 推 files:changed(可能是自身写盘回声,也可能是真的外部修改)→
// 进入这段 → 弹「文件在外部被修改」对话框 → 用户以为是误报点
// 「使用外部」→ reloadCurrentFileFromDisk → 飞行期用户新敲的字符
// 被 setExternalContent 静默覆盖 → 用户以为保存成功实则丢字。
// 跳过是安全的:save() 完成后会写 lastSavedMtimeMs = result.mtimeMs
// (写盘成功的新 mtime),下次 fs:changed 时 mtime 比对逻辑会把外部
// 修改(如果有)正确识别为「外部修改」并弹对话框。延迟一个 fs:changed
// 周期(~300ms debounce)可接受。
if (state.savingCount > 0) return;
// fix(audit 2026-08):Windows 路径大小写不敏感,必须用 pathsEqual。
// state.currentFile.path 可能与 fs-watcher 返回的 case 不一致(用户输入
// 时的 case vs 磁盘真实 case),=== 会漏掉外部修改。
const updated = nextEntries.find((e) => pathsEqual(e.path, state.currentFile.path) && e.entryType !== 'folder');
if (updated && state.lastSavedMtimeMs != null && updated.mtimeMs > state.lastSavedMtimeMs) {
// 抑制「刚保存引发的回声」(P1-2):按 mtime 比对而非时间窗。
// - 干净态:±5ms 容差覆盖 fs stat 抖动(NTFS/ReFS),吞无意义的 echo。
// - 脏态(Phase N-A4):只 skip 精确等于,>0ms 差异一律走冲突对话框。
if (state.lastWriteMtimeMs > 0
&& ((state.isDirty && updated.mtimeMs === state.lastWriteMtimeMs)
|| (!state.isDirty && Math.abs(updated.mtimeMs - state.lastWriteMtimeMs) < 5))) {
return; // 自身写盘回声
}
if (state.isDirty) {
// 脏:让用户选择
const choice = await showConfirmDialog({
title: '文件在外部被修改',
message: `「${updated.name}」在外部被修改,但你有未保存的本地改动。\n\n选择保留哪一份?`,
icon: 'conflict',
buttons: [
{ value: 'cancel', label: '取消(暂不处理)', variant: 'secondary' },
{ value: 'keep_local', label: '保留我的', variant: 'secondary' },
{ value: 'use_external', label: '使用外部版本', variant: 'primary' },
],
});
// audit fix (renderer-M6):choice === null 是 modal-stack 单槽被占
// 时 showConfirmDialog 的返回值,与「用户主动取消」语义不同。
// 旧实现的 else 分支把 lastSavedMtimeMs 静默推进到 updated.mtimeMs
// —— 用户根本没做选择,dirty 检测却被锁住到下一次外部 mtime 变化。
// 现在 null 单独识别为「对话框被拒」,不做任何状态推进,让下一次
// fs.watch 事件再次弹出对话框。
if (choice === 'use_external') {
const ok = await reloadCurrentFileFromDisk(updated);
if (ok) showToast('已切换到外部版本', 'info', 1800);
} else if (choice === 'keep_local') {
// 忽略外部修改:把 lastSavedMtimeMs 推进到最新外部 mtime,
// 下次外部再改时仍然能检测到。
state.lastSavedMtimeMs = updated.mtimeMs;
showToast('已忽略外部修改,保留本地版本', 'info', 1800);
} else if (choice === 'cancel') {
// 用户主动取消:推进 mtime 以避免反复弹窗(行为不变)
state.lastSavedMtimeMs = updated.mtimeMs;
}
// choice === null:modal-stack 被占,对话框没出来 → 什么都不做
} else {
// 干净:自动重载(保留原有体验)
const ok = await reloadCurrentFileFromDisk(updated);
if (ok) showToast('文件已在外部更新,已自动重新加载', 'info', 2200);
}
}
} finally {
// 正常路径:clear watchdog(如果已经触发则 watchdogTimer 已被设为 null)。
if (watchdogTimer) {
clearTimeout(watchdogTimer);
watchdogTimer = null;
}
// audit fix (Round 7 FS-F3):用 token 守护释放「自己的锁」。
// 旧布尔锁的问题是:8s watchdog 把 inFlightFilesChanged 置 false 时,
// 新 handler 已经进入并把 inFlightFilesChanged 重新置 true,但旧 handler
// 的 finally 仍会运行 `if (inFlightFilesChanged) inFlightFilesChanged
// = false`,把新 handler 刚拿到的锁给释放掉 —— 长对话框场景下「锁」
// 实际退化为无保护。token 校验:旧 handler finally 检查
// `inFlightToken === myToken`,不匹配直接跳过释放;新 handler 的锁
// 由它自己的 finally 处理。
//
// 注意:不能在 finally 里直接 return —— eslint no-unsafe-finally 规则
// 会让外层 handler 的返回值(如果 finally 所在 try 有返回值)变成
// finally 的返回值,覆盖真实逻辑。这里 finally 所在 try 没有显式
// return(applyEntries / setExternalContent 等副作用),但用 if 包裹
// 释放路径更稳健:只在自己是当前 in-flight 时才走释放 + 补扫。
if (inFlightToken === myToken) {
// 释放前先做 F2 补扫(FS-F2):在飞期间 main 端 fs-watcher 已经把
// lastFilesSnapshot 推进到新值(main/fs-watcher.js#updateSnapshot
// line ~113),下一次 2s 轮询会判定「无变化」不再 emit → 必须主动
// 调 scanDir 拉一次 fresh data。仅当 pendingRescanRel 等于当前显示
// 目录才补(避免给无关目录补扫造成侧栏错乱)。如果我的 handler 入口
// 没记 pendingRescanRel(即没有被挡的情况),这里直接跳过补扫。
const relToRescan = pendingRescanRel;
pendingRescanRel = null;
inFlightToken = 0; // 先释放锁,让下一个事件可以进入
if (relToRescan && relToRescan === state.currentRelDir) {
// 异步补扫,不 await —— finally 不能阻塞 handler 退出。补扫失败
// 不影响 inFlightToken 释放(已经置 0),下次 fs:changed 会自然推进。
window.api.scanDir(relToRescan).then((r) => {
if (r && r.ok && Array.isArray(r.entries)) {
// 与正常路径 applyEntries 同源;不刷 currentFile.rescue 路径。
applyEntries(r.entries);
}
}).catch((e) => {
console.warn('[app] pending rescan failed:', e && e.message);
});
}
}
}
}));
// 菜单命令
ipcUnsubscribes.push(window.api.onMenuCommand((cmd) => {
if (cmd === 'menu:toggle-theme') {
cycleTheme();
} else if (cmd === 'menu:settings') {
openSettingsDialog();
}
}));
// 置顶状态变化(来自托盘菜单)
ipcUnsubscribes.push(window.api.onAlwaysOnTopChanged((enabled) => {
state.alwaysOnTop = !!enabled;
syncPinButton(state.alwaysOnTop);
}));
// 最大化状态变化
ipcUnsubscribes.push(window.api.onMaximizeStateChanged((isMaximized) => {
state.isMaximized = !!isMaximized;
syncMaxButton(state.isMaximized);
}));
// auto-fallback 2026-08:启动时主进程广播一次数据目录解析结果。
// fellBack=true → custom 路径不可用,已 runtime 回退到默认;
// 弹一个一次性的引导弹窗,让用户明确知道这件事 + 给三个出口(恢复默认 / 切其他 / 取消)。
// fellBack=false → 啥也不弹(避免每次启动都打扰用户)。
ipcUnsubscribes.push(window.api.onDataDirResolved((payload) => {
if (!payload || !payload.fellBack) {
// 不广播:仅在 fellBack 时弹窗;正常启动不要任何反馈。
return;
}
promptDataDirFallback(payload);
}));
// 主进程请求保存(用户点击关闭按钮、有未保存改动、选择「保存后退出」时触发)
ipcUnsubscribes.push(window.api.onSaveRequest(async ({ reqId } = {}) => {
if (!reqId) return;
// audit fix (Round 3 bug 2026-08):IME 期间关窗 —— state.isDirty stale
// 会让 renderer 告诉主进程「无需保存」+ 用户关窗 → IME 字符丢失。同步一次。
syncDirtyFromEditor();
if (!state.isDirty || !state.currentFile) {
// 没有需要保存的内容:直接告诉主进程成功
// audit fix #1:去掉 `editorMode === 'preview'` 短路 —— AI 在 preview 模式下
// 改 doc 也会标脏,save() 内部会切到 edit 模式落盘后再返回。
window.api.reportSaveResult(reqId, { ok: true });
return;
}
try {
// save() 自己吞掉所有异常,用返回值表示成功与否;必须如实上报,
// 否则主进程会以为保存成功并继续退出,改动就丢了。
//
// Phase N Q-fix:silent=true 让关窗口路径的反馈由主进程原生框主导
// (confirmDiscardIfDirty 已经会在保存失败后弹「保存失败」原生框,
// recursive confirmDiscardIfDirty 会在半步保存后弹「保存后又产生新
// 改动」原生框),避免双重反馈(renderer toast 在关窗口期间用户看不
// 见 + 主进程原生框会重叠显示)。成功路径用户也没法看到 toast(窗
// 口即将关闭),所以也不弹。
const ok = await save({ silent: true });
window.api.reportSaveResult(reqId, { ok });
} catch (e) {
// 理论上到不了这里,兜底
window.api.reportSaveResult(reqId, { ok: false, error: e?.message });
}
}));
}
/**
* 打开数据文件夹
*/
async function openDataFolder() {
const result = await window.api.openDataDir();
if (!result.ok) {
// audit fix:fallback 走 result.message(主进程已翻译为中文),
// 之前用 result.error 会把英文 errno / 业务码吐给用户。
showToast(`打开失败:${window.api.friendlyFsError(result?.code, result?.message || result?.error)}`, 'error');
}
}
/**
* 切换数据文件夹(侧栏底部入口)
*
* 复用 openSettingsDialog 已建立的:chooseDataDir IPC + 脏检查 hook + onDataDirChanged 副作用
* 详见 [[feedback-prefer-near-affected-area]] —— 这是高频操作,贴近侧栏。
*
* 与设置对话框互斥:state.settingsDialogOpen 时直接拒绝,避免同时挂两个 dirty-check hook
* 导致最后卸载的覆盖前面、丢失恢复原 hook。
*/
async function switchDataFolder() {
if (state.settingsDialogOpen) {
showToast('请先关闭设置对话框', 'info', 1500);
return;
}
const picked = await window.api.chooseDataDir();
if (!picked) return; // 用户取消系统目录对话框
try {
await withDataDirDirtyCheckHook(async () => {
await settingsStore.update({ dataDir: picked });
await onDataDirChanged();
});
showToast('已切换数据文件夹', 'success', 1500);
} catch (e) {
if (e?.message === 'SETTINGS_UPDATE_CANCELLED') return; // 用户主动取消「未保存改动」
showToast(`切换数据文件夹失败:${e.message}`, 'error');
}
}
/**
* auto-fallback 2026-08:「回到默认」按钮 —— 把持久化的 dataDir 清空,
* 让下次启动也走默认(与 runtime fallback 的区别:runtime fallback 只在
* 内存里走默认,config.json 不动 —— U 盘插回下次启动还能用回去)。
*
* 已在默认文件夹时点这个按钮 = 无害 no-op;用 toast 明确告知,避免用户
* 怀疑「按钮坏了吗」。如果默认目录也不可访问(极少见:home 目录被删?),
* resetDataDir 返回失败 → 弹 error toast。
*/
async function resetDataFolder() {
if (state.settingsDialogOpen) {
showToast('请先关闭设置对话框', 'info', 1500);
return;
}
// 已经在默认 → 无副作用告知,避免误以为按钮失灵
const defaultDir = await window.api.getDefaultDataDir();
const currentDir = await window.api.getDataDir();
if (currentDir === defaultDir) {
showToast('当前已在默认数据文件夹', 'info', 1500);
return;
}
try {
await withDataDirDirtyCheckHook(async () => {
const result = await window.api.resetDataDir();
if (!result.ok) {
throw new Error(result.error || '恢复默认失败');
}
// settingsStore 同步清掉 dataDir 字段(reset IPC 走的是 saveConfig,
// renderer 这边 settingsStore 内存里还残留旧值;直接 reload 一次对齐)。
// 不 reload 的话下次再 update 别的字段时会把旧 dataDir 一起持久化回盘。
await settingsStore.load();
await onDataDirChanged();
});
showToast('已恢复到默认数据文件夹', 'success', 1800);
} catch (e) {
if (e?.message === 'SETTINGS_UPDATE_CANCELLED') return; // 用户主动取消「未保存改动」
showToast(`恢复默认失败:${e.message}`, 'error');
}
}
/**
* auto-fallback 2026-08:数据文件夹不可用时的引导弹窗。
* payload 来自主进程 data-dir:resolved 事件或 fs-watcher ENOENT 兜底;
* { saved, dir, defaultDir } —— saved 是持久化的旧路径,dir 是当前 runtime 在用。
*
* 三选一按钮:
* - 恢复默认 → resetDataFolder()(持久化清空 dataDir)
* - 切换其他 → switchDataFolder()(弹原生 picker 让用户选)
* - 取消 → 什么都不做(用户可能稍后能恢复 U 盘)
*
* 在 modal-stack 单槽被占用时(设置对话框打开 / AI dock)改用 toast 兜底 —— 不阻塞
* 用户当前操作,但提示去哪恢复。
*/
async function promptDataDirFallback(payload) {
const saved = payload?.saved || '<未知>';
const defaultDir = payload?.defaultDir || await window.api.getDefaultDataDir();
// modal-stack 占用 → 降级到 toast
if (typeof modalIsOpen === 'function' && modalIsOpen()) {
showToast(`数据文件夹「${saved}」不可访问,已临时使用默认`, 'warning', 5000);
return;
}
let choice;
try {
choice = await showConfirmDialog({
title: '数据文件夹不可访问',
message: `之前设置的数据文件夹无法访问:\n\n${saved}\n\n可能原因:所在磁盘已卸载 / 移动硬盘离线 / OneDrive 同步未完成 / 文件夹被删除。\n\n当前已临时使用默认文件夹 ${defaultDir},请选择下一步。`,
icon: 'warning',
buttons: [
{ value: 'reset', label: '恢复默认', variant: 'primary' },
{ value: 'switch', label: '切换其他', variant: 'secondary' },
{ value: 'cancel', label: '稍后处理', variant: 'secondary' },
],
});
} catch (e) {
console.warn('[app] promptDataDirFallback 弹窗失败:', e);
showToast(`数据文件夹「${saved}」不可访问`, 'warning', 5000);
return;
}
if (choice === 'reset') {
resetDataFolder();
} else if (choice === 'switch') {
switchDataFolder();
}
// 'cancel' / null → 不做任何操作(保留 runtime fallback,让用户稍后自己处理)
}
/**
* 双栏模式分割条拖动
*
* 支持鼠标 + 触控(平板 / 触屏一体机)。
* 侧栏宽度优先从 CSS 变量 --w-sidebar 读取,避免与 styles.css 双重事实源。
*
* 写的是像素宽度(--w-editor)而不是 fr 比例 —— 这是 split + AI 模式下的关键修复:
* - 旧 fr 架构:editor 和 viewer 都用 fr,共享 fr_space,拖 #splitter 时两边
* 一起变;拖 ai-splitter 时 AI 增长 px → fr_space 缩小 → editor 和 viewer
* 都按比例缩(用户报「编辑器被挤」就是这个)。
* - 新 px 架构:editor 用 px (--w-editor),viewer 用 1fr 自动填剩余。
* 拖 #splitter → editor ±dx、AI 不动、viewer ∓dx;拖 ai-splitter →
* AI ±dx、viewer ∓dx、editor 完全不动(与用户直觉一致)。
*
* 持久化仍是 ratio(settings.splitRatio = editor.width / frSpace),保证:
* 1. 窗口 resize 时按 saved ratio 重算 --w-editor,editor 仍跟窗口响应;
* 2. settings schema 不动,老用户数据兼容。
*
* (audit round-X) AI 面板 打开关闭时 fr_space 也会变
* (+ AI 360 + ai-splitter 4),
* 没有 resize 事件触发但同样需要按 ratio 重算 editor / viewer 比例。
* 见 mountSplitter 内 layoutAttrsObserver —— 与 resize 走同一道 rebalanceEditor。
*/
// 全局注册 splitter 通过 addEventListener 挂到 document / window 的所有 listener
// 由 createSplitter 工厂自管理(factory.dispose() 一次性解除 handle / document /
// window 上的所有 listener + is-dragging class)。这里只保留 3 个 factory 实例,
// pagehide 时统一 dispose —— 替代原 splitterDocumentListeners /
// splitterWindowListeners / splitterObservers 三套数组。
const splitterInstances = [];
/** 取当前侧栏宽度(px)。用户拖拽后由 --sidebar-width 控制(含 "px"),默认与 --w-sidebar 一致。 */
function getSidebarWidth() {
if (!els.sidebar) return 0;
const w = els.sidebar.getBoundingClientRect().width;
return Number.isFinite(w) && w > 0 ? w : 220;
}
/** editor + viewer 那段可用像素空间(fr_space)。减掉所有固定列:
* sidebar + sb-splitter(4) + #splitter(4) + AI panel + ai-splitter(4)。
* 这正是 split + AI 模式下拖 #splitter 报错的
* 根因(旧实现只扣 sidebar+4,把 AI 面板宽度算成 fr 空间的一部分)。 */
function getEditorFrSpace() {
if (!els.appShell) return 1;
const containerWidth = els.appShell.getBoundingClientRect().width;
if (!containerWidth) return 1;
let fixed = getSidebarWidth();
fixed += 4; // sb-splitter
fixed += 4; // #splitter
if (els.appAi && !els.appAi.hidden) {
if (els.aiSplitter && !els.aiSplitter.hidden) {
fixed += els.aiSplitter.getBoundingClientRect().width || 4;
}
fixed += els.appAi.getBoundingClientRect().width || 360;
}
return Math.max(1, containerWidth - fixed);
}
/**
* editor / viewer 中间分割条
*
* 内部用 px 实现(--w-editor),持久化用 splitRatio (0..1)。窗口 resize /
* AI 切换 时按 saved ratio 重新计算 editor 宽度。
*/
function mountSplitter() {
if (!els.splitter || !els.appShell) return;
const MIN_EDITOR_WIDTH = 280; // 与 05-sidebar.css minmax 一致
const MIN_VIEWER_WIDTH = 200; // 阅读视图下限
let currentEditorWidth = 0;
let currentRatio = clampSplitRatio(settingsStore.get('splitRatio'));
function clampEditorWidth(width) {
const frSpace = getEditorFrSpace();
const maxW = Math.max(MIN_EDITOR_WIDTH, frSpace - MIN_VIEWER_WIDTH);
const clamped = Math.max(MIN_EDITOR_WIDTH, Math.min(width, maxW));
currentEditorWidth = clamped;
return clamped;
}
function setEditorWidth(px) {
els.appShell.style.setProperty('--w-editor', px + 'px');
}
function getEditorWidth() {
return els.editorPane.getBoundingClientRect().width;
}
function getCurrentRatio() {
const frSpace = getEditorFrSpace();
if (frSpace <= 0) return 0.5;
return clampSplitRatio(currentEditorWidth / frSpace);
}
const splitter = createSplitter({
handle: els.splitter,
axis: 'x',
getSize: getEditorWidth,
setSize: setEditorWidth,
clampSize: clampEditorWidth,
// 工厂拖完一次后会调 onDragEnd(clampSize(getSize())) —— 已 clamp 的 px。
// 我们反算 ratio 持久化:settings schema 仍是 splitRatio (0..1),内部用 px。
// 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,
});
// AI 面板 / 视图模式切换时 fr_space 也会变(+ AI 360 + ai-splitter 4),
// 没有 resize 事件触发但同样需要按 ratio 重算。否则用户看到「打开 AI 后
// 编辑器宽度没变、阅读视图被挤扁」或「关闭 AI 后阅读视图瞬间变宽」——
// 与窗口 resize 行为不一致。
//
// data-editor-mode 也听一份:preview / edit / split 切换会让 grid 列数
// 变化(mountSplitter 提前在 preview 模式下写过 --w-editor,切到 split
// 模式需要重新按当前列数 + fr_space 重算,避免「切到 split 编辑器突然挤
// 掉阅读视图」)。focus 模式不监听 —— editor 被隐藏、--w-editor 视觉上
// 不生效,监听反而触发无意义的写入。
if (typeof MutationObserver !== 'undefined' && document.body) {
const layoutAttrsObserver = new MutationObserver(() => splitter.rebalance());
layoutAttrsObserver.observe(document.body, {
attributes: true,
attributeFilter: ['data-ai-diff-open', 'data-editor-mode'],
});
// MutationObserver 不归工厂管 —— 把 disconnect 接进 dispose 路径。
splitterInstances.push({
dispose: () => {
try { layoutAttrsObserver.disconnect(); } catch { /* ignore */ }
splitter.dispose();
},
});
} else {
splitterInstances.push(splitter);
}
}
/**
* 侧栏拖拽分割条
*
* 区别于 editor/viewer 的 splitRatio 模型:侧栏宽度是固定像素 —— 因为侧栏
* 总是位于窗口左侧,缩放窗口时它要么不变(不与右侧内容联动),要么让用户
* 自己拖。持久化到 settingsStore.sidebarWidth(px),下次启动恢复。
*/
function mountSidebarSplitter() {
if (!els.sidebarSplitter || !els.appShell) return;
const MIN_WIDTH = 180;
const MAX_WIDTH = 480;
/** 扩展命中区:splitter 视觉只有 4px,但鼠标距 splitter 中线 ≤ HIT_RADIUS 时也算命中。 */
const HIT_RADIUS = 6;
function clampSidebarWidth(width) {
return Math.max(MIN_WIDTH, Math.min(width, MAX_WIDTH));
}
function getSidebarPx() {
return els.sidebar.getBoundingClientRect().width;
}
function applySidebarWidth(px) {
els.appShell.style.setProperty('--sidebar-width', px + 'px');
}
function getInitialWidth() {
const saved = settingsStore.get('sidebarWidth');
if (Number.isFinite(saved) && saved >= MIN_WIDTH && saved <= MAX_WIDTH) {
return saved;
}
// 从 CSS 变量读取当前默认宽度(考虑媒体查询已覆盖窄屏值的情况)
const raw = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width').trim();
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 220;
}
// 仅读 CSS 变量当前生效值(忽略 settingsStore 保存值),给「重置」按钮用。
// 语义是「回到 CSS 默认」,不走持久化分支 —— 否则用户拖到 280 点重置会得到 280。
function getDefaultSidebarWidth() {
const raw = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width').trim();
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 220;
}
// M-series fix (audit Phase M-H6):splitter 中线缓存。
// 每帧 mousemove 频繁调用 getBoundingClientRect 强制 layout flush + 改
// style.cursor 触发 style recalc。改为:缓存中线,仅在 resize / scroll 时
// 失效(失效成本远低于 mousemove 频率)。
let cachedSplitterMidX = null;
function getSplitterMidX() {
if (cachedSplitterMidX == null) {
const r = els.sidebarSplitter.getBoundingClientRect();
cachedSplitterMidX = r.x + r.width / 2;
}
return cachedSplitterMidX;
}
function invalidateCache() {
cachedSplitterMidX = null;
}
function distanceToSplitter(clientX) {
return Math.abs(clientX - getSplitterMidX());
}
const splitter = createSplitter({
handle: els.sidebarSplitter,
axis: 'x',
getSize: getSidebarPx,
setSize: applySidebarWidth,
clampSize: clampSidebarWidth,
initialSize: getInitialWidth,
// audit fix (Settings P2 / splitter clamp):写入前 clamp。
// 旧版直接 Math.round(finalW) → 若 finalW 因 flexbox shrink 短暂超出
// [MIN_WIDTH, MAX_WIDTH](窗口被拖到极窄 / 大字号 DPI 缩放),落盘值就
// 越界。下次启动 getInitialWidth bounds check 会拒掉、走 CSS 默认 ——
// 用户拖的宽度"莫名其妙丢失"。预先 clamp,保证落盘值与视觉宽度一致。
// audit #1:持久化失败要让用户看到 —— 下次启动侧栏宽度丢失、用户不知情。
onDragEnd: () => {
const finalW = clampSidebarWidth(getSidebarPx());
settingsStore.update({ sidebarWidth: Math.round(finalW) }).catch((e) => {
showToast(`侧栏宽度保存失败:${e?.message || '未知错误'}`, 'error', 3500);
});
},
hitZone: {
radius: HIT_RADIUS,
distanceTo: distanceToSplitter,
// 命中区视觉反馈默认走 body.style.cursor(与原实现一致)
},
windowListeners: [
// 窗口 resize / 全局滚动后,splitter 位置会变。resize 不冒泡到
// document,必须挂 window;scroll capture 拦滚动容器触发的事件。
// 走工厂同走一张表,dispose() 一次性解除。
{ type: 'resize', listener: invalidateCache },
{ type: 'scroll', listener: invalidateCache, options: { capture: true } },
],
});
// 侧栏恢复默认宽度按钮(hover splitter 时显形)
// - mousedown 时拦下 + stopPropagation,避免 bubble 到 splitter 触发 drag
// (工厂 hit zone 用 handle.contains 排除子节点,但 capture 阶段还是会
// 比 button 自己处理更早 fire,靠 stopPropagation 兜底)
// - click 时把宽度写到 CSS 变量生效的默认,同时把 sidebarWidth 写回 null,
// 让下次启动也走默认而非保存这个具体值(比写一个 px 更耐改 CSS 默认)
if (els.btnSidebarResetWidth) {
els.btnSidebarResetWidth.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
});
els.btnSidebarResetWidth.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const defaultW = getDefaultSidebarWidth();
applySidebarWidth(clampSidebarWidth(defaultW));
settingsStore.update({ sidebarWidth: null }).catch((err) => {
showToast(`侧栏宽度保存失败:${err?.message || '未知错误'}`, 'error', 3500);
});
showToast('侧栏宽度已恢复默认', 'info', 1500);
});
}
splitterInstances.push(splitter);
}
/**
* AI 中间面板拖拽分割条
*
* 与 mountSidebarSplitter 走同一份像素宽度模型:拖动 dx → 直接写 --w-ai。
* AI 面板的 grid-template 是 minmax(220px, var(--w-ai, 360px)),
* 所以 MIN_WIDTH=220(与 CSS 一致)+ MAX_WIDTH=720(schema 上限)。
*
* 持久化到 settingsStore.aiWidth(px),下次启动恢复。
*
* 设计要点:splitter 仅在 body[data-ai-diff-open="true"] 时显示(CSS 控制),
* 但拖拽逻辑与 splitter 是否显示无关 —— 启动后立即挂好 listener,
* 用户首次展开 AI 面板时也能拖。
*/
function mountAiSplitter() {
if (!els.aiSplitter || !els.appShell) return;
const MIN_WIDTH = 220;
const MAX_WIDTH = 720;
const HIT_RADIUS = 6;
function clampAiWidth(width) {
return Math.max(MIN_WIDTH, Math.min(width, MAX_WIDTH));
}
function getAiPx() {
return els.appAi ? els.appAi.getBoundingClientRect().width : 360;
}
function applyAiWidth(px) {
// 写到 :root 而不是 .appShell,让所有模式(preview/edit/split/focus)共用
document.documentElement.style.setProperty('--w-ai', px + 'px');
}
function getInitialWidth() {
const saved = settingsStore.get('aiWidth');
if (Number.isFinite(saved) && saved >= MIN_WIDTH && saved <= MAX_WIDTH) {
return saved;
}
const raw = getComputedStyle(document.documentElement).getPropertyValue('--w-ai').trim();
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 360;
}
/** AI 面板未打开时整个命中区禁用 —— 避免在 AI 关闭时拖出幽灵宽度 */
function isAiAvailable() {
return !els.aiSplitter.hidden && !(els.appAi && els.appAi.hidden);
}
function distanceToSplitter(clientX) {
const r = els.aiSplitter.getBoundingClientRect();
return Math.abs(clientX - (r.x + r.width / 2));
}
const splitter = createSplitter({
handle: els.aiSplitter,
axis: 'x',
getSize: getAiPx,
setSize: applyAiWidth,
clampSize: clampAiWidth,
initialSize: getInitialWidth,
// audit #1:持久化失败要让用户看到 —— 下次启动 AI 面板宽度丢失、用户不知情。
onDragEnd: () => {
const finalW = clampAiWidth(getAiPx());
settingsStore.update({ aiWidth: Math.round(finalW) }).catch((e) => {
showToast(`AI 面板宽度保存失败:${e?.message || '未知错误'}`, 'error', 3500);
});
},
hitZone: {
radius: HIT_RADIUS,
distanceTo: distanceToSplitter,
isAvailable: isAiAvailable,
},
});
splitterInstances.push(splitter);
}
/**
* 重置布局:恢复默认侧栏宽度(CSS 变量 --w-sidebar)+ 双栏比例 0.5 + AI 面板宽度。
*
* 与 mountSplitter / mountSidebarSplitter / mountAiSplitter 走同一份 CSS 自定义属性:
* - 侧栏:清掉 inline --sidebar-width,回落到 :root 的 --w-sidebar (220px)
* - 双栏:清掉 inline --w-editor,回落到 CSS 默认 50% (minmax 280px, 50%)
* (mountSplitter 启动时会按 splitRatio=0.5 重算 —— 不在这里硬写 0.5fr 避免与新 px 架构不一致)
* - AI 面板:清掉 :root 的 --w-ai,回落到 03-base.css 默认(360px)
*
* 持久化到 sidebarWidth=null / splitRatio=0.5 / aiWidth=null;下次启动也走默认值。
* 失败(IPC / 校验)弹 error toast,不破坏当前显示状态。
*/
async function resetLayout() {
try {
await settingsStore.update({ sidebarWidth: null, splitRatio: 0.5, aiWidth: null });
} catch (e) {
console.error('[app] resetLayout 持久化失败:', e);
showToast(`重置布局失败:${e.message}`, 'error');
return;
}
if (els.appShell) {
// 清掉 inline 值 → 自动落到 :root 默认(避免把 0 写进去导致侧栏消失)
els.appShell.style.removeProperty('--sidebar-width');
els.appShell.style.removeProperty('--w-editor');
}
// AI 面板宽度写在 :root(所有模式共用),这里清掉它回落到 03-base.css 默认
document.documentElement.style.removeProperty('--w-ai');
showToast('布局已重置', 'info', 1500);
}
/**
* 主入口
*/
async function bootstrap() {
// 0. 应用图标:data URL 注入是同步的,挂载点已存在 → 立刻跑。
// 即使后续设置加载失败,左上角图标也能显示,UI 不至于看着「没起来」。
mountAppIcon();
// 1. 加载并应用设置
await settingsStore.load();
// 迁移:旧版的 'auto' 主题已不再支持,统一收敛到 'dark',避免 UI 状态与磁盘数据不一致
if (settingsStore.get('theme') === 'auto') {
await settingsStore.update({ theme: 'dark' });
}
const initialTheme = settingsStore.get('theme');
applyTheme(initialTheme);
syncThemeButton(initialTheme);
applyPalette(settingsStore.get('themePalette'));
state.alwaysOnTop = !!settingsStore.get('alwaysOnTop');
syncPinButton(state.alwaysOnTop);
// 阅读排版 + 行为类偏好(排序 / 自动保存)
applyReaderSettings();
// 行为类偏好(排序、自动保存)已搬出设置对话框,由 sidebar-sort 工具条 / 状态栏 chip
// 各自维护,settingsStore.change 事件统一把任意入口的改动同步到 UI 与运行时。
// 见 [[feedback-prefer-near-affected-area]]。
syncBehaviorPrefsUI(settingsStore.getAll());
// M14 fix (audit):listener 接收完整 event,让 rollback 路径能把 lastSortApplied /
// lastAutosaveMs 三个缓存强制失效。否则 syncBehaviorPrefsUI
// 在 settings 从乐观值回退到 before 时,「新值 vs 上次应用值」缓存比较可能错过
// —— 比如 lastSortApplied=mtime-desc(乐观写盘时更新过),rollback 后 settings.sort=name
// 走 if 不等会重新应用,看似 OK,但若 partial 未覆盖 sort 字段(只改了 autoSaveDebounceMs
// 后失败),回滚前后 sort 都 = 'name',缓存根本不会重算。rolledBack 标志强制清缓存
// 走兜底,比猜测 partial 字段覆盖情况更稳。
settingsStore.on('change', (event) => {
if (event && event.rolledBack) {
lastSortApplied = null;
lastAutosaveMs = null;
}
syncBehaviorPrefsUI(event.settings);
});
// 聚焦模式初始可见性(持久化在 settingsStore.focusMode,默认 false)
applyFocusMode(!!settingsStore.get('focusMode'));
// 2. 应用持久化的视图模式(直接 setMode,不走 onChange 避免循环)
const initialMode = settingsStore.get('editorMode')
|| window.api?.settingsSchema?.DEFAULT_SETTINGS?.editorMode
|| 'split';
applyEditorMode(initialMode, { focus: false });
// 2.5 阅读视图滚动监听 → 状态栏阅读进度
// audit fix (renderer-M11):存住 (scroller, listener) 元组,pagehide 时
// 显式 removeEventListener,与下方 splitterInstances.dispose() 清理风格
// 对齐。当前单实例下 listener 跟着 window 走、不显式拆也无所谓;但
// viewer/splitter 都已统一 dispose 路径,留这一根不拆是清理不对称。
const viewerScroller = els.markdownBody && els.markdownBody.parentElement;
const viewerScrollListener = updateStatusScroll;
if (viewerScroller) {
viewerScroller.addEventListener('scroll', viewerScrollListener, { passive: true });
}
// 3. 绑定静态事件
els.linkOpenFolder.addEventListener('click', () => {
openDataFolder();
});
if (els.linkSwitchFolder) {
els.linkSwitchFolder.addEventListener('click', () => {
switchDataFolder();
});
}
if (els.btnResetFolder) {
els.btnResetFolder.addEventListener('click', () => {
resetDataFolder();
});
}
if (els.btnNewFile) {
els.btnNewFile.addEventListener('click', () => {
fileOps.createNewFile();
});
}
// Stage 8:面包屑的「回到根」按钮。已经在根时按钮被 hidden,不会被点到,
// 这里不重复判断(点一下也就是 no-op)。
if (els.btnBreadcrumbRoot) {
els.btnBreadcrumbRoot.addEventListener('click', () => {
goToRoot();
});
}
// 3.5 行为偏好入口:sidebar-sort 工具条 + 顶部工具栏自动保存按钮
// 见 [[feedback-prefer-near-affected-area]]
const setFileListSort = (mode) => {
if (settingsStore.get('fileListSort') === mode) return;
settingsStore.update({ fileListSort: mode }).catch((e) => {
showToast(`排序切换失败:${e.message}`, 'error');
});
};
// audit fix (CQ-MED-9):按钮 → 排序值的映射从 schema 派生,不再硬编码 'name'/'mtime-desc'。
// 顺序与 SETTINGS_UI_OPTIONS.fileListSort 一致;增删排序方式时只需改 schema。
els.btnSortName?.addEventListener('click', () => setFileListSort(FILE_LIST_SORT_VALUES[0] || 'name'));
els.btnSortMtime?.addEventListener('click', () => setFileListSort(FILE_LIST_SORT_VALUES[1] || 'mtime-desc'));
if (els.toolbarAutosave) {
// 二态切换:点击在「关 (0)」和「开(恢复上次非零值)」之间翻转。
// audit fix (renderer-M5):之前固定 500ms,会把用户在设置里调过的
// 自定义值(如 1500ms)吞掉。关掉时把当前非零值缓存到 lastNonZeroAutoSaveMs;
// 开启时优先用缓存值,schema 默认 500 只在「从未开过」时兜底。
// bootstrap 时把当前持久化值同步进缓存(避免「开机时是 1500ms,点关
// 后点开变成 500ms」的二次踩坑)。
const initialMs = currentAutoSaveDebounceMs();
if (initialMs !== 0) lastNonZeroAutoSaveMs = initialMs;
els.toolbarAutosave.addEventListener('click', () => {
const current = currentAutoSaveDebounceMs();
const next = current === 0 ? lastNonZeroAutoSaveMs : 0;
// 关之前先把当前非零值落进缓存 —— 下次开才能恢复。
if (current !== 0) lastNonZeroAutoSaveMs = current;
settingsStore.update({ autoSaveDebounceMs: next }).then(() => {
showToast(next === 0 ? '自动保存已关闭' : `自动保存:开启(停打后 ${next}ms 保存)`, 'info', 1500);
}).catch((err) => {
showToast(`自动保存切换失败:${err.message}`, 'error');
});
});
}
// 4. 订阅 IPC
subscribeIpc();
// 5. 装载分割条
mountSplitter();
mountSidebarSplitter();
mountAiSplitter();
// 5.5 heading 联动:把当前 heading 同步到状态栏面包屑
watchViewerHeading();
// 5.6 AI 修改:侧栏底部按钮 + 控制器初始化 + 面板挂载
// ai 需要 settingsStore.getAll(),所以必须在 settings 加载完(步骤 1)之后才创建。
mountAiPanel();
// 6. 加载文件列表
await loadFileList();
// 7. 装载右键菜单(阅读视图 + 编辑器)
mountViewerContextMenu();
mountEditorContextMenu();
// 8. 加载全局快捷键
mountKeyboardShortcuts({
onFocusSearch: () => els.searchInput?.focus(),
onClearSearch: () => {
if (els.searchInput && document.activeElement === els.searchInput) {
els.searchInput.value = '';
els.searchInput.dispatchEvent(new Event('input'));
}
},
onSettings: () => openSettingsDialog(),
onToggleTheme: () => cycleTheme(),
onSave: () => {
// audit fix (Round 3 bug 2026-08):IME 期间按 Ctrl+S —— state.isDirty stale
// 会让键盘快捷键直接 return,editor.doc 里有未保存的 IME 字符不落盘。
// save() 入口本身也有同步,但这个 handler 在 save() 之前就 return 了,
// 必须在这里也同步一次。
syncDirtyFromEditor();
// audit fix #1:去掉 `editorMode !== 'preview'` 短路 —— AI 在 preview 模式下
// 改 doc 也会标脏(applyUserChange 绕过 EditorView.editable),save() 内部会
// 判断 isDirty + 切到 edit 模式落盘。这里只检查 isDirty 即可。
if (state.isDirty) save();
},
onToggleEditorMode: () => editorToolbar.cycle(),
onToggleFocusMode: () => setFocusMode(),
onNewFile: () => fileOps.createNewFile(),
onToggleAi: () => {
if (!ai) return;
// 统一走模块级 syncAiButton(与点击路径同款)—— 它会同步
// aria-pressed / aria-label / title 三个属性。原 handler 只写
// aria-pressed + title,aria-label 留给 MutationObserver 兜底更新,
// 读屏用户能感知到中间 ~毫秒级空窗。这次改用同一入口,行为对齐。
syncAiButton(ai.togglePanel());
},
// audit K1-H3:Ctrl+R 之前只在 README / 主菜单存在,渲染端快捷键缺失
// 导致「文档承诺了快捷键但按了无效」。补全:location.reload 让 BrowserWindow
// 重载 renderer,与主菜单的 webContents.reload 行为一致。
//
// Phase 2 audit 修复 (P1 #2):Ctrl+R 之前直接 reload(),dirty 状态下
// location.reload 不会触发 main.js BrowserWindow 'close' 事件 →
// confirmDiscardIfDirty 不弹 → 内存里的新字符随 renderer 重载而丢失。
// 修复:有 dirty 时弹与 Ctrl+W/关闭按钮同源的「保存 / 放弃 / 取消」对话
// 框;选「取消」就 abort reload,选「保存」或「放弃」后再 reload。
onReload: async () => {
// audit fix (Round 3 bug 2026-08):IME 期间 Ctrl+R —— state.isDirty stale
// 会让 reload 不弹「未保存改动」对话框,IME 字符随 renderer 重载丢失。
syncDirtyFromEditor();
if (state.isDirty && state.currentFile && state.currentFile.path) {
const choice = await showConfirmDialog({
title: '未保存的改动',
message: `当前文件「${state.currentFile.name}」有未保存的改动。\n\n重载窗口之前请选择如何处理。`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消', variant: 'primary' },
{ value: 'discard', label: '放弃改动', variant: 'danger' },
{ value: 'save', label: '保存', variant: 'primary' },
],
});
if (choice === 'cancel' || choice == null) return;
if (choice === 'save') {
const ok = await save({ silent: true });
if (!ok) return; // 保存失败让用户自己决定是否再试
} else {
// 'discard' 路径:复位 dirty + 同步 editor 基线(与 openFile discard
// 分支同源,见 openFile 注释块「audit fix (K2-H1)」)
cancelScheduledAutoSave();
state.isDirty = false;
state.lastSavedMtimeMs = null;
editor.markSaved();
syncSaveButton();
}
}
window.location.reload();
},
});
// 注意:原 beforeunload 监听已删除(P2-11)。
// 主进程 main.js 在 BrowserWindow 'close' 事件里就拦下来了,
// 走 confirmDiscardIfDirty 弹自定义确认框;Electron 主进程拦截下
// renderer 的 beforeunload 永不生效,留着只是误导后来人。
// pagehide 兜底:renderer 卸载时把 AI MutationObserver 显式 disconnect,
// 避免 listener 长期持有 aiChatDock + syncAiButton 闭包。
// 同时解绑三个 splitter 注册的 document 级 listener(mousemove/touchmove/
// mouseup/touchend/touchcancel 各 5 个,共 15 个),释放 dragging/pointerId/
// applyWidths 闭包。单进程单 renderer 下影响极小,但更正确。
//
// audit #C1 fix (memory leak critical):原 pagehide 只拆 2 类 listener,遗漏:
// - ai.destroy()(chat panel 7 个 listener + AbortController)
// - viewer / editor / fileList / editorToolbar 的 dispose()
// - scrollFrame (rAF)、livePreviewTimer / autoSaveTimeout / statsDebounceTimer
// 现在统一遍历拆分,规避「飞行中的 timer / rAF 回调访问已 dispose 的对象」。
window.addEventListener('pagehide', () => {
if (aiObserver) { aiObserver.disconnect(); aiObserver = null; }
// splitter:每个 factory 实例 self-contains handle / document / window
// listener;layoutAttrsObserver 由 mountSplitter 把 disconnect 接进 dispose。
// 统一 dispose() 释放闭包(替代原 splitterDocumentListeners /
// splitterWindowListeners / splitterObservers 三套数组)。
for (const inst of splitterInstances) {
try { inst.dispose(); } catch { /* ignore */ }
}
splitterInstances.length = 0;
// audit fix (Phase 6 H1):window.api.on* 的 unsubscribe 集中释放。
// 否则 Ctrl+R 重载 N 次 → 主进程每发一次 menu:toggle-theme 触发 N 次
// cycleTheme、menu:settings 打开 N 个 dialog 堆栈。
for (const unsub of ipcUnsubscribes) {
if (typeof unsub === 'function') {
try { unsub(); } catch (e) { console.warn('[app] ipc unsubscribe failed:', e); }
}
}
ipcUnsubscribes.length = 0;
// 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); }
}
// 文件列表 / 阅读视图 / 编辑器:各自 .dispose() 解绑 signal 上挂的 listener
if (fileList && typeof fileList.dispose === 'function') {
try { fileList.dispose(); } catch (e) { console.warn('[app] fileList.dispose failed:', e); }
}
if (viewer && typeof viewer.dispose === 'function') {
try { viewer.dispose(); } catch (e) { console.warn('[app] viewer.dispose failed:', e); }
}
if (editor && typeof editor.dispose === 'function') {
try { editor.dispose(); } catch (e) { console.warn('[app] editor.dispose failed:', e); }
}
// 关闭任何挂起中的 rAF / debounce timer —— 它们若在 pagehide 之后 fire,
// 会去访问已销毁的组件,抛错进 console;清理路径上每一处都做。
if (typeof scrollFrame !== 'undefined' && scrollFrame) {
cancelAnimationFrame(scrollFrame);
scrollFrame = null;
}
if (typeof livePreviewTimer !== 'undefined' && livePreviewTimer) {
clearTimeout(livePreviewTimer);
livePreviewTimer = null;
}
if (typeof autoSaveTimeout !== 'undefined' && autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = null;
}
if (typeof statsDebounceTimer !== 'undefined' && statsDebounceTimer) {
clearTimeout(statsDebounceTimer);
statsDebounceTimer = null;
}
// audit fix (renderer-M11):阅读视图滚动 listener —— 与上方注册时
// 一致的元组 (scroller, listener) + options,对称地 remove。
if (viewerScroller && viewerScrollListener) {
try { viewerScroller.removeEventListener('scroll', viewerScrollListener); }
catch (e) { console.warn('[app] viewer scroll remove failed:', e); }
}
}, { once: true });
console.debug('[app] 启动完成');
}
bootstrap().catch(err => {
console.error('[app] bootstrap 失败:', err);
// audit fix (3.4):原 boot-error 兜底只在 window.api 缺失时生效,
// IPC 抛错 / settings 加载失败 等会让 renderer 卡空白。这里把错误显示到
// boot-error 元素 + 兜底 toast,让用户至少能看到原因。
const bootEl = document.getElementById('boot-error');
if (bootEl) {
bootEl.removeAttribute('hidden');
const msg = bootEl.querySelector('p.boot-error-detail');
if (msg) msg.textContent = `错误详情:${err.message}`;
}
showToast(`启动失败:${err.message}`, 'error', 0);
});