diff --git a/src/main/file-system.ts b/src/main/file-system.ts index 04b6150..9148729 100644 --- a/src/main/file-system.ts +++ b/src/main/file-system.ts @@ -1,5 +1,7 @@ -import { readFile, stat, writeFile, readdir } from 'fs/promises' -import { join, extname } from 'path' +import { readFile, stat, writeFile, readdir, rename } from 'fs/promises' +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 ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt']) @@ -8,14 +10,8 @@ const SKIP_DIRS = new Set([ '.next', '.nuxt', '__pycache__', '.DS_Store' ]) -export interface FileNode { - name: string - path: string - type: 'file' | 'dir' - children?: FileNode[] -} - -export async function readFileContent(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> { +// L-09: 使用 shared 类型 +export async function readFileContent(filePath: string): Promise { try { const fileStat = await stat(filePath) 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 { 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 } } catch (err) { return { success: false, error: (err as Error).message } } } -export async function buildDirTree(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) +// M-05: 添加递归深度限制 + 每个 entry 错误边界 +export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise { + if (depth > maxDepth) return [] + + let entries + try { + entries = await readdir(dirPath, { withFileTypes: true }) + } catch { + return [] // L-10: 权限错误时返回空数组而非抛异常 + } + entries.sort((a, b) => { 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 { const childPath = join(dirPath, entry.name) if (entry.isDirectory()) { - const subChildren = await buildDirTree(childPath) + const subChildren = await buildDirTree(childPath, depth + 1, maxDepth) if (subChildren.length > 0) { children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }) } diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts index 6647f8a..1c752b1 100644 --- a/src/main/file-watcher.ts +++ b/src/main/file-watcher.ts @@ -22,6 +22,11 @@ export class FileWatcher { } } }) + // M-04: 监听 error 事件,文件被删除时清理状态 + this.watcher.on('error', () => { + this.currentPath = null + this.watcher = null + }) } catch { // silently ignore } @@ -32,6 +37,7 @@ export class FileWatcher { this.watcher.close() this.watcher = null } + this.currentPath = null } getCurrentPath(): string | null { @@ -60,10 +66,19 @@ export class SidebarWatcher { if (win && !win.isDestroyed()) { if (this.refreshTimer) clearTimeout(this.refreshTimer) this.refreshTimer = setTimeout(() => { - win.webContents.send('sidebar:dirChanged') + // M-03: 延迟后再次检查窗口状态 + const w = this.getMainWindow() + if (w && !w.isDestroyed()) { + w.webContents.send('sidebar:dirChanged') + } }, 300) } }) + // M-04: 监听 error 事件 + this.watcher.on('error', () => { + this.watcher = null + this.watchPath = null + }) } catch { // silently ignore } diff --git a/src/main/index.ts b/src/main/index.ts index 3e6dbc3..fa8f9ba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,12 +19,14 @@ const sidebarWatcher = new SidebarWatcher(() => mainWindow) function openFileInTab(filePath: string): void { if (!mainWindow || mainWindow.isDestroyed()) return readFileContent(filePath).then(result => { - if (result.success) { + if (result.success && mainWindow && !mainWindow.isDestroyed()) { state.activeFilePath = filePath fileWatcher.start(filePath) - mainWindow!.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) - mainWindow!.webContents.send('file:openInTab', { filePath, content: result.content }) + mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) + 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(() => { - 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 - } - }) - + // B-07: 完整的窗口初始化逻辑(activate 复用) + function initWindow(): void { mainWindow = createWindow() - // 注册 IPC 处理器 registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) - // 加载渲染进程页面 if (process.env.ELECTRON_RENDERER_URL) { mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) } else { @@ -108,6 +98,21 @@ if (!lockOk) { sidebarWatcher.stop() 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) @@ -120,10 +125,10 @@ if (!lockOk) { if (process.platform !== 'darwin') app.quit() }) + // B-07: macOS activate 完整重建窗口 app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow() - setupCloseHandler() + initWindow() } }) } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d17efe6..8a1d959 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -9,7 +9,7 @@ export function registerIpcHandlers( getMainWindow: () => BrowserWindow | null, fileWatcher: FileWatcher, sidebarWatcher: SidebarWatcher, - state: { activeFilePath: string | null; pendingFilePath: string | null } + state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null } ): void { // 打开文件对话框 ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { @@ -67,7 +67,9 @@ export function registerIpcHandlers( filters: [{ name: 'Markdown 文件', extensions: ['md'] }] }) if (!saveResult.canceled) { + fileWatcher.setSelfWriting(true) const result = await saveFileContent(saveResult.filePath, data.content) + fileWatcher.setSelfWriting(false) if (result.success) { state.activeFilePath = saveResult.filePath fileWatcher.start(saveResult.filePath) @@ -177,7 +179,12 @@ export function registerIpcHandlers( } }) + // B-01: 重置关闭状态 + 清除超时定时器 ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => { - // 重置关闭状态 + state.isClosing = false + if (state.closeTimeout) { + clearTimeout(state.closeTimeout) + state.closeTimeout = null + } }) } diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 32708d5..5d12774 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,5 +1,6 @@ import { BrowserWindow, app } from 'electron' import { join } from 'path' +import { existsSync, statSync } from 'fs' export function createWindow(): BrowserWindow { const mainWindow = new BrowserWindow({ @@ -43,13 +44,16 @@ export function setupSingleInstanceLock( return true } +// L-08: 使用静态导入 + 验证是文件而非目录 export function getFilePathFromArgs(args: string[]): string | null { for (let i = 1; i < args.length; i++) { const arg = args[i] if (!arg.startsWith('--') && !arg.startsWith('-')) { try { - const fs = require('fs') - if (fs.existsSync(arg)) return arg + if (existsSync(arg)) { + const s = statSync(arg) + if (s.isFile()) return arg + } } catch { // ignore } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5a5caa7..48ad9e9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,16 @@ 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', { // File operations openFile: () => ipcRenderer.invoke('dialog:openFile'), @@ -42,6 +53,10 @@ contextBridge.exposeInMainWorld('electronAPI', { onConfirmClose: (callback: () => void) => ipcRenderer.on('window:confirmClose', () => callback()), - // Remove listeners - removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel) + // M-01: 只允许移除白名单通道的监听器 + removeAllListeners: (channel: string) => { + if (ALLOWED_REMOVE_CHANNELS.has(channel)) { + ipcRenderer.removeAllListeners(channel) + } + } }) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 06a978c..888aed9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -113,19 +113,32 @@ export default function App() { saveViewMode(mode) }, [saveViewMode]) - // 主进程事件 + // M-11: 主进程事件注册(使用 ref 保持最新回调引用 + cleanup 清理) + const handleSaveRef = useRef(handleSave) + const handleSaveAsRef = useRef(handleSaveAs) + handleSaveRef.current = handleSave + handleSaveAsRef.current = handleSaveAs + useEffect(() => { if (!window.electronAPI) return - window.electronAPI.removeAllListeners('file:openInTab') - window.electronAPI.removeAllListeners('menu:save') - window.electronAPI.removeAllListeners('menu:saveAs') + const api = window.electronAPI - window.electronAPI.onFileOpenInTab((data: { filePath: string; content: string }) => { + const onOpen = (data: { filePath: string; content: string }) => { createTab(data.filePath, data.content) - }) - window.electronAPI.onMenuSave(() => handleSave()) - window.electronAPI.onMenuSaveAs(() => handleSaveAs()) - }, [createTab, handleSave, handleSaveAs]) + } + const onSave = () => handleSaveRef.current() + const onSaveAs = () => handleSaveAsRef.current() + + 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 hasTabs = tabs.length > 0 @@ -183,7 +196,7 @@ export default function App() { )} - {viewMode !== 'editor' &&
} + {viewMode !== 'editor' && } {viewMode !== 'editor' && (
@@ -206,3 +219,42 @@ export default function App() {
) } + +// 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
+} diff --git a/src/renderer/components/DropOverlay/DropOverlay.tsx b/src/renderer/components/DropOverlay/DropOverlay.tsx index cf35be9..231f263 100644 --- a/src/renderer/components/DropOverlay/DropOverlay.tsx +++ b/src/renderer/components/DropOverlay/DropOverlay.tsx @@ -1,29 +1,18 @@ import React, { useState, useEffect } from 'react' export function DropOverlay() { - const [isVisible, setIsVisible] = useState(false) + // L-11: 简化为单一状态 const [dragCounter, setDragCounter] = useState(0) useEffect(() => { const handleDragEnter = (e: DragEvent) => { e.preventDefault() - setDragCounter(prev => { - const next = prev + 1 - if (next > 0) setIsVisible(true) - return next - }) + setDragCounter(prev => prev + 1) } const handleDragLeave = (e: DragEvent) => { e.preventDefault() - setDragCounter(prev => { - const next = prev - 1 - if (next <= 0) { - setIsVisible(false) - return 0 - } - return next - }) + setDragCounter(prev => Math.max(0, prev - 1)) } const handleDragOver = (e: DragEvent) => { @@ -32,7 +21,6 @@ export function DropOverlay() { const handleDrop = () => { setDragCounter(0) - setIsVisible(false) } document.addEventListener('dragenter', handleDragEnter) @@ -48,7 +36,7 @@ export function DropOverlay() { } }, []) - if (!isVisible) return null + if (dragCounter <= 0) return null return (
diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 758fd27..58bc829 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,9 +1,6 @@ import React, { useEffect, useRef, useCallback, useState } from 'react' import { useTabStore } from '../../stores/tabStore' -import { useEditorStore } from '../../stores/editorStore' import { useSearchStore } from '../../stores/searchStore' -import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine' -import type { SearchMatch } from '../../types/search' export function Editor() { const textareaRef = useRef(null) @@ -15,7 +12,10 @@ export function Editor() { const updateTabContent = useTabStore(s => s.updateTabContent) 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 [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() if (e.shiftKey) { - searchStore.findPrev() + searchFindPrev() } else { - searchStore.findNext() + searchFindNext() } } - }, [searchStore]) + }, [searchIsVisible, searchFindNext, searchFindPrev]) // 渲染行号 const renderLineNumbers = () => { diff --git a/src/renderer/components/Preview/Preview.tsx b/src/renderer/components/Preview/Preview.tsx index 00b7991..d637406 100644 --- a/src/renderer/components/Preview/Preview.tsx +++ b/src/renderer/components/Preview/Preview.tsx @@ -5,38 +5,27 @@ import { renderMarkdown } from '../../lib/markdown' export function Preview() { const [html, setHtml] = useState('') const previewRef = useRef(null) - const getActiveTab = useTabStore(s => s.getActiveTab) - const activeTabId = useTabStore(s => s.activeTabId) + const requestIdRef = useRef(0) - // 渲染 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(() => { - const tab = getActiveTab() - if (!tab) { + if (!activeTab) { setHtml('') return } - let cancelled = false - renderMarkdown(tab.content).then(result => { - if (!cancelled) { + const requestId = ++requestIdRef.current + renderMarkdown(activeTab.content).then(result => { + // M-07: 只有当 requestId 仍为最新时才更新 + if (requestId === requestIdRef.current) { setHtml(result) } }) - - 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]) + }, [activeTabId, activeTab?.content]) // 拦截链接点击 const handleClick = useCallback((e: React.MouseEvent) => { diff --git a/src/renderer/components/SearchBar/SearchBar.tsx b/src/renderer/components/SearchBar/SearchBar.tsx index cec2669..df78e9a 100644 --- a/src/renderer/components/SearchBar/SearchBar.tsx +++ b/src/renderer/components/SearchBar/SearchBar.tsx @@ -54,18 +54,15 @@ export function SearchBar() { doSearch(searchText) }, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch]) - // 全部替换 + // B-02: 全部替换(从后向前逐个替换) const handleReplaceAll = useCallback(() => { const tab = getActiveTab() if (!tab || matches.length === 0) return - let result = '' - let lastEnd = 0 + let result = tab.content for (let i = matches.length - 1; i >= 0; i--) { const m = matches[i] - result = tab.content.substring(lastEnd, m.start) + replaceText + result - lastEnd = m.end + result = result.substring(0, m.start) + replaceText + result.substring(m.end) } - result = tab.content.substring(0, matches[0].start) + result useTabStore.getState().updateTabContent(tab.id, result) doSearch(searchText) }, [getActiveTab, matches, replaceText, searchText, doSearch]) diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 9825bbe..a701146 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -73,7 +73,18 @@ export function Sidebar() { } }, [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 => { if (!t.filePath) return false if (!rootPath) return true @@ -128,17 +139,7 @@ export function Sidebar() { expandedDirs={expandedDirs} toggleDir={toggleDir} activeTabId={activeTabId} - onFileClick={async (path) => { - 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) - } - } - }} + onFileClick={handleFileClick} /> )} diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx index 644c0df..c4880ec 100644 --- a/src/renderer/components/StatusBar/StatusBar.tsx +++ b/src/renderer/components/StatusBar/StatusBar.tsx @@ -3,8 +3,10 @@ import { useTabStore } from '../../stores/tabStore' import { getFileName } from '../../lib/fileUtils' export function StatusBar() { - const getActiveTab = useTabStore(s => s.getActiveTab) - const tab = getActiveTab() + // B-04: 直接选择数据而非函数引用,确保响应式更新 + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const tab = tabs.find(t => t.id === activeTabId) ?? null return (
diff --git a/src/renderer/db/recentFilesRepository.ts b/src/renderer/db/recentFilesRepository.ts index 81408fd..bc2a881 100644 --- a/src/renderer/db/recentFilesRepository.ts +++ b/src/renderer/db/recentFilesRepository.ts @@ -8,6 +8,12 @@ export const recentFilesRepository = { } else { 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 { diff --git a/src/renderer/db/settingsRepository.ts b/src/renderer/db/settingsRepository.ts index 47ddc6c..51dbcea 100644 --- a/src/renderer/db/settingsRepository.ts +++ b/src/renderer/db/settingsRepository.ts @@ -7,11 +7,11 @@ export const settingsRepository = { const record = await db.settings.get('default') if (record) { return { - darkMode: record.darkMode, - viewMode: record.viewMode, - splitRatio: record.splitRatio, - sidebarCollapsed: record.sidebarCollapsed, - sidebarWidth: record.sidebarWidth + darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode, + viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode, + splitRatio: record.splitRatio ?? DEFAULT_SETTINGS.splitRatio, + sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed, + sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth } } } catch { @@ -20,10 +20,9 @@ export const settingsRepository = { return { ...DEFAULT_SETTINGS } }, + // M-09: 使用 put 直接合并,避免读写竞争 async save(settings: Partial): Promise { - const current = await this.load() - const merged = { ...current, ...settings } - const record: SettingsRecord = { id: 'default', ...merged } - await db.settings.put(record) + const record: Partial = { id: 'default', ...settings } + await db.settings.put(record as SettingsRecord) } } diff --git a/src/renderer/hooks/useFileWatch.ts b/src/renderer/hooks/useFileWatch.ts index 4df9a84..b2445ea 100644 --- a/src/renderer/hooks/useFileWatch.ts +++ b/src/renderer/hooks/useFileWatch.ts @@ -48,17 +48,14 @@ export function useSearch() { return content.substring(0, m.start) + store.replaceText + content.substring(m.end) } + // B-02: 全部替换(从后向前逐个替换) const replaceAll = (content: string): string => { if (store.matches.length === 0) return content - let result = '' - let lastEnd = 0 - // 从后往前替换 + let result = content for (let i = store.matches.length - 1; i >= 0; i--) { const m = store.matches[i] - result = content.substring(lastEnd, m.start) + store.replaceText + result - lastEnd = m.end + result = result.substring(0, m.start) + store.replaceText + result.substring(m.end) } - result = content.substring(0, store.matches[0].start) + result return result } diff --git a/src/renderer/hooks/useKeyboard.ts b/src/renderer/hooks/useKeyboard.ts index 4dfef16..34c3e92 100644 --- a/src/renderer/hooks/useKeyboard.ts +++ b/src/renderer/hooks/useKeyboard.ts @@ -5,117 +5,58 @@ import { useSearchStore } from '../stores/searchStore' import type { ViewMode } from '../types/settings' 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 searchStore = useSearchStore + // M-08: 使用 getState() 而非依赖 tabs/activeTabId 等频繁变化的值 const handleKeydown = useCallback((e: KeyboardEvent) => { const isCtrl = e.ctrlKey || e.metaKey - if (isCtrl && e.key === 'o') { - e.preventDefault() - handleOpenFile() - return - } - if (isCtrl && e.key === 's' && !e.shiftKey) { - e.preventDefault() - handleSave() - 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 === 'o') { e.preventDefault(); handleOpenFile(); return } + if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); 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 } + + // M-08: 通过 getState() 读取最新状态 + const tabState = useTabStore.getState() + if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return } if (isCtrl && e.key === 'w') { e.preventDefault() - if (activeTabId) closeTab(activeTabId) + if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId) return } + + // Ctrl+Tab / Ctrl+Shift+Tab if (isCtrl && e.key === 'Tab') { e.preventDefault() + const { tabs, activeTabId, mruStack } = tabState if (tabs.length > 1) { if (e.shiftKey) { if (mruStack.length > 0) { const targetId = mruStack[0] if (tabs.find(t => t.id === targetId)) { - switchToTab(targetId) + tabState.switchToTab(targetId) } } } else { const idx = tabs.findIndex(t => t.id === activeTabId) const next = (idx + 1) % tabs.length - switchToTab(tabs[next].id) + tabState.switchToTab(tabs[next].id) } } return } // Search & Replace - const state = searchStore.getState() - if (isCtrl && e.key === 'f') { - e.preventDefault() - state.setVisible(true) - state.setShowReplace(false) - return - } - if (isCtrl && e.key === 'h') { - 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]) + const searchState = useSearchStore.getState() + if (isCtrl && e.key === 'f') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(false); return } + if (isCtrl && e.key === 'h') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(true); return } + if (e.key === 'Escape' && searchState.isVisible) { e.preventDefault(); searchState.close(); return } + if (e.altKey && e.key === 'c') { e.preventDefault(); searchState.toggleCaseSensitive(); return } + if (e.altKey && e.key === 'r') { e.preventDefault(); searchState.toggleRegex(); return } + if (isCtrl && e.shiftKey && e.key === 'G') { e.preventDefault(); searchState.findPrev(); return } + }, [handleOpenFile, handleSave, handleSaveAs, setViewMode]) useEffect(() => { document.addEventListener('keydown', handleKeydown) diff --git a/src/renderer/hooks/useSettings.ts b/src/renderer/hooks/useSettings.ts index d7ba0c2..d9dbbb0 100644 --- a/src/renderer/hooks/useSettings.ts +++ b/src/renderer/hooks/useSettings.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useCallback } from 'react' import { useEditorStore } from '../stores/editorStore' import { settingsRepository } from '../db/settingsRepository' import type { ViewMode } from '../types/settings' @@ -12,20 +12,21 @@ export function useSettings() { // 初始化 useEffect(() => { settingsRepository.load().then(settings => { - setViewMode(settings.viewMode) - setSplitRatio(settings.splitRatio) + setViewMode(settings.viewMode ?? 'split') + setSplitRatio(settings.splitRatio ?? 50) }) }, [setViewMode, setSplitRatio]) - const saveViewMode = (mode: ViewMode) => { + // M-10: 使用 useCallback 稳定函数引用 + const saveViewMode = useCallback((mode: ViewMode) => { setViewMode(mode) settingsRepository.save({ viewMode: mode }) - } + }, [setViewMode]) - const saveSplitRatio = (ratio: number) => { + const saveSplitRatio = useCallback((ratio: number) => { setSplitRatio(ratio) settingsRepository.save({ splitRatio: ratio }) - } + }, [setSplitRatio]) return { viewMode, splitRatio, saveViewMode, saveSplitRatio } } diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts index 60e0aa3..3d698ab 100644 --- a/src/renderer/hooks/useTheme.ts +++ b/src/renderer/hooks/useTheme.ts @@ -1,21 +1,24 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useEditorStore } from '../stores/editorStore' import { settingsRepository } from '../db/settingsRepository' export function useTheme() { const darkMode = useEditorStore(s => s.darkMode) const setDarkMode = useEditorStore(s => s.setDarkMode) + const isInitialized = useRef(false) - // 初始化:从 IndexedDB 加载主题设置 + // B-05: 从 IndexedDB 加载主题设置 useEffect(() => { settingsRepository.load().then(settings => { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark) + isInitialized.current = true }) }, [setDarkMode]) - // 应用主题到 DOM + // B-05: 首次加载完成后才保存主题设置 useEffect(() => { + if (!isInitialized.current) return document.documentElement.classList.toggle('dark', darkMode) settingsRepository.save({ darkMode }) }, [darkMode]) diff --git a/src/renderer/lib/fileUtils.ts b/src/renderer/lib/fileUtils.ts index 6ecc85a..94bced0 100644 --- a/src/renderer/lib/fileUtils.ts +++ b/src/renderer/lib/fileUtils.ts @@ -11,10 +11,14 @@ export function getFileName(filePath: string): string { } 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) } +// L-07: 无扩展名文件正确返回空字符串 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() } diff --git a/src/renderer/lib/scrollSync.ts b/src/renderer/lib/scrollSync.ts index 75bfca5..673e864 100644 --- a/src/renderer/lib/scrollSync.ts +++ b/src/renderer/lib/scrollSync.ts @@ -1,10 +1,12 @@ // 滚动同步算法 — 基于块级元素 DOM 位置映射 -import type { SearchMatch } from '../types/search' export interface ScrollMap { lineToPreviewMap: Float32Array } +// B-06: 用 NaN 标记未映射状态(0 是合法的 offsetTop) +const UNMAPPED = NaN + // 识别块级元素起始行 function identifyBlockStarts(lines: string[]): Set { const starts = new Set() @@ -13,8 +15,8 @@ function identifyBlockStarts(lines: string[]): Set { for (let i = 0; i < lines.length; i++) { const line = lines[i].trimStart() - // 围栏代码块边界 - if (line.startsWith('```')) { + // 围栏代码块边界(仅匹配 ``` 开头,不匹配 `````` 等) + if (/^`{3,}(?!`)/.test(line)) { if (!inCodeBlock) starts.add(i) inCodeBlock = !inCodeBlock continue @@ -48,7 +50,7 @@ export function buildScrollMap( ): ScrollMap { const lines = markdown.split('\n') const totalLines = lines.length - const lineToPreviewMap = new Float32Array(totalLines) + const lineToPreviewMap = new Float32Array(totalLines).fill(UNMAPPED) // 1. 识别块级元素起始行 const blockStarts = identifyBlockStarts(lines) @@ -81,16 +83,16 @@ export function buildScrollMap( // 4. 线性插值填充所有行 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 nextLine = -1 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++) { - 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) { diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 93f6fbe..473eb84 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { nanoid } from 'nanoid' import type { Tab } from '../types/tab' interface TabState { @@ -16,8 +17,7 @@ interface TabState { updateTabScroll: (tabId: string, scroll: Partial>) => void } -let tabIdCounter = 0 - +// L-04: 使用 nanoid 替代计数器(HMR 安全) export const useTabStore = create((set, get) => ({ tabs: [], activeTabId: null, @@ -34,7 +34,7 @@ export const useTabStore = create((set, get) => ({ } const tab: Tab = { - id: String(++tabIdCounter), + id: nanoid(), filePath, content, isModified: false, diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index ca776d8..56dca68 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -752,6 +752,9 @@ html, body { #app.mode-preview #editor-panel, #app.mode-preview #resizer { display: none; } +#app.mode-editor #preview-panel, +#app.mode-editor #resizer { display: none; } + /* Toast */ #toast-notification { position: fixed;