fix: 修复代码审计发现的全部 37 个问题

严重 Bug(8 个):
- B-01: isClosing 永不重置 — WINDOW_CANCEL_CLOSE 现在正确重置状态并清除超时
- B-02: replaceAll 算法完全错误 — 改为从后向前逐个替换(SearchBar + useFileWatch)
- B-03: Preview 用 setInterval 轮询 — 改为响应式订阅 activeTab.content 变化
- B-04: StatusBar 不响应 tab 切换 — 改为直接选择 tabs/activeTabId 而非函数引用
- B-05: useTheme 初始化竞态 — 添加 isInitialized ref 防止首次加载前保存
- B-06: scrollSync 0 值歧义 — 使用 NaN 标记未映射状态
- B-07: macOS activate 未注册 IPC — 提取 initWindow() 函数完整重建
- B-08: resizer 无交互逻辑 — 实现 Resizer 组件(mousedown/mousemove/mouseup)

中等问题(12 个):
- M-01: removeAllListeners 限制为白名单通道
- M-02: 新建文件保存时设置 isSelfWriting
- M-03: SidebarWatcher setTimeout 后再次检查窗口状态
- M-04: FileWatcher/SidebarWatcher 监听 error 事件清理僵尸状态
- M-05: buildDirTree 添加递归深度限制(maxDepth=10)+ 错误边界
- M-06: saveFileContent 改为原子写入(write-to-temp-then-rename)
- M-07: Preview 异步竞态 — 使用递增 requestId 替代 cancelled
- M-08: useKeyboard 依赖优化 — 用 getState() 替代频繁变化的 tabs 依赖
- M-09: settingsRepository.save() 改为 Dexie put 直接合并
- M-10: useSettings 返回函数用 useCallback 稳定引用
- M-11: App.tsx 事件注册添加 cleanup 清理 + ref 保持最新回调
- M-12: ipc-channels.ts 类型签名统一

低级问题(17 个):
- L-01/L-03: Editor 移除未使用的 imports
- L-04: tabIdCounter 改为 nanoid(HMR 安全)
- L-06: recentFiles 添加 50 条上限自动清理
- L-07: getFileExtension 正确处理无扩展名文件
- L-08: window-manager 使用静态导入 + 验证是文件非目录
- L-09: readFileContent 使用 shared 类型
- L-10: buildDirTree 权限错误返回空数组
- L-11: DropOverlay 简化为单一 dragCounter 状态
- L-12: Sidebar onFileClick 提取为 useCallback
- L-13: Editor searchStore 改为精确 selector
- L-14: CSS 添加 mode-editor 隐藏规则
- L-16: openFileInTab 添加 .catch() 异常处理

