v0.2.0: 全面代码质量优化

- ESLint flat config + Prettier + EditorConfig
- Markdown处理器LRU缓存
- Zustand选择器优化减少重渲染
- CodeMirror Compartment主题热切换
- Preview防抖(150ms) + IndexedDB去抖(500ms)
- 组件拆分: App.tsx 310→104行, Sidebar.tsx 244→90行
- 统一错误处理 errorHandler.ts
- ConfirmDialog替代原生confirm
- LoadingSpinner加载状态
- Toast多条堆叠+类型区分
- 可访问性增强(ARIA属性、键盘导航)
- Vitest测试框架(78个测试用例)
- Git hooks(husky + lint-staged)
- 项目文档(README.md, CONTRIBUTING.md)
This commit is contained in:
thzxx
2026-06-03 22:13:32 +08:00
parent 3cecb0f9eb
commit 7a4e2b0a67
72 changed files with 5103 additions and 894 deletions
+26
View File
@@ -0,0 +1,26 @@
import { useEffect } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
/**
* AR-02: 自动展开到活动文件所在的目录
*/
export function useAutoExpandDir(activeFilePath: string | null) {
const rootPath = useSidebarStore(s => s.rootPath)
const expandDirs = useSidebarStore(s => s.expandDirs)
useEffect(() => {
if (!activeFilePath || !rootPath) return
const norm = (p: string) => p.replace(/\\/g, '/')
if (!norm(activeFilePath).startsWith(norm(rootPath))) return
const dirsToExpand: string[] = []
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
let prev = ''
while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) {
prev = dir
dirsToExpand.push(dir)
dir = dir.replace(/[/\\][^/\\]+$/, '')
}
if (dirsToExpand.length > 0) expandDirs(dirsToExpand)
}, [activeFilePath, rootPath, expandDirs])
}
+71
View File
@@ -0,0 +1,71 @@
import { useState, useCallback, useRef } from 'react'
interface ConfirmOptions {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
}
interface ConfirmState extends ConfirmOptions {
open: boolean
resolve: ((value: boolean) => void) | null
}
/**
* UX-01: Promise-based 确认对话框 hook
* 替代原生 confirm(),返回 Promise<boolean>
*/
export function useConfirm() {
const [state, setState] = useState<ConfirmState>({
open: false,
title: '',
message: '',
resolve: null
})
// 使用 ref 确保回调中能拿到最新的 resolve
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
setState({
open: true,
title: options.title,
message: options.message,
confirmLabel: options.confirmLabel,
cancelLabel: options.cancelLabel,
variant: options.variant,
resolve
})
})
}, [])
const handleConfirm = useCallback(() => {
resolveRef.current?.(true)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
const handleCancel = useCallback(() => {
resolveRef.current?.(false)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
return {
confirm,
confirmDialogProps: {
open: state.open,
title: state.title,
message: state.message,
confirmLabel: state.confirmLabel,
cancelLabel: state.cancelLabel,
variant: state.variant,
onConfirm: handleConfirm,
onCancel: handleCancel
}
}
}
+5 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants'
import { logError } from '../lib/errorHandler'
export function useDragDrop(showToast: (msg: string) => void) {
const createTab = useTabStore(s => s.createTab)
@@ -15,7 +16,7 @@ export function useDragDrop(showToast: (msg: string) => void) {
let rejected = 0
for (const file of Array.from(files)) {
const filePath = (file as File & { path?: string }).path || file.name
const filePath: string = (file as File & { path?: string }).path || file.name
if (!isAllowedFile(filePath)) {
rejected++
continue
@@ -31,12 +32,12 @@ export function useDragDrop(showToast: (msg: string) => void) {
if (result.success && result.content !== undefined) {
createTab(filePath, result.content)
}
} catch (err) {
console.error('Failed to read file via electronAPI:', err)
} catch (error) {
logError('拖拽读取文件失败', error)
}
} else {
const reader = new FileReader()
reader.onload = (ev) => {
reader.onload = (ev: ProgressEvent<FileReader>) => {
const content = ev.target?.result as string
createTab(file.name, content)
}
+84
View File
@@ -0,0 +1,84 @@
import { useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler'
import type { ToastType } from '../components/Toast/Toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
*/
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return
setLoading('file-open', true)
const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) {
createTab(result.filePath, result.content)
if (result.filePath) recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} catch (error) {
logError('打开文件失败', error)
showToast('打开文件失败', 'error')
} finally {
setLoading('file-open', false)
}
}, [createTab, showToast, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: tab.filePath,
content: tab.content
})
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success')
}
} catch (error) {
logError('保存文件失败', error)
showToast('保存失败', 'error')
}
}, [getActiveTab, showToast])
const handleSaveAs = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFileAs({ content: tab.content })
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success')
}
} catch (error) {
logError('另存为失败', error)
showToast('另存为失败', 'error')
}
}, [getActiveTab, showToast])
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
if (!window.electronAPI) return
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} finally {
setLoading('file-open', false)
}
}, [createTab, setLoading])
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
}
+9 -2
View File
@@ -1,8 +1,14 @@
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
/**
* AR-03: 文件外部修改检测
* 使用 Zustand store 状态替代 DOM CustomEvent
*/
export function useFileWatch() {
const getActiveTab = useTabStore(s => s.getActiveTab)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
useEffect(() => {
if (!window.electronAPI) return
@@ -10,10 +16,11 @@ export function useFileWatch() {
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab()
if (tab && tab.filePath === filePath) {
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
// AR-03: 直接更新 store 状态,不再派发 CustomEvent
setExternallyModified({ filePath })
}
})
return unsubscribe
}, [getActiveTab])
}, [getActiveTab, setExternallyModified])
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useCallback } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
import { useEditorStore } from '../stores/editorStore'
/**
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
* UX-02: 添加目录加载 loading 状态
*/
export function useFolderOperations() {
const rootPath = useSidebarStore(s => s.rootPath)
const setRootPath = useSidebarStore(s => s.setRootPath)
const setTree = useSidebarStore(s => s.setTree)
const expandDirs = useSidebarStore(s => s.expandDirs)
const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.openFolderDialog()
if (!result.canceled && result.filePaths[0]) {
const dirPath = result.filePaths[0]
setRootPath(dirPath)
expandDirs([dirPath])
setLoading('dir-load', true)
try {
const dirTree = await window.electronAPI.readDirTree(dirPath)
if (dirTree.success && dirTree.tree) {
setTree(dirTree.tree)
window.electronAPI.watchDir(dirPath)
}
} finally {
setLoading('dir-load', false)
}
}
}, [setRootPath, setTree, expandDirs, setLoading])
const refreshTree = useCallback(async () => {
if (!rootPath || !window.electronAPI) return
setLoading('dir-load', true)
try {
const result = await window.electronAPI.readDirTree(rootPath)
if (result.success && result.tree) setTree(result.tree)
} finally {
setLoading('dir-load', false)
}
}, [rootPath, setTree, setLoading])
useEffect(() => {
if (!window.electronAPI) return
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
return unsubscribe
}, [refreshTree])
return { handleOpenFolder }
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
/**
* 主进程事件注册 hook
* 处理菜单触发的文件打开、保存、另存为事件
*/
export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) {
const createTab = useTabStore(s => s.createTab)
const handleSaveRef = useRef(handleSave)
const handleSaveAsRef = useRef(handleSaveAs)
handleSaveRef.current = handleSave
handleSaveAsRef.current = handleSaveAs
useEffect(() => {
if (!window.electronAPI) return
const api = window.electronAPI
const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content)
if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
const unsub1 = api.onFileOpenInTab(onOpen)
const unsub2 = api.onMenuSave(() => handleSaveRef.current())
const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current())
return () => { unsub1(); unsub2(); unsub3() }
}, [createTab])
}
-1
View File
@@ -1,7 +1,6 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import type { ViewMode } from '../types/settings'
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const setViewMode = useEditorStore(s => s.setViewMode)
+6 -9
View File
@@ -1,20 +1,17 @@
import { useEffect, useCallback } from 'react'
import { useCallback } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ViewMode } from '../types/settings'
/**
* AR-04: 设置 hook
* 不再独立加载设置(由 useSettingsInit 统一加载)
* 仅负责视图模式的读取和保存
*/
export function useSettings() {
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
// 初始化
useEffect(() => {
settingsRepository.load().then(settings => {
setViewMode(settings.viewMode ?? 'editor')
}).catch(err => console.error('Failed to load settings:', err))
}, [setViewMode])
// M-10: 使用 useCallback 稳定函数引用
const saveViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode)
settingsRepository.save({ viewMode: mode })
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { useSidebarStore } from '../stores/sidebarStore'
import { settingsRepository } from '../db/settingsRepository'
import { logError } from '../lib/errorHandler'
/**
* AR-04: 统一设置加载 hook
* 一次性从 IndexedDB 加载所有设置,分发到各 store
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式
*/
export function useSettingsInit() {
const setDarkMode = useEditorStore(s => s.setDarkMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false)
useEffect(() => {
if (isInitialized.current) return
isInitialized.current = true
settingsRepository.load()
.then((settings) => {
// 主题
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
setDarkMode(darkMode)
document.documentElement.classList.toggle('dark', darkMode)
// 视图模式
setViewMode(settings.viewMode ?? 'editor')
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB
const sidebarStore = useSidebarStore.getState()
if (!sidebarStore._loaded) {
useSidebarStore.setState({
isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth,
_loaded: true
})
}
})
.catch((error: unknown) => {
logError('加载设置失败', error)
})
}, [setDarkMode, setViewMode])
return { isInitialized }
}
+33
View File
@@ -0,0 +1,33 @@
import { useState, useRef, useEffect } from 'react'
/**
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
*/
export function useSidebarResize() {
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent): void => {
if (sidebarRef.current) {
const newWidth = Math.max(180, Math.min(500, e.clientX))
sidebarRef.current.style.width = newWidth + 'px'
}
}
const handleMouseUp = (): void => setIsResizing(false)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing])
const startResize = (): void => setIsResizing(true)
return { sidebarRef, isResizing, startResize }
}
+8 -16
View File
@@ -1,29 +1,21 @@
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
/**
* AR-04: 主题 hook
* 不再独立加载设置(由 useSettingsInit 统一加载)
* 仅负责主题切换时的同步和保存
*/
export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode)
const setDarkMode = useEditorStore(s => s.setDarkMode)
const isInitialized = useRef(false)
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
// B-05: 从 IndexedDB 加载主题设置
// darkMode 变化时同步 class 并保存
useEffect(() => {
settingsRepository.load().then(settings => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
isInitialized.current = true
}).catch(err => console.error('Failed to load theme settings:', err))
}, [setDarkMode])
// B-05: 首次加载完成后才保存主题设置
useEffect(() => {
if (!isInitialized.current) return
document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode })
}, [darkMode])
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
return { darkMode, toggleDarkMode }
}
+43
View File
@@ -0,0 +1,43 @@
import { useState, useCallback, useRef } from 'react'
import type { ToastItem, ToastType } from '../components/Toast/Toast'
/** 默认自动消失时间 (ms) */
const DEFAULT_DURATION = 3000
/** 最大同时显示数量 */
const MAX_TOASTS = 5
/**
* UX-05: 升级版 Toast 状态管理 hook
* 支持多条堆叠、类型区分、自动消失
*/
export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([])
const counterRef = useRef(0)
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
const id = `toast-${++counterRef.current}`
const newToast: ToastItem = { id, message: msg, type, duration }
setToasts(prev => {
const next = [...prev, newToast]
// 超过最大数量时移除最旧的
if (next.length > MAX_TOASTS) {
return next.slice(next.length - MAX_TOASTS)
}
return next
})
return id
}, [])
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const cleanup = useCallback(() => {
setToasts([])
}, [])
return { toasts, showToast, dismissToast, cleanup }
}
+23 -5
View File
@@ -1,6 +1,24 @@
import { useEffect } from 'react'
import { useEffect, useCallback, useRef } from 'react'
/**
* 未保存提醒 hook
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
*/
export function useUnsavedWarning(
hasUnsaved: () => boolean,
confirmFn?: (message: string) => Promise<boolean>
) {
const confirmFnRef = useRef(confirmFn)
confirmFnRef.current = confirmFn
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
if (confirmFnRef.current) {
return confirmFnRef.current(message)
}
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
return true
}, [])
export function useUnsavedWarning(hasUnsaved: () => boolean) {
useEffect(() => {
if (!window.electronAPI) {
const handler = (e: BeforeUnloadEvent) => {
@@ -15,12 +33,12 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
const api = window.electronAPI
const unsubscribe = api.onConfirmClose(() => {
const unsubscribe = api.onConfirmClose(async () => {
if (!hasUnsaved()) {
api.forceClose()
return
}
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?')
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) {
api.forceClose()
} else {
@@ -29,5 +47,5 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
})
return unsubscribe
}, [hasUnsaved])
}, [hasUnsaved, doConfirm])
}