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:
@@ -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'
|
||||
Reference in New Issue
Block a user