TypeScript 检查零错误,构建成功。
This commit is contained in:
thzxx
2026-05-27 20:52:49 +08:00
parent 5e1c89d280
commit 959fde70e3
23 changed files with 279 additions and 239 deletions
+24 -15
View File
@@ -1,5 +1,7 @@
import { readFile, stat, writeFile, readdir } from 'fs/promises' import { readFile, stat, writeFile, readdir, rename } from 'fs/promises'
import { join, extname } from 'path' import { join, extname, basename } from 'path'
import { tmpdir } from 'os'
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt']) const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
@@ -8,14 +10,8 @@ const SKIP_DIRS = new Set([
'.next', '.nuxt', '__pycache__', '.DS_Store' '.next', '.nuxt', '__pycache__', '.DS_Store'
]) ])
export interface FileNode { // L-09: 使用 shared 类型
name: string export async function readFileContent(filePath: string): Promise<ReadFileResult> {
path: string
type: 'file' | 'dir'
children?: FileNode[]
}
export async function readFileContent(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> {
try { try {
const fileStat = await stat(filePath) const fileStat = await stat(filePath)
if (fileStat.size > MAX_FILE_SIZE) { if (fileStat.size > MAX_FILE_SIZE) {
@@ -30,17 +26,30 @@ export async function readFileContent(filePath: string): Promise<{ success: bool
} }
} }
export async function saveFileContent(filePath: string, content: string): Promise<{ success: boolean; filePath?: string; error?: string }> { // M-06: 原子写入(write-to-temp-then-rename
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
try { try {
await writeFile(filePath, content, 'utf-8') const dir = require('path').dirname(filePath)
const tmpFile = join(tmpdir(), `marklite-${Date.now()}-${basename(filePath)}`)
await writeFile(tmpFile, content, 'utf-8')
await rename(tmpFile, filePath)
return { success: true, filePath } return { success: true, filePath }
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
} }
export async function buildDirTree(dirPath: string): Promise<FileNode[]> { // M-05: 添加递归深度限制 + 每个 entry 错误边界
const entries = await readdir(dirPath, { withFileTypes: true }) export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise<FileNode[]> {
if (depth > maxDepth) return []
let entries
try {
entries = await readdir(dirPath, { withFileTypes: true })
} catch {
return [] // L-10: 权限错误时返回空数组而非抛异常
}
entries.sort((a, b) => { entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1 if (a.isDirectory() && !b.isDirectory()) return -1
if (!a.isDirectory() && b.isDirectory()) return 1 if (!a.isDirectory() && b.isDirectory()) return 1
@@ -54,7 +63,7 @@ export async function buildDirTree(dirPath: string): Promise<FileNode[]> {
const childPath = join(dirPath, entry.name) const childPath = join(dirPath, entry.name)
if (entry.isDirectory()) { if (entry.isDirectory()) {
const subChildren = await buildDirTree(childPath) const subChildren = await buildDirTree(childPath, depth + 1, maxDepth)
if (subChildren.length > 0) { if (subChildren.length > 0) {
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }) children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
} }
+16 -1
View File
@@ -22,6 +22,11 @@ export class FileWatcher {
} }
} }
}) })
// M-04: 监听 error 事件,文件被删除时清理状态
this.watcher.on('error', () => {
this.currentPath = null
this.watcher = null
})
} catch { } catch {
// silently ignore // silently ignore
} }
@@ -32,6 +37,7 @@ export class FileWatcher {
this.watcher.close() this.watcher.close()
this.watcher = null this.watcher = null
} }
this.currentPath = null
} }
getCurrentPath(): string | null { getCurrentPath(): string | null {
@@ -60,10 +66,19 @@ export class SidebarWatcher {
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
if (this.refreshTimer) clearTimeout(this.refreshTimer) if (this.refreshTimer) clearTimeout(this.refreshTimer)
this.refreshTimer = setTimeout(() => { this.refreshTimer = setTimeout(() => {
win.webContents.send('sidebar:dirChanged') // M-03: 延迟后再次检查窗口状态
const w = this.getMainWindow()
if (w && !w.isDestroyed()) {
w.webContents.send('sidebar:dirChanged')
}
}, 300) }, 300)
} }
}) })
// M-04: 监听 error 事件
this.watcher.on('error', () => {
this.watcher = null
this.watchPath = null
})
} catch { } catch {
// silently ignore // silently ignore
} }
+24 -19
View File
@@ -19,12 +19,14 @@ const sidebarWatcher = new SidebarWatcher(() => mainWindow)
function openFileInTab(filePath: string): void { function openFileInTab(filePath: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return if (!mainWindow || mainWindow.isDestroyed()) return
readFileContent(filePath).then(result => { readFileContent(filePath).then(result => {
if (result.success) { if (result.success && mainWindow && !mainWindow.isDestroyed()) {
state.activeFilePath = filePath state.activeFilePath = filePath
fileWatcher.start(filePath) fileWatcher.start(filePath)
mainWindow!.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
mainWindow!.webContents.send('file:openInTab', { filePath, content: result.content }) mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
} }
}).catch((err) => {
console.error('openFileInTab failed:', err)
}) })
} }
@@ -70,24 +72,12 @@ if (!lockOk) {
}) })
} }
app.whenReady().then(() => { // B-07: 完整的窗口初始化逻辑(activate 复用)
app.on('open-file', (event, filePath) => { function initWindow(): void {
event.preventDefault()
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
state.pendingFilePath = filePath
} else if (mainWindow && !mainWindow.isDestroyed()) {
openFileInTab(filePath)
} else {
state.pendingFilePath = filePath
}
})
mainWindow = createWindow() mainWindow = createWindow()
// 注册 IPC 处理器
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
// 加载渲染进程页面
if (process.env.ELECTRON_RENDERER_URL) { if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else { } else {
@@ -108,6 +98,21 @@ if (!lockOk) {
sidebarWatcher.stop() sidebarWatcher.stop()
mainWindow = null mainWindow = null
}) })
}
app.whenReady().then(() => {
app.on('open-file', (event, filePath) => {
event.preventDefault()
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
state.pendingFilePath = filePath
} else if (mainWindow && !mainWindow.isDestroyed()) {
openFileInTab(filePath)
} else {
state.pendingFilePath = filePath
}
})
initWindow()
// 命令行文件 // 命令行文件
const cmdFile = getFilePathFromArgs(process.argv) const cmdFile = getFilePathFromArgs(process.argv)
@@ -120,10 +125,10 @@ if (!lockOk) {
if (process.platform !== 'darwin') app.quit() if (process.platform !== 'darwin') app.quit()
}) })
// B-07: macOS activate 完整重建窗口
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow() initWindow()
setupCloseHandler()
} }
}) })
} }
+9 -2
View File
@@ -9,7 +9,7 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null, getMainWindow: () => BrowserWindow | null,
fileWatcher: FileWatcher, fileWatcher: FileWatcher,
sidebarWatcher: SidebarWatcher, sidebarWatcher: SidebarWatcher,
state: { activeFilePath: string | null; pendingFilePath: string | null } state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
): void { ): void {
// 打开文件对话框 // 打开文件对话框
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
@@ -67,7 +67,9 @@ export function registerIpcHandlers(
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
}) })
if (!saveResult.canceled) { if (!saveResult.canceled) {
fileWatcher.setSelfWriting(true)
const result = await saveFileContent(saveResult.filePath, data.content) const result = await saveFileContent(saveResult.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (result.success) { if (result.success) {
state.activeFilePath = saveResult.filePath state.activeFilePath = saveResult.filePath
fileWatcher.start(saveResult.filePath) fileWatcher.start(saveResult.filePath)
@@ -177,7 +179,12 @@ export function registerIpcHandlers(
} }
}) })
// B-01: 重置关闭状态 + 清除超时定时器
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => { ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
// 重置关闭状态 state.isClosing = false
if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
state.closeTimeout = null
}
}) })
} }
+6 -2
View File
@@ -1,5 +1,6 @@
import { BrowserWindow, app } from 'electron' import { BrowserWindow, app } from 'electron'
import { join } from 'path' import { join } from 'path'
import { existsSync, statSync } from 'fs'
export function createWindow(): BrowserWindow { export function createWindow(): BrowserWindow {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
@@ -43,13 +44,16 @@ export function setupSingleInstanceLock(
return true return true
} }
// L-08: 使用静态导入 + 验证是文件而非目录
export function getFilePathFromArgs(args: string[]): string | null { export function getFilePathFromArgs(args: string[]): string | null {
for (let i = 1; i < args.length; i++) { for (let i = 1; i < args.length; i++) {
const arg = args[i] const arg = args[i]
if (!arg.startsWith('--') && !arg.startsWith('-')) { if (!arg.startsWith('--') && !arg.startsWith('-')) {
try { try {
const fs = require('fs') if (existsSync(arg)) {
if (fs.existsSync(arg)) return arg const s = statSync(arg)
if (s.isFile()) return arg
}
} catch { } catch {
// ignore // ignore
} }
+17 -2
View File
@@ -1,5 +1,16 @@
import { contextBridge, ipcRenderer, shell } from 'electron' import { contextBridge, ipcRenderer, shell } from 'electron'
// M-01: 限制 removeAllListeners 只能操作白名单通道
const ALLOWED_REMOVE_CHANNELS = new Set([
'file:openInTab',
'menu:save',
'menu:saveAs',
'menu:viewMode',
'file:externallyModified',
'sidebar:dirChanged',
'window:confirmClose'
])
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// File operations // File operations
openFile: () => ipcRenderer.invoke('dialog:openFile'), openFile: () => ipcRenderer.invoke('dialog:openFile'),
@@ -42,6 +53,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
onConfirmClose: (callback: () => void) => onConfirmClose: (callback: () => void) =>
ipcRenderer.on('window:confirmClose', () => callback()), ipcRenderer.on('window:confirmClose', () => callback()),
// Remove listeners // M-01: 只允许移除白名单通道的监听器
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel) removeAllListeners: (channel: string) => {
if (ALLOWED_REMOVE_CHANNELS.has(channel)) {
ipcRenderer.removeAllListeners(channel)
}
}
}) })
+62 -10
View File
@@ -113,19 +113,32 @@ export default function App() {
saveViewMode(mode) saveViewMode(mode)
}, [saveViewMode]) }, [saveViewMode])
// 主进程事件 // M-11: 主进程事件注册(使用 ref 保持最新回调引用 + cleanup 清理)
const handleSaveRef = useRef(handleSave)
const handleSaveAsRef = useRef(handleSaveAs)
handleSaveRef.current = handleSave
handleSaveAsRef.current = handleSaveAs
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
window.electronAPI.removeAllListeners('file:openInTab') const api = window.electronAPI
window.electronAPI.removeAllListeners('menu:save')
window.electronAPI.removeAllListeners('menu:saveAs')
window.electronAPI.onFileOpenInTab((data: { filePath: string; content: string }) => { const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content) createTab(data.filePath, data.content)
}) }
window.electronAPI.onMenuSave(() => handleSave()) const onSave = () => handleSaveRef.current()
window.electronAPI.onMenuSaveAs(() => handleSaveAs()) const onSaveAs = () => handleSaveAsRef.current()
}, [createTab, handleSave, handleSaveAs])
api.onFileOpenInTab(onOpen)
api.onMenuSave(onSave)
api.onMenuSaveAs(onSaveAs)
return () => {
api.removeAllListeners('file:openInTab')
api.removeAllListeners('menu:save')
api.removeAllListeners('menu:saveAs')
}
}, [createTab])
const activeTab = getActiveTab() const activeTab = getActiveTab()
const hasTabs = tabs.length > 0 const hasTabs = tabs.length > 0
@@ -183,7 +196,7 @@ export default function App() {
<Editor /> <Editor />
</div> </div>
)} )}
{viewMode !== 'editor' && <div id="resizer" />} {viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />}
{viewMode !== 'editor' && ( {viewMode !== 'editor' && (
<div id="preview-panel" style={previewStyle}> <div id="preview-panel" style={previewStyle}>
<Preview /> <Preview />
@@ -206,3 +219,42 @@ export default function App() {
</div> </div>
) )
} }
// B-08: 分屏调节器组件
function Resizer({ onResize }: { onResize: (ratio: number) => void }) {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const handleMouseMove = (ev: MouseEvent) => {
const wrapper = document.getElementById('content-wrapper')
if (!wrapper) return
const rect = wrapper.getBoundingClientRect()
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
const editorPanel = document.getElementById('editor-panel')
const previewPanel = document.getElementById('preview-panel')
if (editorPanel) editorPanel.style.flex = `0 0 ${pct}%`
if (previewPanel) previewPanel.style.flex = `0 0 ${100 - pct}%`
}
const handleMouseUp = (ev: MouseEvent) => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
// 保存最终比例
const wrapper = document.getElementById('content-wrapper')
if (wrapper) {
const rect = wrapper.getBoundingClientRect()
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
onResize(pct)
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [onResize])
return <div id="resizer" onMouseDown={handleMouseDown} />
}
@@ -1,29 +1,18 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
export function DropOverlay() { export function DropOverlay() {
const [isVisible, setIsVisible] = useState(false) // L-11: 简化为单一状态
const [dragCounter, setDragCounter] = useState(0) const [dragCounter, setDragCounter] = useState(0)
useEffect(() => { useEffect(() => {
const handleDragEnter = (e: DragEvent) => { const handleDragEnter = (e: DragEvent) => {
e.preventDefault() e.preventDefault()
setDragCounter(prev => { setDragCounter(prev => prev + 1)
const next = prev + 1
if (next > 0) setIsVisible(true)
return next
})
} }
const handleDragLeave = (e: DragEvent) => { const handleDragLeave = (e: DragEvent) => {
e.preventDefault() e.preventDefault()
setDragCounter(prev => { setDragCounter(prev => Math.max(0, prev - 1))
const next = prev - 1
if (next <= 0) {
setIsVisible(false)
return 0
}
return next
})
} }
const handleDragOver = (e: DragEvent) => { const handleDragOver = (e: DragEvent) => {
@@ -32,7 +21,6 @@ export function DropOverlay() {
const handleDrop = () => { const handleDrop = () => {
setDragCounter(0) setDragCounter(0)
setIsVisible(false)
} }
document.addEventListener('dragenter', handleDragEnter) document.addEventListener('dragenter', handleDragEnter)
@@ -48,7 +36,7 @@ export function DropOverlay() {
} }
}, []) }, [])
if (!isVisible) return null if (dragCounter <= 0) return null
return ( return (
<div id="drop-overlay"> <div id="drop-overlay">
+8 -8
View File
@@ -1,9 +1,6 @@
import React, { useEffect, useRef, useCallback, useState } from 'react' import React, { useEffect, useRef, useCallback, useState } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { useSearchStore } from '../../stores/searchStore' import { useSearchStore } from '../../stores/searchStore'
import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine'
import type { SearchMatch } from '../../types/search'
export function Editor() { export function Editor() {
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -15,7 +12,10 @@ export function Editor() {
const updateTabContent = useTabStore(s => s.updateTabContent) const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified) const setModified = useTabStore(s => s.setModified)
const searchStore = useSearchStore() // L-13: 只选择需要的字段,避免整个 store 变化触发重渲染
const searchIsVisible = useSearchStore(s => s.isVisible)
const searchFindNext = useSearchStore(s => s.findNext)
const searchFindPrev = useSearchStore(s => s.findPrev)
const [lineCount, setLineCount] = useState(1) const [lineCount, setLineCount] = useState(1)
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 }) const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 })
@@ -145,15 +145,15 @@ export function Editor() {
} }
// 搜索导航 // 搜索导航
if (e.key === 'Enter' && searchStore.isVisible) { if (e.key === 'Enter' && searchIsVisible) {
e.preventDefault() e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
searchStore.findPrev() searchFindPrev()
} else { } else {
searchStore.findNext() searchFindNext()
} }
} }
}, [searchStore]) }, [searchIsVisible, searchFindNext, searchFindPrev])
// 渲染行号 // 渲染行号
const renderLineNumbers = () => { const renderLineNumbers = () => {
+12 -23
View File
@@ -5,38 +5,27 @@ import { renderMarkdown } from '../../lib/markdown'
export function Preview() { export function Preview() {
const [html, setHtml] = useState('') const [html, setHtml] = useState('')
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
const getActiveTab = useTabStore(s => s.getActiveTab) const requestIdRef = useRef(0)
const activeTabId = useTabStore(s => s.activeTabId)
// 渲染 Markdown const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
// B-03 + M-07: 响应式渲染(无轮询,无竞态)
useEffect(() => { useEffect(() => {
const tab = getActiveTab() if (!activeTab) {
if (!tab) {
setHtml('') setHtml('')
return return
} }
let cancelled = false const requestId = ++requestIdRef.current
renderMarkdown(tab.content).then(result => { renderMarkdown(activeTab.content).then(result => {
if (!cancelled) { // M-07: 只有当 requestId 仍为最新时才更新
if (requestId === requestIdRef.current) {
setHtml(result) setHtml(result)
} }
}) })
}, [activeTabId, activeTab?.content])
return () => { cancelled = true }
}, [activeTabId, getActiveTab])
// 监听编辑器内容变化
useEffect(() => {
const timer = setInterval(() => {
const tab = getActiveTab()
if (!tab) return
renderMarkdown(tab.content).then(result => {
setHtml(result)
})
}, 300)
return () => clearInterval(timer)
}, [getActiveTab])
// 拦截链接点击 // 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent) => { const handleClick = useCallback((e: React.MouseEvent) => {
@@ -54,18 +54,15 @@ export function SearchBar() {
doSearch(searchText) doSearch(searchText)
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch]) }, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
// 全部替换 // B-02: 全部替换(从后向前逐个替换)
const handleReplaceAll = useCallback(() => { const handleReplaceAll = useCallback(() => {
const tab = getActiveTab() const tab = getActiveTab()
if (!tab || matches.length === 0) return if (!tab || matches.length === 0) return
let result = '' let result = tab.content
let lastEnd = 0
for (let i = matches.length - 1; i >= 0; i--) { for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i] const m = matches[i]
result = tab.content.substring(lastEnd, m.start) + replaceText + result result = result.substring(0, m.start) + replaceText + result.substring(m.end)
lastEnd = m.end
} }
result = tab.content.substring(0, matches[0].start) + result
useTabStore.getState().updateTabContent(tab.id, result) useTabStore.getState().updateTabContent(tab.id, result)
doSearch(searchText) doSearch(searchText)
}, [getActiveTab, matches, replaceText, searchText, doSearch]) }, [getActiveTab, matches, replaceText, searchText, doSearch])
+13 -12
View File
@@ -73,7 +73,18 @@ export function Sidebar() {
} }
}, [isResizing]) }, [isResizing])
// 独立文件(不在当前文件夹内的已打开文件) // L-12: 提取为 useCallback 避免每次渲染重建
const handleFileClick = useCallback(async (path: string) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) {
switchToTab(existing.id)
} else if (window.electronAPI) {
const result = await window.electronAPI.readFile(path)
if (result.success && result.content) {
createTab(path, result.content)
}
}
}, [tabs, switchToTab, createTab])
const independentFiles = tabs.filter(t => { const independentFiles = tabs.filter(t => {
if (!t.filePath) return false if (!t.filePath) return false
if (!rootPath) return true if (!rootPath) return true
@@ -128,17 +139,7 @@ export function Sidebar() {
expandedDirs={expandedDirs} expandedDirs={expandedDirs}
toggleDir={toggleDir} toggleDir={toggleDir}
activeTabId={activeTabId} activeTabId={activeTabId}
onFileClick={async (path) => { onFileClick={handleFileClick}
const existing = tabs.find(t => t.filePath === path)
if (existing) {
switchToTab(existing.id)
} else if (window.electronAPI) {
const result = await window.electronAPI.readFile(path)
if (result.success && result.content) {
createTab(path, result.content)
}
}
}}
/> />
</> </>
)} )}
@@ -3,8 +3,10 @@ import { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
export function StatusBar() { export function StatusBar() {
const getActiveTab = useTabStore(s => s.getActiveTab) // B-04: 直接选择数据而非函数引用,确保响应式更新
const tab = getActiveTab() const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const tab = tabs.find(t => t.id === activeTabId) ?? null
return ( return (
<div id="statusbar"> <div id="statusbar">
+6
View File
@@ -8,6 +8,12 @@ export const recentFilesRepository = {
} else { } else {
await db.recentFiles.add({ filePath, lastOpened: Date.now() }) await db.recentFiles.add({ filePath, lastOpened: Date.now() })
} }
// L-06: 清理超过 50 条的旧记录
const all = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
if (all.length > 50) {
const toDelete = all.slice(50)
await db.recentFiles.bulkDelete(toDelete.map(f => f.id!))
}
}, },
async getAll(limit = 20): Promise<string[]> { async getAll(limit = 20): Promise<string[]> {
+8 -9
View File
@@ -7,11 +7,11 @@ export const settingsRepository = {
const record = await db.settings.get('default') const record = await db.settings.get('default')
if (record) { if (record) {
return { return {
darkMode: record.darkMode, darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
viewMode: record.viewMode, viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
splitRatio: record.splitRatio, splitRatio: record.splitRatio ?? DEFAULT_SETTINGS.splitRatio,
sidebarCollapsed: record.sidebarCollapsed, sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
} }
} }
} catch { } catch {
@@ -20,10 +20,9 @@ export const settingsRepository = {
return { ...DEFAULT_SETTINGS } return { ...DEFAULT_SETTINGS }
}, },
// M-09: 使用 put 直接合并,避免读写竞争
async save(settings: Partial<Settings>): Promise<void> { async save(settings: Partial<Settings>): Promise<void> {
const current = await this.load() const record: Partial<SettingsRecord> = { id: 'default', ...settings }
const merged = { ...current, ...settings } await db.settings.put(record as SettingsRecord)
const record: SettingsRecord = { id: 'default', ...merged }
await db.settings.put(record)
} }
} }
+3 -6
View File
@@ -48,17 +48,14 @@ export function useSearch() {
return content.substring(0, m.start) + store.replaceText + content.substring(m.end) return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
} }
// B-02: 全部替换(从后向前逐个替换)
const replaceAll = (content: string): string => { const replaceAll = (content: string): string => {
if (store.matches.length === 0) return content if (store.matches.length === 0) return content
let result = '' let result = content
let lastEnd = 0
// 从后往前替换
for (let i = store.matches.length - 1; i >= 0; i--) { for (let i = store.matches.length - 1; i >= 0; i--) {
const m = store.matches[i] const m = store.matches[i]
result = content.substring(lastEnd, m.start) + store.replaceText + result result = result.substring(0, m.start) + store.replaceText + result.substring(m.end)
lastEnd = m.end
} }
result = content.substring(0, store.matches[0].start) + result
return result return result
} }
+25 -84
View File
@@ -5,117 +5,58 @@ import { useSearchStore } from '../stores/searchStore'
import type { ViewMode } from '../types/settings' import type { ViewMode } from '../types/settings'
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) { export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const createTab = useTabStore(s => s.createTab)
const closeTab = useTabStore(s => s.closeTab)
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const switchToTab = useTabStore(s => s.switchToTab)
const mruStack = useTabStore(s => s.mruStack)
const setViewMode = useEditorStore(s => s.setViewMode) const setViewMode = useEditorStore(s => s.setViewMode)
const searchStore = useSearchStore
// M-08: 使用 getState() 而非依赖 tabs/activeTabId 等频繁变化的值
const handleKeydown = useCallback((e: KeyboardEvent) => { const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') { if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
e.preventDefault() if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
handleOpenFile() if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
return if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('split'); return }
} if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('editor'); return }
if (isCtrl && e.key === 's' && !e.shiftKey) { if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
e.preventDefault()
handleSave() // M-08: 通过 getState() 读取最新状态
return const tabState = useTabStore.getState()
} if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
if (isCtrl && e.shiftKey && e.key === 'S') {
e.preventDefault()
handleSaveAs()
return
}
if (isCtrl && e.key === '1') {
e.preventDefault()
setViewMode('split')
return
}
if (isCtrl && e.key === '2') {
e.preventDefault()
setViewMode('editor')
return
}
if (isCtrl && e.key === '3') {
e.preventDefault()
setViewMode('preview')
return
}
if (isCtrl && e.key === 't') {
e.preventDefault()
createTab(null, '')
return
}
if (isCtrl && e.key === 'w') { if (isCtrl && e.key === 'w') {
e.preventDefault() e.preventDefault()
if (activeTabId) closeTab(activeTabId) if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
return return
} }
// Ctrl+Tab / Ctrl+Shift+Tab
if (isCtrl && e.key === 'Tab') { if (isCtrl && e.key === 'Tab') {
e.preventDefault() e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) { if (tabs.length > 1) {
if (e.shiftKey) { if (e.shiftKey) {
if (mruStack.length > 0) { if (mruStack.length > 0) {
const targetId = mruStack[0] const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) { if (tabs.find(t => t.id === targetId)) {
switchToTab(targetId) tabState.switchToTab(targetId)
} }
} }
} else { } else {
const idx = tabs.findIndex(t => t.id === activeTabId) const idx = tabs.findIndex(t => t.id === activeTabId)
const next = (idx + 1) % tabs.length const next = (idx + 1) % tabs.length
switchToTab(tabs[next].id) tabState.switchToTab(tabs[next].id)
} }
} }
return return
} }
// Search & Replace // Search & Replace
const state = searchStore.getState() const searchState = useSearchStore.getState()
if (isCtrl && e.key === 'f') { if (isCtrl && e.key === 'f') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(false); return }
e.preventDefault() if (isCtrl && e.key === 'h') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(true); return }
state.setVisible(true) if (e.key === 'Escape' && searchState.isVisible) { e.preventDefault(); searchState.close(); return }
state.setShowReplace(false) if (e.altKey && e.key === 'c') { e.preventDefault(); searchState.toggleCaseSensitive(); return }
return if (e.altKey && e.key === 'r') { e.preventDefault(); searchState.toggleRegex(); return }
} if (isCtrl && e.shiftKey && e.key === 'G') { e.preventDefault(); searchState.findPrev(); return }
if (isCtrl && e.key === 'h') { }, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
e.preventDefault()
state.setVisible(true)
state.setShowReplace(true)
return
}
if (e.key === 'Escape' && state.isVisible) {
e.preventDefault()
state.close()
return
}
if (e.altKey && e.key === 'c') {
e.preventDefault()
state.toggleCaseSensitive()
return
}
if (e.altKey && e.key === 'r') {
e.preventDefault()
state.toggleRegex()
return
}
if (isCtrl && e.shiftKey && e.key === 'G') {
e.preventDefault()
state.findPrev()
return
}
if (isCtrl && e.shiftKey && e.key === 'H') {
e.preventDefault()
// replaceAll 需要外部实现
return
}
}, [handleOpenFile, handleSave, handleSaveAs, createTab, closeTab, activeTabId, tabs, switchToTab, mruStack, setViewMode, searchStore])
useEffect(() => { useEffect(() => {
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
+8 -7
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useCallback } from 'react'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository' import { settingsRepository } from '../db/settingsRepository'
import type { ViewMode } from '../types/settings' import type { ViewMode } from '../types/settings'
@@ -12,20 +12,21 @@ export function useSettings() {
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
settingsRepository.load().then(settings => { settingsRepository.load().then(settings => {
setViewMode(settings.viewMode) setViewMode(settings.viewMode ?? 'split')
setSplitRatio(settings.splitRatio) setSplitRatio(settings.splitRatio ?? 50)
}) })
}, [setViewMode, setSplitRatio]) }, [setViewMode, setSplitRatio])
const saveViewMode = (mode: ViewMode) => { // M-10: 使用 useCallback 稳定函数引用
const saveViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode) setViewMode(mode)
settingsRepository.save({ viewMode: mode }) settingsRepository.save({ viewMode: mode })
} }, [setViewMode])
const saveSplitRatio = (ratio: number) => { const saveSplitRatio = useCallback((ratio: number) => {
setSplitRatio(ratio) setSplitRatio(ratio)
settingsRepository.save({ splitRatio: ratio }) settingsRepository.save({ splitRatio: ratio })
} }, [setSplitRatio])
return { viewMode, splitRatio, saveViewMode, saveSplitRatio } return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
} }
+6 -3
View File
@@ -1,21 +1,24 @@
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository' import { settingsRepository } from '../db/settingsRepository'
export function useTheme() { export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode) const darkMode = useEditorStore(s => s.darkMode)
const setDarkMode = useEditorStore(s => s.setDarkMode) const setDarkMode = useEditorStore(s => s.setDarkMode)
const isInitialized = useRef(false)
// 初始化:从 IndexedDB 加载主题设置 // B-05: 从 IndexedDB 加载主题设置
useEffect(() => { useEffect(() => {
settingsRepository.load().then(settings => { settingsRepository.load().then(settings => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark) setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
isInitialized.current = true
}) })
}, [setDarkMode]) }, [setDarkMode])
// 应用主题到 DOM // B-05: 首次加载完成后才保存主题设置
useEffect(() => { useEffect(() => {
if (!isInitialized.current) return
document.documentElement.classList.toggle('dark', darkMode) document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode }) settingsRepository.save({ darkMode })
}, [darkMode]) }, [darkMode])
+6 -2
View File
@@ -11,10 +11,14 @@ export function getFileName(filePath: string): string {
} }
export function isAllowedFile(filePath: string): boolean { export function isAllowedFile(filePath: string): boolean {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase() const ext = getFileExtension(filePath)
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext) return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
} }
// L-07: 无扩展名文件正确返回空字符串
export function getFileExtension(filePath: string): string { export function getFileExtension(filePath: string): string {
return filePath.substring(filePath.lastIndexOf('.')).toLowerCase() const name = filePath.split(/[/\\]/).pop() || filePath
const dotIndex = name.lastIndexOf('.')
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
return name.substring(dotIndex).toLowerCase()
} }
+9 -7
View File
@@ -1,10 +1,12 @@
// 滚动同步算法 — 基于块级元素 DOM 位置映射 // 滚动同步算法 — 基于块级元素 DOM 位置映射
import type { SearchMatch } from '../types/search'
export interface ScrollMap { export interface ScrollMap {
lineToPreviewMap: Float32Array lineToPreviewMap: Float32Array
} }
// B-06: 用 NaN 标记未映射状态(0 是合法的 offsetTop
const UNMAPPED = NaN
// 识别块级元素起始行 // 识别块级元素起始行
function identifyBlockStarts(lines: string[]): Set<number> { function identifyBlockStarts(lines: string[]): Set<number> {
const starts = new Set<number>() const starts = new Set<number>()
@@ -13,8 +15,8 @@ function identifyBlockStarts(lines: string[]): Set<number> {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i].trimStart() const line = lines[i].trimStart()
// 围栏代码块边界 // 围栏代码块边界(仅匹配 ``` 开头,不匹配 `````` 等)
if (line.startsWith('```')) { if (/^`{3,}(?!`)/.test(line)) {
if (!inCodeBlock) starts.add(i) if (!inCodeBlock) starts.add(i)
inCodeBlock = !inCodeBlock inCodeBlock = !inCodeBlock
continue continue
@@ -48,7 +50,7 @@ export function buildScrollMap(
): ScrollMap { ): ScrollMap {
const lines = markdown.split('\n') const lines = markdown.split('\n')
const totalLines = lines.length const totalLines = lines.length
const lineToPreviewMap = new Float32Array(totalLines) const lineToPreviewMap = new Float32Array(totalLines).fill(UNMAPPED)
// 1. 识别块级元素起始行 // 1. 识别块级元素起始行
const blockStarts = identifyBlockStarts(lines) const blockStarts = identifyBlockStarts(lines)
@@ -81,16 +83,16 @@ export function buildScrollMap(
// 4. 线性插值填充所有行 // 4. 线性插值填充所有行
for (let i = 0; i < totalLines; i++) { for (let i = 0; i < totalLines; i++) {
if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue if (!isNaN(lineToPreviewMap[i]) && uniqueStartsSet.has(i)) continue
// 找到前后最近的已映射行 // 找到前后最近的已映射行
let prevLine = -1 let prevLine = -1
let nextLine = -1 let nextLine = -1
for (let j = i - 1; j >= 0; j--) { for (let j = i - 1; j >= 0; j--) {
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break } if (!isNaN(lineToPreviewMap[j]) || uniqueStartsSet.has(j)) { prevLine = j; break }
} }
for (let j = i + 1; j < totalLines; j++) { for (let j = i + 1; j < totalLines; j++) {
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break } if (!isNaN(lineToPreviewMap[j]) || uniqueStartsSet.has(j)) { nextLine = j; break }
} }
if (prevLine === -1 && nextLine === -1) { if (prevLine === -1 && nextLine === -1) {
+3 -3
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { nanoid } from 'nanoid'
import type { Tab } from '../types/tab' import type { Tab } from '../types/tab'
interface TabState { interface TabState {
@@ -16,8 +17,7 @@ interface TabState {
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
} }
let tabIdCounter = 0 // L-04: 使用 nanoid 替代计数器(HMR 安全)
export const useTabStore = create<TabState>((set, get) => ({ export const useTabStore = create<TabState>((set, get) => ({
tabs: [], tabs: [],
activeTabId: null, activeTabId: null,
@@ -34,7 +34,7 @@ export const useTabStore = create<TabState>((set, get) => ({
} }
const tab: Tab = { const tab: Tab = {
id: String(++tabIdCounter), id: nanoid(),
filePath, filePath,
content, content,
isModified: false, isModified: false,
+3
View File
@@ -752,6 +752,9 @@ html, body {
#app.mode-preview #editor-panel, #app.mode-preview #editor-panel,
#app.mode-preview #resizer { display: none; } #app.mode-preview #resizer { display: none; }
#app.mode-editor #preview-panel,
#app.mode-editor #resizer { display: none; }
/* Toast */ /* Toast */
#toast-notification { #toast-notification {
position: fixed; position: fixed;