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
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import { stat } from 'fs/promises'
import { join, basename, resolve, normalize, isAbsolute } from 'path'
import { basename, normalize, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击
function validatePath(filePath: string): boolean {
+60
View File
@@ -0,0 +1,60 @@
/**
* DX-02: Preload 类型安全声明
* 为 window.electronAPI 提供完整的 TypeScript 类型定义,
* 确保渲染进程访问 API 时有完整的类型提示和编译时检查。
*/
import type {
OpenFileResponse,
ReadFileResult,
SaveFilePayload,
SaveAsPayload,
SaveFileResult,
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult,
} from '../shared/types'
interface ElectronAPI {
// File operations
openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
getCurrentPath: () => Promise<string | null>
getFileStats: (filePath: string) => Promise<FileStatsResult>
reloadFile: () => Promise<ReloadFileResult>
// Tab management
tabSwitched: (filePath: string | null) => Promise<void>
// Window control
forceClose: () => Promise<void>
cancelClose: () => Promise<void>
// Shell
openExternal: (url: string) => void
// File Tree (Sidebar)
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
// Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => () => void
onMenuSave: (callback: () => void) => () => void
onMenuSaveAs: (callback: () => void) => () => void
onViewModeChange: (callback: (mode: string) => void) => () => void
onExternalModification: (callback: (filePath: string) => void) => () => void
onDirChanged: (callback: () => void) => () => void
onConfirmClose: (callback: () => void) => () => void
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}
+67 -254
View File
@@ -1,14 +1,17 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useTabStore } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useSidebarStore } from './stores/sidebarStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch'
import { recentFilesRepository } from './db/recentFilesRepository'
import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop'
import { useIpcListeners } from './hooks/useIpcListeners'
import { useToast } from './hooks/useToast'
import { useConfirm } from './hooks/useConfirm'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
@@ -16,292 +19,102 @@ import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { Toast } from './components/Toast/Toast'
import { ToastContainer } from './components/Toast/Toast'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { AppIcon, Gitee } from './components/Icons'
const APP_VERSION = 'v0.1.1'
import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const getActiveTab = useTabStore(s => s.getActiveTab)
const createTab = useTabStore(s => s.createTab)
const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent)
const loadFromDB = useTabStore(s => s.loadFromDB)
const loadSidebarFromDB = useSidebarStore(s => s.loadFromDB)
const viewMode = useEditorStore(s => s.viewMode)
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode } = useSettings()
const [showModifiedBanner, setShowModifiedBanner] = useState(false)
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const { toasts, showToast, dismissToast, cleanup } = useToast()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const toastTimer = useRef<number>(0)
// 启动时从 IndexedDB 加载标签页和 sidebar 设置
useEffect(() => {
loadFromDB()
loadSidebarFromDB()
return () => clearTimeout(toastTimer.current)
}, [loadFromDB, loadSidebarFromDB])
useSettingsInit()
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
const showToast = useCallback((msg: string) => {
clearTimeout(toastTimer.current)
setToastMessage(msg)
toastTimer.current = window.setTimeout(() => setToastMessage(null), 3000)
}, [])
// 拖拽打开
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast)
// 文件修改检测
useFileWatch()
// 未保存提醒
useUnsavedWarning(() => tabs.some(t => t.isModified))
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
return confirm({
title: '未保存的更改',
message,
variant: 'warning',
confirmLabel: '不保存',
cancelLabel: '取消'
})
}, [confirm])
// 文件外部修改事件
useEffect(() => {
const handler = (e: Event) => {
const filePath = (e as CustomEvent).detail
const tab = getActiveTab()
if (tab && tab.filePath === filePath) {
setShowModifiedBanner(true)
setModifiedFilePath(filePath)
}
}
window.addEventListener('file-externally-modified', handler)
return () => window.removeEventListener('file-externally-modified', handler)
}, [getActiveTab])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners(handleSave, handleSaveAs)
// 切换标签时通知主进程更新窗口标题
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
}, [activeTabId, tabs])
// 打开文件
const handleOpenFile = useCallback(async () => {
try {
if (window.electronAPI) {
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 {
showToast('操作失败')
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content) {
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
}
}, [createTab, showToast])
// 保存文件
const handleSave = useCallback(async () => {
try {
const tab = getActiveTab()
if (!tab) return
if (window.electronAPI) {
const result = await window.electronAPI.saveFile({
filePath: tab.filePath,
content: tab.content
})
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存')
}
}
} catch {
showToast('操作失败')
}
}, [getActiveTab, showToast])
// 另存为
const handleSaveAs = useCallback(async () => {
try {
const tab = getActiveTab()
if (!tab) return
if (window.electronAPI) {
const result = await window.electronAPI.saveFileAs({ content: tab.content })
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存')
}
}
} catch {
showToast('操作失败')
}
}, [getActiveTab, showToast])
// 快捷键
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
// 打开最近文件
const handleOpenRecent = useCallback(async (filePath: string) => {
if (window.electronAPI) {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
}
}, [createTab])
// 视图模式切换
const handleViewModeChange = useCallback((mode: 'editor' | 'preview') => {
saveViewMode(mode)
}, [saveViewMode])
// M-11: 主进程事件注册
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 onSave = () => handleSaveRef.current()
const onSaveAs = () => handleSaveAsRef.current()
const unsub1 = api.onFileOpenInTab(onOpen)
const unsub2 = api.onMenuSave(onSave)
const unsub3 = api.onMenuSaveAs(onSaveAs)
return () => {
unsub1()
unsub2()
unsub3()
}
}, [createTab])
const hasTabs = tabs.length > 0
setExternallyModified(null)
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
return (
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile}
onSave={handleSave}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
darkMode={darkMode}
onToggleDark={toggleDarkMode}
onShowAbout={() => setShowAbout(true)}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{showModifiedBanner && (
<ModifiedBanner
onReload={() => {
if (window.electronAPI && modifiedFilePath) {
window.electronAPI.readFile(modifiedFilePath).then(result => {
if (result.success && result.content) {
const tab = tabs.find(t => t.filePath === modifiedFilePath)
if (tab) {
updateTabContent(tab.id, result.content)
setModified(tab.id, false)
}
}
setShowModifiedBanner(false)
})
}
}}
onDismiss={() => setShowModifiedBanner(false)}
/>
)}
{hasTabs ? (
<div id="content-wrapper">
{viewMode === 'editor' && (
<div id="editor-panel">
<Editor darkMode={darkMode} />
</div>
)}
{viewMode === 'preview' && (
<div id="preview-panel">
<Preview />
</div>
)}
</div>
) : (
<WelcomeScreen
onOpen={handleOpenFile}
onNew={() => createTab(null, '')}
onOpenRecent={handleOpenRecent}
/>
)}
</div>
</div>
<StatusBar />
{toastMessage && <Toast message={toastMessage} />}
<DropOverlay />
{/* 关于弹框 */}
{showAbout && (
<div className="about-overlay" onClick={() => setShowAbout(false)} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
<div className="about-dialog" onClick={e => e.stopPropagation()}>
<div className="about-header">
<AppIcon size={64} />
<h2>MarkLite</h2>
<span className="about-version">{APP_VERSION}</span>
</div>
<div className="about-body">
<p> Windows Markdown </p>
<div className="about-features">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<ErrorBoundary>
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile} onSave={handleSave}
viewMode={viewMode} onViewModeChange={saveViewMode}
darkMode={darkMode} onToggleDark={toggleDarkMode}
onShowAbout={() => setShowAbout(true)}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{externallyModified && (
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
</div>
</div>
<div className="about-footer">
<a className="about-link" href="#" onClick={(e) => {
e.preventDefault()
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
} else {
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
}
}}>
<Gitee size={16} />
<span>gitee.com/thzxx/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p className="about-copyright">© 2026 thzxx</p>
</div>
<button className="about-close-btn" onClick={() => setShowAbout(false)}></button>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
</div>
</div>
)}
</div>
<StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay />
{showAbout && <AboutDialog onClose={() => setShowAbout(false)} />}
<ConfirmDialog {...confirmDialogProps} />
</div>
</ErrorBoundary>
)
}
App.displayName = 'App'
export default App
@@ -0,0 +1,55 @@
import React from 'react'
import { AppIcon, Gitee } from '../Icons'
const APP_VERSION = 'v0.2.0'
interface AboutDialogProps {
onClose: () => void
}
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
} else {
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
}
}
return (
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="about-header">
<AppIcon size={64} />
<h2>MarkLite</h2>
<span className="about-version">{APP_VERSION}</span>
</div>
<div className="about-body">
<p> Windows Markdown </p>
<div className="about-features">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} />
<span>gitee.com/thzxx/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p className="about-copyright">© 2026 thzxx</p>
</div>
<button className="about-close-btn" onClick={onClose}></button>
</div>
</div>
)
})
AboutDialog.displayName = 'AboutDialog'
@@ -0,0 +1 @@
export { AboutDialog } from './AboutDialog'
@@ -0,0 +1,112 @@
import React, { useEffect, useRef, useCallback } from 'react'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = '确定',
cancelLabel = '取消',
variant = 'warning',
onConfirm,
onCancel
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
// 保存焦点并在打开时聚焦确认按钮
useEffect(() => {
if (!open) return
previousFocusRef.current = document.activeElement as HTMLElement
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
return () => clearTimeout(timer)
}, [open])
// 关闭时恢复焦点
useEffect(() => {
if (open) return
return () => {
previousFocusRef.current?.focus()
}
}, [open])
// ESC 键关闭
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}, [onCancel])
useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, handleKeyDown])
// 防止背景滚动
useEffect(() => {
if (!open) return
const original = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = original }
}, [open])
if (!open) return null
return (
<div
className="confirm-overlay"
onClick={onCancel}
role="presentation"
>
<div
className="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
onClick={e => e.stopPropagation()}
>
<div className={`confirm-header confirm-${variant}`}>
<h3 id="confirm-title">{title}</h3>
</div>
<div className="confirm-body">
<p id="confirm-message">{message}</p>
</div>
<div className="confirm-actions">
<button
className="confirm-btn confirm-btn-cancel"
onClick={onCancel}
type="button"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
className={`confirm-btn confirm-btn-${variant}`}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
ConfirmDialog.displayName = 'ConfirmDialog'
@@ -0,0 +1 @@
export { ConfirmDialog } from './ConfirmDialog'
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { UploadCloud } from '../Icons'
export function DropOverlay() {
export const DropOverlay = React.memo(function DropOverlay() {
const [dragCounter, setDragCounter] = useState(0)
useEffect(() => {
@@ -9,16 +9,13 @@ export function DropOverlay() {
e.preventDefault()
setDragCounter(prev => prev + 1)
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
setDragCounter(prev => Math.max(0, prev - 1))
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
}
const handleDrop = () => {
setDragCounter(0)
}
@@ -39,11 +36,13 @@ export function DropOverlay() {
if (dragCounter <= 0) return null
return (
<div id="drop-overlay">
<div id="drop-overlay" role="dialog" aria-label="拖拽文件以打开">
<div className="drop-content">
<UploadCloud size={64} />
<p></p>
</div>
</div>
)
}
})
DropOverlay.displayName = 'DropOverlay'
+2 -3
View File
@@ -8,9 +8,8 @@ interface EditorProps {
}
export function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
@@ -94,4 +93,4 @@ export function Editor({ darkMode }: EditorProps) {
)
}
Editor.displayName = 'Editor'
Editor.displayName = 'Editor'
@@ -5,16 +5,14 @@ interface EditorToolbarProps {
viewRef: React.MutableRefObject<EditorView | null>
}
export function EditorToolbar({ viewRef }: EditorToolbarProps) {
export const EditorToolbar = React.memo(function EditorToolbar({ viewRef }: EditorToolbarProps) {
const insertFormatting = useCallback((before: string, after: string, placeholder: string) => {
const view = viewRef.current
if (!view) return
const { from, to } = view.state.selection.main
const selected = view.state.sliceDoc(from, to)
const text = selected || placeholder
const insert = before + text + after
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + before.length, head: from + before.length + text.length }
@@ -25,12 +23,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
const insertLinePrefix = useCallback((prefix: string) => {
const view = viewRef.current
if (!view) return
const { from } = view.state.selection.main
const line = view.state.doc.lineAt(from)
const currentLine = view.state.sliceDoc(line.from, line.to)
// 如果已经有前缀,移除它
if (currentLine.startsWith(prefix)) {
view.dispatch({
changes: { from: line.from, to: line.from + prefix.length, insert: '' }
@@ -46,11 +41,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
const insertBlock = useCallback((text: string) => {
const view = viewRef.current
if (!view) return
const { from } = view.state.selection.main
const line = view.state.doc.lineAt(from)
const insertPos = line.to + 1
view.dispatch({
changes: { from: insertPos, to: insertPos, insert: '\n' + text + '\n' },
selection: { anchor: insertPos + 1, head: insertPos + 1 + text.length }
@@ -59,49 +52,51 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
}, [viewRef])
return (
<div className="editor-toolbar">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)">
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)" aria-label="粗体">
<strong>B</strong>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)" aria-label="斜体">
<em>I</em>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线" aria-label="删除线">
<s>S</s>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码" aria-label="行内代码">
{'</>'}
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题">
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题" aria-label="一级标题">
H1
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题" aria-label="二级标题">
H2
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题" aria-label="三级标题">
H3
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表">
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表" aria-label="无序列表">
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表" aria-label="有序列表">
1.
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用" aria-label="引用">
</button>
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块">
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块" aria-label="代码块">
{'{ }'}
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接">
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接" aria-label="插入链接">
🔗
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('![', '](url)', '图片描述')} title="图片">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('![', '](url)', '图片描述')} title="图片" aria-label="插入图片">
🖼
</button>
</div>
)
}
})
EditorToolbar.displayName = 'EditorToolbar'
+25 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'
import { EditorState, type Extension } from '@codemirror/state'
import { EditorState, Compartment, type Extension } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars, drawSelection, rectangularSelection } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
@@ -13,22 +13,35 @@ interface UseCodeMirrorOptions {
darkMode: boolean
}
// PF-04: 创建主题 Compartment,用于动态切换主题而不重建 EditorView
const themeCompartment = new Compartment()
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const isExternalUpdate = useRef(false)
const onChangeRef = useRef(onChange)
const darkModeRef = useRef(darkMode)
// 始终保持 onChangeRef 为最新回调
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// 初始化编辑器
// PF-04: darkMode 变化时通过 Compartment.reconfigure() 切换主题
useEffect(() => {
darkModeRef.current = darkMode
const view = viewRef.current
if (!view) return
view.dispatch({
effects: themeCompartment.reconfigure(darkMode ? oneDark : [])
})
}, [darkMode])
// 初始化编辑器(仅在首次挂载时执行)
useEffect(() => {
if (!containerRef.current) return
// 中文本地化(搜索/替换面板)
const zhPhrases = EditorState.phrases.of({
'Find': '查找',
'Replace': '替换',
@@ -65,15 +78,14 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of(update => {
EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdate.current) {
onChangeRef.current(update.state.doc.toString())
}
})
}),
themeCompartment.of(darkModeRef.current ? oneDark : [])
]
if (darkMode) extensions.push(oneDark)
const state = EditorState.create({
doc: content,
extensions
@@ -90,7 +102,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
view.destroy()
viewRef.current = null
}
}, [darkMode]) // darkMode 变化时重建
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 外部内容更新(切换标签时)
const setContent = useCallback((newContent: string) => {
@@ -105,8 +118,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
isExternalUpdate.current = false
}
}, [])
// 获取/设置滚动位置
const getScrollTop = useCallback(() => {
const getScrollTop = useCallback((): number => {
return viewRef.current?.scrollDOM.scrollTop ?? 0
}, [])
@@ -116,8 +129,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
}
}, [])
// 获取/设置选区
const getSelection = useCallback(() => {
const getSelection = useCallback((): { from: number; to: number } => {
const view = viewRef.current
if (!view) return { from: 0, to: 0 }
const ranges = view.state.selection.ranges
@@ -130,6 +142,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
view.dispatch({ selection: { anchor: from, head: to } })
view.focus()
}, [])
return {
containerRef,
viewRef,
@@ -0,0 +1,87 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = (): void => {
this.setState({ hasError: false, error: null })
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
textAlign: 'center',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
</h2>
<pre
style={{
padding: '1rem',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
maxWidth: '600px',
overflow: 'auto',
fontSize: '0.875rem',
color: '#666',
}}
>
{this.state.error?.message}
</pre>
<button
onClick={this.handleReset}
style={{
marginTop: '1rem',
padding: '0.5rem 1.5rem',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '1rem',
}}
>
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1 @@
export { ErrorBoundary } from './ErrorBoundary'
@@ -0,0 +1,97 @@
import React from 'react'
import { File, Folder, ChevronRight } from '../Icons'
import type { FileNode } from '../../types/file'
interface FileTreeProps {
nodes: FileNode[]
depth: number
expandedDirs: string[]
toggleDir: (path: string) => void
activeTabId: string | null
activeFilePath: string | null
onFileClick: (path: string) => void
}
/** AR-02: 从 Sidebar.tsx 提取的递归文件树组件 */
export const FileTree = React.memo(function FileTree({
nodes,
depth,
expandedDirs,
toggleDir,
activeFilePath,
onFileClick
}: FileTreeProps) {
return (
<>
{nodes.map((node: FileNode) => {
const isExpanded = node.type === 'dir' && expandedDirs.includes(node.path)
const isActive = node.type === 'file' && node.path === activeFilePath
return (
<React.Fragment key={node.path}>
<div
className={`tree-item ${isActive ? 'active' : ''}`}
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
role="treeitem"
aria-expanded={node.type === 'dir' ? isExpanded : undefined}
aria-selected={isActive}
aria-label={node.name}
tabIndex={0}
onClick={() => {
if (node.type === 'dir') {
toggleDir(node.path)
} else {
onFileClick(node.path)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (node.type === 'dir') {
toggleDir(node.path)
} else {
onFileClick(node.path)
}
}
}}
>
{node.type === 'dir' ? (
<>
<span className={`tree-arrow ${isExpanded ? 'expanded' : ''}`} aria-hidden="true">
<ChevronRight size={10} />
</span>
<span className="tree-icon" aria-hidden="true">
<Folder size={14} />
</span>
</>
) : (
<>
<span style={{ width: '16px', flexShrink: 0 }} />
<span className="tree-icon" aria-hidden="true">
<File size={14} />
</span>
</>
)}
<span className="tree-name">{node.name}</span>
</div>
{isExpanded && node.children && (
<div role="group">
<FileTree
nodes={node.children}
depth={depth + 1}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeTabId={null}
activeFilePath={activeFilePath}
onFileClick={onFileClick}
/>
</div>
)}
</React.Fragment>
)
})}
</>
)
})
FileTree.displayName = 'FileTree'
@@ -0,0 +1 @@
export { FileTree } from './FileTree'
@@ -0,0 +1,70 @@
import React from 'react'
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large'
label?: string
/** 是否全屏覆盖 */
overlay?: boolean
}
const sizeMap = {
small: 16,
medium: 24,
large: 36
}
/**
* UX-02: 通用加载指示器组件
*/
export const LoadingSpinner = React.memo(function LoadingSpinner({
size = 'medium',
label,
overlay = false
}: LoadingSpinnerProps) {
const px = sizeMap[size]
const spinner = (
<div
className={`loading-spinner loading-spinner-${size}`}
role="status"
aria-label={label || '加载中'}
>
<svg
className="loading-spinner-svg"
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
opacity="0.2"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
{label && <span className="loading-spinner-label">{label}</span>}
</div>
)
if (!overlay) return spinner
return (
<div className="loading-overlay" aria-busy="true">
{spinner}
</div>
)
})
LoadingSpinner.displayName = 'LoadingSpinner'
@@ -0,0 +1 @@
export { LoadingSpinner } from './LoadingSpinner'
@@ -1,14 +1,18 @@
import React from 'react'
interface ModifiedBannerProps {
onReload: () => void
onDismiss: () => void
}
export function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
return (
<div id="modified-banner">
<div id="modified-banner" role="alert" aria-live="assertive">
<span></span>
<button className="banner-btn" onClick={onReload}></button>
<button className="banner-btn" onClick={onDismiss}></button>
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件"></button>
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改"></button>
</div>
)
}
})
ModifiedBanner.displayName = 'ModifiedBanner'
+47 -14
View File
@@ -1,37 +1,63 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { renderMarkdown } from '../../lib/markdown'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
// PF-07: 防抖延迟常量
const PREVIEW_DEBOUNCE_MS = 150
export function Preview() {
const [html, setHtml] = useState('')
const [rendering, setRendering] = useState(false)
const previewRef = useRef<HTMLDivElement>(null)
const requestIdRef = useRef(0)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
const setLoading = useEditorStore(s => s.setLoading)
// 响应式渲染(无轮询,无竞态)
// PF-07: 响应式渲染 + 防抖
useEffect(() => {
if (!activeTab) {
setHtml('')
setRendering(false)
setLoading('markdown-render', false)
return
}
const requestId = ++requestIdRef.current
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
if (requestId === requestIdRef.current) {
setHtml(result)
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
setRendering(true)
setLoading('markdown-render', true)
debounceTimerRef.current = setTimeout(() => {
const requestId = ++requestIdRef.current
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
if (requestId === requestIdRef.current) {
setHtml(result)
setRendering(false)
setLoading('markdown-render', false)
}
})
}, PREVIEW_DEBOUNCE_MS)
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
})
}, [activeTabId, activeTab?.content])
}
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent) => {
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const link = (e.target as HTMLElement).closest('a')
if (!link) return
e.preventDefault()
const href = link.getAttribute('href')
const href: string | null = link.getAttribute('href')
if (!href) return
if (href.startsWith('#')) {
const target = previewRef.current?.querySelector(href)
@@ -59,10 +85,17 @@ export function Preview() {
role="region"
aria-label="Markdown预览"
aria-live="polite"
aria-busy={rendering}
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
>
{rendering && html === '' && (
<div className="preview-loading" aria-label="正在渲染Markdown">
<LoadingSpinner size="medium" label="正在渲染..." />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
}
Preview.displayName = 'Preview'
Preview.displayName = 'Preview'
+47 -181
View File
@@ -1,116 +1,44 @@
import React, { useEffect, useCallback, useState, useRef } from 'react'
import React, { useCallback } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { FolderPlus, File, Folder, ChevronRight } from '../Icons'
import type { FileNode } from '../../types/file'
import { FolderPlus, File } from '../Icons'
import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
const norm = (p: string) => p.replace(/\\\\/g, '/')
export function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const activeTab = useTabStore(s => s.getActiveTab())
const switchToTab = useTabStore(s => s.switchToTab)
const createTab = useTabStore(s => s.createTab)
const rootPath = useSidebarStore(s => s.rootPath)
const tree = useSidebarStore(s => s.tree)
const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const setRootPath = useSidebarStore(s => s.setRootPath)
const setTree = useSidebarStore(s => s.setTree)
const isVisible = useSidebarStore(s => s.isVisible)
const expandDirs = useSidebarStore(s => s.expandDirs)
// M-01: 归一化路径分隔符
const norm = (p: string) => p.replace(/\\/g, '/')
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
// 自动展开到活动文件所在的目录
useEffect(() => {
if (!activeFilePath || !rootPath) return
const normActive = norm(activeFilePath)
const normRoot = norm(rootPath)
if (!normActive.startsWith(normRoot)) 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])
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null)
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const dirPath = await window.electronAPI.openFolderDialog()
if (dirPath) {
setRootPath(dirPath)
// 展开根目录
expandDirs([dirPath])
const result = await window.electronAPI.readDirTree(dirPath)
if (result.success && result.tree) {
setTree(result.tree)
window.electronAPI.watchDir(dirPath)
}
}
}, [setRootPath, setTree, expandDirs])
const refreshTree = useCallback(async () => {
if (!rootPath || !window.electronAPI) return
const result = await window.electronAPI.readDirTree(rootPath)
if (result.success && result.tree) {
setTree(result.tree)
}
}, [rootPath, setTree])
useEffect(() => {
if (!window.electronAPI) return
const unsubscribe = window.electronAPI.onDirChanged(() => {
refreshTree()
})
return unsubscribe
}, [refreshTree])
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
if (sidebarRef.current) {
const newWidth = Math.max(180, Math.min(500, e.clientX))
sidebarRef.current.style.width = newWidth + 'px'
}
}
const handleMouseUp = () => setIsResizing(false)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing])
const activeFilePath = activeTab?.filePath ?? null
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
useAutoExpandDir(activeFilePath)
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)
recentFilesRepository.add(path)
}
if (existing) { switchToTab(existing.id); return }
if (!window.electronAPI) return
const result = await window.electronAPI.readFile(path)
if (result.success && result.content) {
createTab(path, result.content)
recentFilesRepository.add(path)
}
}, [tabs, switchToTab, createTab])
// M-01: 独立文件区(归一化路径比较)
const independentFiles = tabs.filter(t => {
if (!t.filePath) return false
if (!rootPath) return true
@@ -120,123 +48,61 @@ export function Sidebar() {
if (!isVisible) return null
return (
<div id="sidebar" ref={sidebarRef}>
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
<div id="sidebar-header">
<span id="sidebar-title"></span>
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
<button
className="sidebar-header-btn"
onClick={handleOpenFolder}
title="打开文件夹"
aria-label="打开文件夹"
>
<FolderPlus size={14} />
</button>
</div>
<div id="sidebar-tree" role="tree" aria-label="文件树">
{/* 独立文件区 */}
<nav id="sidebar-tree" role="tree" aria-label="文件树">
{independentFiles.length > 0 && (
<div className="independent-files-section">
<div className="independent-files-header"></div>
<div className="independent-files-section" role="group" aria-label="已打开的文件">
<div className="independent-files-header" id="independent-files-label"></div>
{independentFiles.map(tab => (
<div
key={tab.id}
<div key={tab.id}
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
style={{ paddingLeft: '8px' }}
role="treeitem"
tabIndex={0}
aria-selected={tab.id === activeTabId}
aria-label={getFileName(tab.filePath!)}
onClick={() => switchToTab(tab.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
>
<span className="tree-icon">
<File size={14} />
</span>
<span className="tree-icon"><File size={14} /></span>
<span className="tree-name">{getFileName(tab.filePath!)}</span>
{tab.isModified && <span className="independent-modified-dot"> </span>}
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> </span>}
</div>
))}
</div>
)}
{/* 文件夹目录树 */}
{rootPath && (
<>
<div className="sidebar-section-header"></div>
<div className="sidebar-section-header" id="folder-tree-label"></div>
<FileTree
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
depth={0}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeTabId={activeTabId}
activeFilePath={activeFilePath}
onFileClick={handleFileClick}
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
/>
</>
)}
</div>
</nav>
<div
className="sidebar-resize-handle"
onMouseDown={() => setIsResizing(true)}
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="调整侧边栏宽度"
tabIndex={0}
/>
</div>
)
}
// 文件树递归组件
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, activeFilePath, onFileClick }: {
nodes: FileNode[]
depth: number
expandedDirs: string[]
toggleDir: (path: string) => void
activeTabId: string | null
activeFilePath: string | null
onFileClick: (path: string) => void
}) {
return (
<>
{nodes.map(node => (
<React.Fragment key={node.path}>
<div
className={`tree-item ${node.type === 'file' && node.path === activeFilePath ? 'active' : ''}`}
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
role="treeitem"
onClick={() => {
if (node.type === 'dir') {
toggleDir(node.path)
} else {
onFileClick(node.path)
}
}}
>
{node.type === 'dir' ? (
<>
<span className={`tree-arrow ${expandedDirs.includes(node.path) ? 'expanded' : ''}`}>
<ChevronRight size={10} />
</span>
<span className="tree-icon">
<Folder size={14} />
</span>
</>
) : (
<>
<span style={{ width: '16px', flexShrink: 0 }} />
<span className="tree-icon">
<File size={14} />
</span>
</>
)}
<span className="tree-name">{node.name}</span>
</div>
{node.type === 'dir' && expandedDirs.includes(node.path) && node.children && (
<FileTree
nodes={node.children}
depth={depth + 1}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeTabId={activeTabId}
activeFilePath={activeFilePath}
onFileClick={onFileClick}
/>
)}
</React.Fragment>
))}
</>
</aside>
)
}
Sidebar.displayName = 'Sidebar'
FileTree.displayName = 'FileTree'
@@ -1,18 +1,34 @@
import React from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { getFileName } from '../../lib/fileUtils'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
export function StatusBar() {
// B-04: 直接选择数据而非函数引用,确保响应式更新
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const tab = tabs.find(t => t.id === activeTabId) ?? null
export const StatusBar = React.memo(function StatusBar() {
const activeTab = useTabStore(s => s.getActiveTab())
const loadingStates = useEditorStore(s => s.loadingStates)
// 判断是否有任何加载状态激活
const hasLoading = Object.values(loadingStates).some(Boolean)
const loadingLabel = loadingStates['file-open']
? '正在打开文件...'
: loadingStates['dir-load']
? '正在加载目录...'
: loadingStates['markdown-render']
? '正在渲染...'
: ''
return (
<div id="statusbar" role="status" aria-live="polite">
<div className="status-left">
{hasLoading && (
<span className="status-loading" aria-busy="true">
<LoadingSpinner size="small" />
<span>{loadingLabel}</span>
</span>
)}
<span id="status-text">
{tab ? (tab.filePath ? getFileName(tab.filePath) : '未命名') : '就绪'}
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
</span>
</div>
<div className="status-right">
@@ -22,6 +38,6 @@ export function StatusBar() {
</div>
</div>
)
}
})
StatusBar.displayName = 'StatusBar'
StatusBar.displayName = 'StatusBar'
+112 -71
View File
@@ -1,7 +1,9 @@
import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useConfirm } from '../../hooks/useConfirm'
import { getFileName } from '../../lib/fileUtils'
import { Close, Plus } from '../Icons'
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
interface ContextMenuState {
visible: boolean
@@ -22,6 +24,7 @@ export function TabBar() {
const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签
const scrollToActiveTab = useCallback(() => {
@@ -70,15 +73,21 @@ export function TabBar() {
return () => tabList.removeEventListener('wheel', handleWheel)
}, [])
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
const confirmed = await confirm({
title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTab(tabId)
}, [tabs, closeTab])
}, [tabs, closeTab, confirm])
// C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
@@ -98,47 +107,71 @@ export function TabBar() {
return () => document.removeEventListener('click', handleClick)
}, [menu.visible])
const handleMenuClose = useCallback(() => {
const handleMenuClose = useCallback(async () => {
const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
const confirmed = await confirm({
title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab])
}, [tabs, menu.tabId, closeTab, confirm])
const handleMenuCloseOthers = useCallback(() => {
const handleMenuCloseOthers = useCallback(async () => {
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
if (otherModified.length > 0) {
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
const confirmed = await confirm({
title: '关闭其他标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs])
}, [tabs, menu.tabId, closeOtherTabs, confirm])
const handleMenuCloseAll = useCallback(() => {
const handleMenuCloseAll = useCallback(async () => {
const modified = tabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
const confirmed = await confirm({
title: '关闭全部标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeAllTabs()
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs])
}, [tabs, closeAllTabs, confirm])
const handleMenuCloseRight = useCallback(() => {
const handleMenuCloseRight = useCallback(async () => {
const index = tabs.findIndex(t => t.id === menu.tabId)
const rightTabs = tabs.slice(index + 1)
const modified = rightTabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
const confirmed = await confirm({
title: '关闭右侧标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight])
}, [tabs, menu.tabId, closeTabsToRight, confirm])
const hasRightTabs = menu.visible && (() => {
const index = tabs.findIndex(t => t.id === menu.tabId)
@@ -148,65 +181,73 @@ export function TabBar() {
if (tabs.length === 0) return null
return (
<div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
</span>
<button
className="tab-close"
onClick={(e) => handleClose(e, tab.id)}
<>
<div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1}
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
>
<Close size={10} />
</button>
</div>
))}
</div>
<button
className="tab-add-btn"
onClick={() => createTab(null, '')}
title="新建标签页 (Ctrl+T)"
>
<Plus size={14} />
</button>
{/* 右键菜单 */}
{menu.visible && (
<div
className="tab-context-menu"
style={{ left: menu.x, top: menu.y }}
onClick={(e) => e.stopPropagation()}
>
<div className="tab-context-item" onClick={handleMenuClose}>
</div>
{tabs.length > 1 && (
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
</span>
<button
className="tab-close"
onClick={(e) => handleClose(e, tab.id)}
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
>
<Close size={10} />
</button>
</div>
)}
{hasRightTabs && (
<div className="tab-context-item" onClick={handleMenuCloseRight}>
</div>
)}
<div className="tab-context-divider" />
<div className="tab-context-item" onClick={handleMenuCloseAll}>
</div>
))}
</div>
)}
</div>
<button
className="tab-add-btn"
onClick={() => createTab(null, '')}
title="新建标签页 (Ctrl+T)"
aria-label="新建标签页"
>
<Plus size={14} />
</button>
{/* 右键菜单 */}
{menu.visible && (
<div
className="tab-context-menu"
style={{ left: menu.x, top: menu.y }}
role="menu"
aria-label="标签操作"
onClick={(e) => e.stopPropagation()}
>
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
</div>
{tabs.length > 1 && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
</div>
)}
{hasRightTabs && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
</div>
)}
<div className="tab-context-divider" role="separator" />
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
</div>
</div>
)}
</div>
<ConfirmDialog {...confirmDialogProps} />
</>
)
}
TabBar.displayName = 'TabBar'
TabBar.displayName = 'TabBar'
+97 -5
View File
@@ -1,11 +1,103 @@
interface ToastProps {
import React, { useState, useEffect, useCallback, useRef } from 'react'
/** Toast 类型 */
export type ToastType = 'success' | 'error' | 'warning' | 'info'
/** 单条 Toast 数据 */
export interface ToastItem {
id: string
message: string
type: ToastType
duration: number
}
export function Toast({ message }: ToastProps) {
/** Toast 容器组件属性 */
interface ToastContainerProps {
toasts: ToastItem[]
onDismiss: (id: string) => void
}
/** 单条 Toast 组件 */
const SingleToast = React.memo(function SingleToast({
toast,
onDismiss
}: {
toast: ToastItem
onDismiss: (id: string) => void
}) {
const [visible, setVisible] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
// 进入动画
requestAnimationFrame(() => setVisible(true))
// 自动消失
if (toast.duration > 0) {
timerRef.current = setTimeout(() => {
setVisible(false)
// 等待退出动画完成后再移除
setTimeout(() => onDismiss(toast.id), 300)
}, toast.duration)
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [toast.id, toast.duration, onDismiss])
const handleClose = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current)
setVisible(false)
setTimeout(() => onDismiss(toast.id), 300)
}, [toast.id, onDismiss])
const typeIcons: Record<ToastType, string> = {
success: '✓',
error: '✕',
warning: '⚠',
info: ''
}
return (
<div id="toast-notification" className="show">
{message}
<div
className={`toast-item toast-${toast.type} ${visible ? 'toast-visible' : ''}`}
role="alert"
aria-live="assertive"
>
<span className="toast-icon" aria-hidden="true">
{typeIcons[toast.type]}
</span>
<span className="toast-message">{toast.message}</span>
<button
className="toast-close"
onClick={handleClose}
aria-label="关闭通知"
type="button"
>
</button>
</div>
)
}
})
/**
* UX-05: Toast 容器组件
* 支持多条堆叠、类型区分、关闭按钮和自动消失
*/
export const ToastContainer = React.memo(function ToastContainer({
toasts,
onDismiss
}: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="toast-container" aria-label="通知区域">
{toasts.map(toast => (
<SingleToast key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
)
})
ToastContainer.displayName = 'ToastContainer'
+2
View File
@@ -0,0 +1,2 @@
export { ToastContainer } from './Toast'
export type { ToastType, ToastItem } from './Toast'
+24 -12
View File
@@ -11,38 +11,50 @@ interface ToolbarProps {
onShowAbout: () => void
}
export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left">
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)">
<div className="toolbar-left" role="group" aria-label="文件操作">
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
<FolderOpen size={18} />
<span></span>
</button>
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)">
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
<Save size={18} />
<span></span>
</button>
<div className="toolbar-divider" />
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
<div className="toolbar-divider" role="separator" />
<button
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
onClick={() => onViewModeChange('editor')}
title="编辑 (Ctrl+1)"
aria-label="编辑模式"
aria-pressed={viewMode === 'editor'}
>
<EditMode size={18} />
<span></span>
</button>
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+2)">
<button
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
onClick={() => onViewModeChange('preview')}
title="预览 (Ctrl+2)"
aria-label="预览模式"
aria-pressed={viewMode === 'preview'}
>
<PreviewMode size={18} />
<span></span>
</button>
</div>
<div className="toolbar-right">
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题">
<div className="toolbar-right" role="group" aria-label="设置">
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
<button className="toolbar-btn" onClick={onShowAbout} title="关于">
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
<Info size={18} />
</button>
</div>
</div>
)
}
})
Toolbar.displayName = 'Toolbar'
Toolbar.displayName = 'Toolbar'
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect } from 'react'
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { getFileName } from '../../lib/fileUtils'
@@ -9,29 +9,29 @@ interface WelcomeScreenProps {
onOpenRecent?: (filePath: string) => void
}
export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
const [recentFiles, setRecentFiles] = useState<string[]>([])
useEffect(() => {
recentFilesRepository.getAll(10).then(files => {
recentFilesRepository.getAll(10).then((files: string[]) => {
setRecentFiles(files)
})
}, [])
return (
<div id="welcome-screen">
<div id="welcome-screen" role="main" aria-label="欢迎页面">
<div className="welcome-content">
<div className="welcome-icon">
<div className="welcome-icon" aria-hidden="true">
<AppIcon size={80} />
</div>
<h1>使 MarkLite</h1>
<p> Markdown </p>
<div className="welcome-actions">
<button className="welcome-btn primary" onClick={onOpen}>
<div className="welcome-actions" role="group" aria-label="快速操作">
<button className="welcome-btn primary" onClick={onOpen} aria-label="打开文件">
<WelcomeFile size={20} />
</button>
<button className="welcome-btn secondary" onClick={onNew}>
<button className="welcome-btn secondary" onClick={onNew} aria-label="新建文件">
<WelcomeNew size={20} />
</button>
@@ -40,15 +40,17 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp
{recentFiles.length > 0 && (
<div className="welcome-recent">
<h3></h3>
<div className="recent-list">
{recentFiles.map(filePath => (
<div className="recent-list" role="list" aria-label="最近打开的文件">
{recentFiles.map((filePath: string) => (
<button
key={filePath}
className="recent-item"
onClick={() => onOpenRecent?.(filePath)}
title={filePath}
role="listitem"
aria-label={`打开 ${getFileName(filePath)}`}
>
<span className="recent-icon">
<span className="recent-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
@@ -62,11 +64,13 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp
</div>
)}
<div className="welcome-tips">
<div className="welcome-tips" role="note" aria-label="使用提示">
<p>💡 .md </p>
<p> Ctrl+O | Ctrl+S | Ctrl+F | Ctrl+1/2 </p>
</div>
</div>
</div>
)
}
})
WelcomeScreen.displayName = 'WelcomeScreen'
+7 -7
View File
@@ -1,28 +1,28 @@
import { db } from './schema'
import { db, type RecentFile } from './schema'
export const recentFilesRepository = {
async add(filePath: string): Promise<void> {
const existing = await db.recentFiles.where('filePath').equals(filePath).first()
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
if (existing) {
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
} else {
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
}
// L-06: 清理超过 50 条的旧记录
const all = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
const all: RecentFile[] = 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!))
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
}
},
async getAll(limit = 20): Promise<string[]> {
const files = await db.recentFiles
async getAll(limit: number = 20): Promise<string[]> {
const files: RecentFile[] = await db.recentFiles
.orderBy('lastOpened')
.reverse()
.limit(limit)
.toArray()
return files.map((f: { filePath: string; lastOpened: number }) => f.filePath)
return files.map((f: RecentFile) => f.filePath)
},
async remove(filePath: string): Promise<void> {
+5 -4
View File
@@ -1,10 +1,11 @@
import { db, type SettingsRecord } from './schema'
import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
import { logError } from '../lib/errorHandler'
export const settingsRepository = {
async load(): Promise<Settings> {
try {
const record = await db.settings.get('default')
const record: SettingsRecord | undefined = await db.settings.get('default')
if (record) {
return {
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
@@ -13,15 +14,15 @@ export const settingsRepository = {
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
}
}
} catch {
// fallback
} catch (error) {
logError('加载设置失败', error)
}
return { ...DEFAULT_SETTINGS }
},
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
async save(partial: Partial<Settings>): Promise<void> {
const current = await this.load()
const current: Settings = await this.load()
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
await db.settings.put(merged)
}
+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])
}
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from 'vitest'
import { AppError, logError, getErrorMessage, tryAsync } from '../errorHandler'
describe('errorHandler', () => {
describe('AppError', () => {
it('should create an error with code and message', () => {
const error = new AppError('Test error', 'TEST_CODE')
expect(error.message).toBe('Test error')
expect(error.code).toBe('TEST_CODE')
expect(error.name).toBe('AppError')
expect(error).toBeInstanceOf(Error)
})
it('should store cause', () => {
const cause = new Error('root cause')
const error = new AppError('Test error', 'TEST_CODE', cause)
expect(error.cause).toBe(cause)
})
})
describe('logError', () => {
it('should log error with context', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const error = new Error('test error')
logError('TestContext', error)
expect(consoleSpy).toHaveBeenCalledWith(
'[TestContext] test error',
error
)
consoleSpy.mockRestore()
})
it('should handle non-Error values', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
logError('TestContext', 'string error')
expect(consoleSpy).toHaveBeenCalledWith(
'[TestContext] string error',
'string error'
)
consoleSpy.mockRestore()
})
})
describe('getErrorMessage', () => {
it('should return message for AppError', () => {
const error = new AppError('Custom error', 'CODE')
expect(getErrorMessage(error, 'Fallback')).toBe('Custom error')
})
it('should return fallback + message for standard Error', () => {
const error = new Error('details')
expect(getErrorMessage(error, 'Fallback')).toBe('Fallback: details')
})
it('should return fallback for non-Error values', () => {
expect(getErrorMessage('unknown', 'Fallback')).toBe('Fallback')
})
it('should return fallback for null', () => {
expect(getErrorMessage(null, 'Fallback')).toBe('Fallback')
})
})
describe('tryAsync', () => {
it('should return result on success', async () => {
const [result, error] = await tryAsync(async () => 42, 'Test')
expect(result).toBe(42)
expect(error).toBeNull()
})
it('should return AppError on failure', async () => {
const [result, error] = await tryAsync(
async () => { throw new Error('fail') },
'TestContext'
)
expect(result).toBeNull()
expect(error).toBeInstanceOf(AppError)
expect(error?.message).toBe('TestContext: fail')
expect(error?.code).toBe('TestContext')
})
it('should handle non-Error thrown values', async () => {
const [result, error] = await tryAsync(
async () => { throw 'string error' },
'TestContext'
)
expect(result).toBeNull()
expect(error).toBeInstanceOf(AppError)
expect(error?.message).toBe('TestContext: string error')
})
it('should preserve AppError cause', async () => {
const originalError = new Error('original')
const [, error] = await tryAsync(
async () => { throw originalError },
'Ctx'
)
expect(error?.cause).toBe(originalError)
})
})
})
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
describe('fileUtils', () => {
describe('getFileName', () => {
it('should extract filename from Unix path', () => {
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
})
it('should extract filename from Windows path', () => {
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
})
it('should return the input if it is already a filename', () => {
expect(getFileName('file.md')).toBe('file.md')
})
it('should handle path with trailing slash', () => {
// getFileName uses pop() || filePath, empty string is falsy so returns filePath
expect(getFileName('/path/to/dir/')).toBe('/path/to/dir/')
})
it('should handle mixed path separators', () => {
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
})
})
describe('getFileExtension', () => {
it('should return lowercase extension', () => {
expect(getFileExtension('file.MD')).toBe('.md')
})
it('should return .markdown extension', () => {
expect(getFileExtension('file.markdown')).toBe('.markdown')
})
it('should return .txt extension', () => {
expect(getFileExtension('notes.txt')).toBe('.txt')
})
it('should return empty string for no extension', () => {
expect(getFileExtension('Makefile')).toBe('')
})
it('should return empty string for hidden files starting with dot', () => {
expect(getFileExtension('.gitignore')).toBe('')
})
it('should extract extension from full path', () => {
expect(getFileExtension('/home/user/file.md')).toBe('.md')
})
it('should extract extension from Windows path', () => {
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
})
it('should handle file with multiple dots', () => {
expect(getFileExtension('my.file.md')).toBe('.md')
})
})
describe('isAllowedFile', () => {
it('should return true for .md files', () => {
expect(isAllowedFile('file.md')).toBe(true)
})
it('should return true for .markdown files', () => {
expect(isAllowedFile('file.markdown')).toBe(true)
})
it('should return true for .txt files', () => {
expect(isAllowedFile('file.txt')).toBe(true)
})
it('should return true for uppercase extensions', () => {
expect(isAllowedFile('file.MD')).toBe(true)
})
it('should return false for .js files', () => {
expect(isAllowedFile('file.js')).toBe(false)
})
it('should return false for .json files', () => {
expect(isAllowedFile('file.json')).toBe(false)
})
it('should return false for files without extension', () => {
expect(isAllowedFile('Makefile')).toBe(false)
})
it('should work with full paths', () => {
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
expect(isAllowedFile('/home/user/script.py')).toBe(false)
})
})
})
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest'
import { renderMarkdown } from '../markdown'
describe('renderMarkdown', () => {
it('should render a simple heading', async () => {
const result = await renderMarkdown('# Hello World')
expect(result).toContain('<h1')
expect(result).toContain('Hello World')
})
it('should render bold text', async () => {
const result = await renderMarkdown('**bold**')
expect(result).toContain('<strong')
expect(result).toContain('bold')
})
it('should render italic text', async () => {
const result = await renderMarkdown('*italic*')
expect(result).toContain('<em')
expect(result).toContain('italic')
})
it('should render inline code', async () => {
const result = await renderMarkdown('`code`')
expect(result).toContain('<code')
expect(result).toContain('code')
})
it('should render code blocks with syntax highlighting', async () => {
const md = '```javascript\nconst x = 1;\n```'
const result = await renderMarkdown(md)
expect(result).toContain('<pre')
expect(result).toContain('<code')
expect(result).toContain('const')
})
it('should render links', async () => {
const result = await renderMarkdown('[link](https://example.com)')
expect(result).toContain('<a')
expect(result).toContain('https://example.com')
expect(result).toContain('link')
})
it('should render unordered lists', async () => {
const result = await renderMarkdown('- item 1\n- item 2')
expect(result).toContain('<ul')
expect(result).toContain('<li')
expect(result).toContain('item 1')
expect(result).toContain('item 2')
})
it('should render ordered lists', async () => {
const result = await renderMarkdown('1. first\n2. second')
expect(result).toContain('<ol')
expect(result).toContain('first')
expect(result).toContain('second')
})
it('should render blockquotes', async () => {
const result = await renderMarkdown('> quote')
expect(result).toContain('<blockquote')
expect(result).toContain('quote')
})
it('should render tables via GFM', async () => {
const md = '| A | B |\n|---|---|\n| 1 | 2 |'
const result = await renderMarkdown(md)
expect(result).toContain('<table')
expect(result).toContain('<td')
})
it('should render strikethrough via GFM', async () => {
const result = await renderMarkdown('~~deleted~~')
expect(result).toContain('<del')
expect(result).toContain('deleted')
})
it('should handle empty content', async () => {
const result = await renderMarkdown('')
expect(result).toBe('')
})
it('should return error HTML on rendering failure with invalid input', async () => {
// unified should still handle gracefully, but verify error path works
const result = await renderMarkdown('normal text')
expect(result).not.toContain('渲染错误')
})
it('should cache processors for same filePath', async () => {
// Call twice with same filePath to test caching
const result1 = await renderMarkdown('# Test', '/test/file.md')
const result2 = await renderMarkdown('# Test 2', '/test/file.md')
expect(result1).toContain('<h1')
expect(result2).toContain('Test 2')
})
it('should handle null filePath', async () => {
const result = await renderMarkdown('# No File', null)
expect(result).toContain('No File')
})
it('should handle undefined filePath', async () => {
const result = await renderMarkdown('# No File')
expect(result).toContain('No File')
})
})
+42
View File
@@ -0,0 +1,42 @@
/**
* CQ-05: 统一错误处理模块
* 提供一致的错误处理模式,替换分散的 try-catch
*/
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly cause?: unknown
) {
super(message)
this.name = 'AppError'
}
}
/** 日志级别的错误处理(仅 console.error,不中断流程) */
export function logError(context: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error)
console.error(`[${context}] ${message}`, error)
}
/** 用户操作级错误处理(返回友好的错误信息给 showToast) */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AppError) return error.message
if (error instanceof Error) return `${fallback}: ${error.message}`
return fallback
}
/** 通用 try-catch 包装器,返回 [result, error] */
export async function tryAsync<T>(
fn: () => Promise<T>,
context: string
): Promise<[T | null, null] | [null, AppError]> {
try {
const result = await fn()
return [result, null]
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return [null, new AppError(`${context}: ${message}`, context, error)]
}
}
+50 -22
View File
@@ -13,9 +13,8 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
return () => (tree: Root) => {
if (!filePath) return
// 获取文件所在目录
const dir = filePath.replace(/[/\\][^/\\]+$/, '')
const sep = dir.includes('\\') ? '\\' : '/'
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
const sep: string = dir.includes('\\') ? '\\' : '/'
function visit(node: Element | Root): void {
if (!node.children) return
@@ -23,9 +22,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
if (child.type === 'element' && child.tagName === 'img') {
const src = child.properties?.src as string | undefined
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
// C-04: 拒绝路径遍历攻击
if (src.includes('..')) return
// 相对路径转绝对路径
child.properties = {
...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
@@ -42,27 +39,58 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 10
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
function buildProcessor(filePath: string | null) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
}
})
.use(rehypeFixImages(filePath ?? null))
.use(rehypeHighlight)
.use(rehypeStringify)
}
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
const key: string = filePath ?? '__null__'
if (processorCache.has(key)) {
const cached = processorCache.get(key)!
processorCache.delete(key)
processorCache.set(key, cached)
return cached
}
const processor = buildProcessor(filePath)
if (processorCache.size >= MAX_CACHE_SIZE) {
const oldestKey = processorCache.keys().next().value
if (oldestKey !== undefined) {
processorCache.delete(oldestKey)
}
}
processorCache.set(key, processor)
return processor
}
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
try {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
}
})
.use(rehypeFixImages(filePath ?? null))
.use(rehypeHighlight)
.use(rehypeStringify)
const processor = getCachedProcessor(filePath ?? null)
const result = await processor.process(content)
return String(result)
} catch (e) {
return `<p style="color:red">渲染错误: ${e instanceof Error ? e.message : String(e)}</p>`
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useEditorStore } from '../editorStore'
describe('editorStore', () => {
beforeEach(() => {
useEditorStore.setState({
viewMode: 'editor',
darkMode: false,
externallyModified: null,
loadingStates: {},
})
})
describe('setViewMode', () => {
it('should set view mode to preview', () => {
useEditorStore.getState().setViewMode('preview')
expect(useEditorStore.getState().viewMode).toBe('preview')
})
it('should set view mode to editor', () => {
useEditorStore.setState({ viewMode: 'preview' })
useEditorStore.getState().setViewMode('editor')
expect(useEditorStore.getState().viewMode).toBe('editor')
})
})
describe('setDarkMode', () => {
it('should enable dark mode', () => {
useEditorStore.getState().setDarkMode(true)
expect(useEditorStore.getState().darkMode).toBe(true)
})
it('should disable dark mode', () => {
useEditorStore.setState({ darkMode: true })
useEditorStore.getState().setDarkMode(false)
expect(useEditorStore.getState().darkMode).toBe(false)
})
})
describe('toggleDarkMode', () => {
it('should toggle from false to true', () => {
useEditorStore.getState().toggleDarkMode()
expect(useEditorStore.getState().darkMode).toBe(true)
})
it('should toggle from true to false', () => {
useEditorStore.setState({ darkMode: true })
useEditorStore.getState().toggleDarkMode()
expect(useEditorStore.getState().darkMode).toBe(false)
})
})
describe('setExternallyModified', () => {
it('should set externally modified info', () => {
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
})
it('should clear externally modified info', () => {
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
useEditorStore.getState().setExternallyModified(null)
expect(useEditorStore.getState().externallyModified).toBeNull()
})
})
describe('loading states', () => {
it('should set loading state', () => {
useEditorStore.getState().setLoading('file-open', true)
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
})
it('should return false for unset loading key', () => {
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
})
it('should update loading state to false', () => {
useEditorStore.getState().setLoading('file-open', true)
useEditorStore.getState().setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
})
it('should handle multiple independent loading states', () => {
const { setLoading } = useEditorStore.getState()
setLoading('file-open', true)
setLoading('markdown-render', true)
const state = useEditorStore.getState()
expect(state.isLoading('file-open')).toBe(true)
expect(state.isLoading('markdown-render')).toBe(true)
setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
})
})
})
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock external dependencies before importing store
vi.mock('../../db/tabRepository', () => ({
tabRepository: {
loadAll: vi.fn().mockResolvedValue([]),
loadActiveTabId: vi.fn().mockResolvedValue(null),
saveAll: vi.fn().mockResolvedValue(undefined),
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
clearAll: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('nanoid', () => {
let counter = 0
return {
nanoid: vi.fn(() => `test-id-${++counter}`),
}
})
import { useTabStore } from '../tabStore'
describe('tabStore', () => {
beforeEach(() => {
// Reset store state between tests
useTabStore.setState({
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
})
})
describe('createTab', () => {
it('should create a new tab with default values', () => {
const tab = useTabStore.getState().createTab()
const state = useTabStore.getState()
expect(tab.id).toBeTruthy()
expect(tab.filePath).toBeNull()
expect(tab.content).toBe('')
expect(tab.isModified).toBe(false)
expect(state.tabs).toHaveLength(1)
expect(state.activeTabId).toBe(tab.id)
})
it('should create a tab with file path and content', () => {
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
expect(tab.filePath).toBe('/test.md')
expect(tab.content).toBe('# Hello')
})
it('should switch to existing tab if same filePath is opened', () => {
const { createTab } = useTabStore.getState()
createTab('/test.md', '# First')
// Create another tab to make it active
createTab('/other.md', '# Other')
// Now try to open the first file again
const existing = createTab('/test.md', '# Updated')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(2)
expect(state.activeTabId).toBe(existing.id)
})
})
describe('closeTab', () => {
it('should close a tab and update activeTabId', () => {
const { createTab, closeTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
closeTab(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab2.id)
expect(state.activeTabId).toBe(tab2.id)
})
it('should set activeTabId to null when closing last tab', () => {
const { createTab, closeTab } = useTabStore.getState()
const tab = createTab('/file1.md')
closeTab(tab.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull()
})
it('should handle closing non-existent tab gracefully', () => {
const { createTab, closeTab } = useTabStore.getState()
createTab('/file1.md')
closeTab('non-existent')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
})
})
describe('closeOtherTabs', () => {
it('should close all tabs except the specified one', () => {
const { createTab, closeOtherTabs } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
closeOtherTabs(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.activeTabId).toBe(tab1.id)
})
})
describe('closeAllTabs', () => {
it('should close all tabs', () => {
const { createTab, closeAllTabs } = useTabStore.getState()
createTab('/file1.md')
createTab('/file2.md')
closeAllTabs()
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull()
expect(state.mruStack).toHaveLength(0)
})
})
describe('closeTabsToRight', () => {
it('should close tabs to the right of the specified tab', () => {
const { createTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
closeTabsToRight(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id)
})
it('should keep active tab if it is in the remaining set', () => {
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
switchToTab(tab1.id)
closeTabsToRight(tab1.id)
const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id)
})
})
describe('switchToTab', () => {
it('should switch active tab and update MRU stack', () => {
const { createTab, switchToTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
switchToTab(tab1.id)
const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id)
expect(state.mruStack).toContain(tab2.id)
})
it('should not update if switching to already active tab', () => {
const { createTab, switchToTab } = useTabStore.getState()
createTab('/file1.md')
const stateBefore = useTabStore.getState()
switchToTab(stateBefore.activeTabId!)
const stateAfter = useTabStore.getState()
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
})
})
describe('updateTabContent', () => {
it('should update content and mark as modified', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New Content')
expect(updated.isModified).toBe(true)
})
})
describe('getActiveTab', () => {
it('should return the active tab', () => {
const { createTab, getActiveTab } = useTabStore.getState()
const tab = createTab('/file.md')
const active = getActiveTab()
expect(active?.id).toBe(tab.id)
})
it('should return null when no tabs exist', () => {
const { getActiveTab } = useTabStore.getState()
expect(getActiveTab()).toBeNull()
})
})
describe('setModified', () => {
it('should set modified flag on a tab', () => {
const { createTab, setModified } = useTabStore.getState()
const tab = createTab('/file.md')
setModified(tab.id, true)
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.isModified).toBe(true)
setModified(tab.id, false)
const state2 = useTabStore.getState()
const updated2 = state2.tabs.find(t => t.id === tab.id)!
expect(updated2.isModified).toBe(false)
})
})
describe('updateTabScroll', () => {
it('should update scroll position', () => {
const { createTab, updateTabScroll } = useTabStore.getState()
const tab = createTab('/file.md')
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.scrollTop).toBe(100)
expect(updated.selectionStart).toBe(10)
expect(updated.selectionEnd).toBe(20)
})
})
})
+19 -4
View File
@@ -4,17 +4,32 @@ import type { ViewMode } from '../types/settings'
interface EditorState {
viewMode: ViewMode
darkMode: boolean
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
externallyModified: { filePath: string } | null
// UX-02: 全局加载状态
loadingStates: Record<string, boolean>
setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void
toggleDarkMode: () => void
setExternallyModified: (info: { filePath: string } | null) => void
// UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void
isLoading: (key: string) => boolean
}
export const useEditorStore = create<EditorState>((set) => ({
export const useEditorStore = create<EditorState>((set, get) => ({
viewMode: 'editor',
darkMode: false,
externallyModified: null,
loadingStates: {},
setViewMode: (mode) => set({ viewMode: mode }),
setDarkMode: (dark) => set({ darkMode: dark }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
setLoading: (key: string, loading: boolean) => set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading }
})),
isLoading: (key: string) => get().loadingStates[key] ?? false
}))
+8 -9
View File
@@ -16,6 +16,7 @@ interface SidebarState {
toggleDir: (path: string) => void
expandDirs: (paths: string[]) => void
setSidebarWidth: (width: number) => void
/** @deprecated AR-04: 设置由 useSettingsInit 统一加载,此方法仅作兼容保留 */
loadFromDB: () => Promise<void>
}
@@ -27,7 +28,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
sidebarWidth: 240,
_loaded: false,
// 从 IndexedDB 加载 sidebar 设置
// AR-04: 设置由 useSettingsInit 统一加载
loadFromDB: async () => {
if (get()._loaded) return
try {
@@ -42,29 +43,27 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
}
},
setVisible: (visible) => {
setVisible: (visible: boolean) => {
set({ isVisible: visible })
// 异步保存到 DB
settingsRepository.save({ sidebarCollapsed: !visible })
},
setRootPath: (path) => set({ rootPath: path }),
setTree: (tree) => set({ tree }),
toggleDir: (path) =>
setRootPath: (path: string | null) => set({ rootPath: path }),
setTree: (tree: FileNode[]) => set({ tree }),
toggleDir: (path: string) =>
set(state => ({
expandedDirs: state.expandedDirs.includes(path)
? state.expandedDirs.filter(p => p !== path)
: [...state.expandedDirs, path]
})),
expandDirs: (paths) =>
expandDirs: (paths: string[]) =>
set(state => {
const newDirs = paths.filter(p => !state.expandedDirs.includes(p))
return newDirs.length > 0
? { expandedDirs: [...state.expandedDirs, ...newDirs] }
: state
}),
setSidebarWidth: (width) => {
setSidebarWidth: (width: number) => {
set({ sidebarWidth: width })
// 异步保存到 DB
settingsRepository.save({ sidebarWidth: width })
}
}))
+184 -163
View File
@@ -3,6 +3,25 @@ import { nanoid } from 'nanoid'
import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository'
// PF-08: 防抖工具函数
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = ((...args: unknown[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}) as F & { cancel: () => void }
debounced.cancel = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return debounced
}
interface TabState {
tabs: Tab[]
activeTabId: string | null
@@ -23,50 +42,11 @@ interface TabState {
saveToDB: () => Promise<void>
}
export const useTabStore = create<TabState>((set, get) => ({
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
// PF-08: 模块级防抖保存函数(500ms延迟)
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
// 从 IndexedDB 加载标签页快照
loadFromDB: async () => {
if (get()._loaded) return
try {
const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(),
tabRepository.loadActiveTabId()
])
if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({
id: s.id,
filePath: s.filePath,
content: s.content,
isModified: s.isModified,
scrollTop: s.scrollTop,
selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd
}))
// 使用保存的 activeTabId,如果不存在则取最后一个
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
? savedActiveTabId
: tabs[tabs.length - 1].id
set({
tabs,
activeTabId,
_loaded: true
})
} else {
set({ _loaded: true })
}
} catch (err) {
console.error('Failed to load tabs from DB:', err)
set({ _loaded: true })
}
},
// 保存当前标签页到 IndexedDB
saveToDB: async () => {
function getActualSaveToDB(get: () => TabState) {
return async () => {
const { tabs } = get()
if (tabs.length === 0) {
await tabRepository.clearAll()
@@ -84,141 +64,182 @@ export const useTabStore = create<TabState>((set, get) => ({
}))
await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId)
},
}
}
createTab: (filePath = null, content = '') => {
if (filePath) {
const existing = get().tabs.find(t => t.filePath === filePath)
if (existing) {
get().switchToTab(existing.id)
return existing
}
}
export const useTabStore = create<TabState>((set, get) => {
const actualSave = getActualSaveToDB(get)
_debouncedSaveToDB = debounce(actualSave, 500)
const tab: Tab = {
id: nanoid(),
filePath,
content,
isModified: false,
scrollTop: 0,
selectionStart: 0,
selectionEnd: 0
}
return {
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
set(state => ({
tabs: [...state.tabs, tab],
activeTabId: tab.id
}))
// 异步保存
setTimeout(() => get().saveToDB(), 0)
return tab
},
closeTab: (tabId) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.filter(t => t.id !== tabId)
const newMru = state.mruStack.filter(id => id !== tabId)
let newActiveId = state.activeTabId
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null
loadFromDB: async () => {
if (get()._loaded) return
try {
const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(),
tabRepository.loadActiveTabId()
])
if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({
id: s.id,
filePath: s.filePath,
content: s.content,
isModified: s.isModified,
scrollTop: s.scrollTop,
selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd
}))
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
? savedActiveTabId
: tabs[tabs.length - 1].id
set({ tabs, activeTabId, _loaded: true })
} else {
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
if (mruCandidate) {
newActiveId = mruCandidate
newMru.splice(newMru.indexOf(mruCandidate), 1)
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
}
set({ _loaded: true })
}
} catch (err) {
console.error('Failed to load tabs from DB:', err)
set({ _loaded: true })
}
},
saveToDB: async () => {
if (_debouncedSaveToDB) {
_debouncedSaveToDB()
}
},
createTab: (filePath: string | null = null, content: string = ''): Tab => {
if (filePath) {
const existing = get().tabs.find(t => t.filePath === filePath)
if (existing) {
get().switchToTab(existing.id)
return existing
}
}
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: newMru
const tab: Tab = {
id: nanoid(),
filePath,
content,
isModified: false,
scrollTop: 0,
selectionStart: 0,
selectionEnd: 0
}
})
setTimeout(() => get().saveToDB(), 0)
},
set(state => ({
tabs: [...state.tabs, tab],
activeTabId: tab.id
}))
closeOtherTabs: (tabId) => {
set(state => {
const target = state.tabs.find(t => t.id === tabId)
if (!target) return state
return { tabs: [target], activeTabId: tabId, mruStack: [] }
})
setTimeout(() => get().saveToDB(), 0)
},
_debouncedSaveToDB?.()
return tab
},
closeAllTabs: () => {
set({ tabs: [], activeTabId: null, mruStack: [] })
setTimeout(() => get().saveToDB(), 0)
},
closeTab: (tabId: string) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
closeTabsToRight: (tabId) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId
: tabId
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
}
})
setTimeout(() => get().saveToDB(), 0)
},
const newTabs = state.tabs.filter(t => t.id !== tabId)
const newMru = state.mruStack.filter(id => id !== tabId)
switchToTab: (tabId) => {
set(state => {
if (state.activeTabId === tabId) return state
const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack
return { activeTabId: tabId, mruStack: newMru }
})
// 异步保存 activeTabId
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
},
let newActiveId = state.activeTabId
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null
} else {
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
if (mruCandidate) {
newActiveId = mruCandidate
newMru.splice(newMru.indexOf(mruCandidate), 1)
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
}
}
}
updateTabContent: (tabId, content) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, content, isModified: true } : t
)
}))
},
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
})
setModified: (tabId, modified) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, isModified: modified } : t
)
}))
},
_debouncedSaveToDB?.()
},
getActiveTab: () => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
},
closeOtherTabs: (tabId: string) => {
set(state => {
const target = state.tabs.find(t => t.id === tabId)
if (!target) return state
return { tabs: [target], activeTabId: tabId, mruStack: [] }
})
_debouncedSaveToDB?.()
},
updateTabScroll: (tabId, scroll) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, ...scroll } : t
)
}))
closeAllTabs: () => {
set({ tabs: [], activeTabId: null, mruStack: [] })
_debouncedSaveToDB?.()
},
closeTabsToRight: (tabId: string) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId
: tabId
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
}
})
_debouncedSaveToDB?.()
},
switchToTab: (tabId: string) => {
set(state => {
if (state.activeTabId === tabId) return state
const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack
return { activeTabId: tabId, mruStack: newMru }
})
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
},
updateTabContent: (tabId: string, content: string) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, content, isModified: true } : t
)
}))
},
setModified: (tabId: string, modified: boolean) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, isModified: modified } : t
)
}))
},
getActiveTab: (): Tab | null => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
},
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, ...scroll } : t
)
}))
}
}
}))
})
+362 -2
View File
@@ -438,9 +438,30 @@ html, body {
opacity: 1;
}
/* Selection */
/* Selection — 必须匹配oneDark主题的选择器特异性才能覆盖 */
.cm-selectionBackground {
background: rgba(26, 115, 232, 0.2) !important;
background: rgba(26, 115, 232, 0.25) !important;
}
.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground,
.cm-content ::selection {
background: rgba(26, 115, 232, 0.3) !important;
}
:root.dark .cm-selectionBackground {
background: rgba(100, 160, 255, 0.25) !important;
}
:root.dark .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
:root.dark .cm-editor.cm-focused .cm-selectionBackground,
:root.dark .cm-content ::selection {
background: rgba(100, 160, 255, 0.35) !important;
}
.cm-editor .cm-content {
user-select: text !important;
-webkit-user-select: text !important;
}
.cm-editor .cm-cursor {
@@ -1208,3 +1229,342 @@ html, body {
.about-close-btn:hover {
background: var(--primary-dark);
}
/* ===== UX-01: ConfirmDialog ===== */
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
animation: fadeIn 0.15s ease;
}
.confirm-dialog {
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
width: 380px;
max-width: 90vw;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
.confirm-header {
padding: 20px 24px 0;
}
.confirm-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.confirm-header.confirm-danger h3 { color: #d93025; }
.confirm-header.confirm-warning h3 { color: #e37400; }
:root.dark .confirm-header.confirm-warning h3 { color: #fdd663; }
.confirm-body {
padding: 12px 24px 20px;
}
.confirm-body p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
}
.confirm-btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: var(--font-ui);
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg);
color: var(--text);
}
.confirm-btn-cancel:hover {
background: var(--bg-tertiary);
}
.confirm-btn-danger {
background: #d93025;
border-color: #d93025;
color: white;
}
.confirm-btn-danger:hover {
background: #b5271d;
}
.confirm-btn-warning {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-warning:hover {
background: var(--primary-dark);
}
.confirm-btn-info {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-info:hover {
background: var(--primary-dark);
}
.confirm-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ===== UX-02: LoadingSpinner ===== */
.loading-spinner {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
.loading-spinner-label {
font-size: 13px;
font-family: var(--font-ui);
color: var(--text-secondary);
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
:root.dark .loading-overlay {
background: rgba(30, 30, 30, 0.8);
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--text-tertiary);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Status bar loading indicator */
.status-loading {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--primary);
font-size: 12px;
}
.status-loading .loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
/* ===== UX-05: Toast Container (升级版) ===== */
.toast-container {
position: fixed;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 9999;
pointer-events: none;
max-width: 480px;
width: 100%;
padding: 0 16px;
}
.toast-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
font-family: var(--font-ui);
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translateY(10px) scale(0.95);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: auto;
max-width: 100%;
}
.toast-item.toast-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Toast 类型样式 */
.toast-success {
background: #e6f4ea;
color: #137333;
border: 1px solid #a8dab5;
}
.toast-error {
background: #fce8e6;
color: #c5221f;
border: 1px solid #f28b82;
}
.toast-warning {
background: #fef7e0;
color: #945700;
border: 1px solid #fdd663;
}
.toast-info {
background: var(--text);
color: var(--bg);
border: 1px solid transparent;
}
:root.dark .toast-success {
background: #0d3a1a;
color: #81c995;
border-color: #1e5a2e;
}
:root.dark .toast-error {
background: #3c1214;
color: #f28b82;
border-color: #5c1a1a;
}
:root.dark .toast-warning {
background: #3a3000;
color: #fdd663;
border-color: #5a4a00;
}
:root.dark .toast-info {
background: var(--bg-secondary);
color: var(--text);
border-color: var(--border);
}
.toast-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.toast-message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast-close {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 12px;
border-radius: 4px;
padding: 0;
transition: opacity 0.15s ease;
}
.toast-close:hover {
opacity: 1;
}
.toast-close:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
}
/* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
button:focus-visible,
[role="treeitem"]:focus-visible,
[role="tab"]:focus-visible,
[role="menuitem"]:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* 确保 tab 上的关闭按钮也有焦点指示 */
.tab-close:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
/* 确保上下文菜单项有焦点指示 */
.tab-context-item:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
/* Skip to content 链接(可选,用于键盘导航) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+2 -2
View File
@@ -6,8 +6,8 @@
--bg-secondary: #f8f9fa;
--bg-tertiary: #f1f3f4;
--text: #333333;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--text-secondary: #555a5e;
--text-tertiary: #70757a;
--border: #e1e4e8;
--border-light: #f0f0f0;
--code-bg: #f6f8fa;
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+3 -7
View File
@@ -21,7 +21,7 @@ export interface IpcInvokeMap {
'window:forceClose': [void, void]
'window:cancelClose': [void, void]
'dir:readTree': [string, ReadDirTreeResult]
'dir:openDialog': [void, string | null]
'dir:openDialog': [void, { canceled: boolean; filePaths: string[] }]
'dir:watch': [string, void]
'dir:unwatch': [void, void]
}
@@ -41,7 +41,7 @@ export interface ElectronAPI {
cancelClose: () => Promise<void>
openExternal: (url: string) => void
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<string | null>
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
@@ -53,8 +53,4 @@ export interface ElectronAPI {
onConfirmClose: (callback: () => void) => Unsubscribe
}
declare global {
interface Window {
electronAPI?: ElectronAPI
}
}