问题: FileTree 组件的 className 模板为空字符串,文件项永远不会显示
active 高亮样式,导致用户无法在目录树中看到当前打开的文件。
修复:
- FileTree 新增 activeFilePath prop,用于比较 node.path
- Sidebar 组件从 tabs + activeTabId 计算 activeFilePath
- className 改为 node.path === activeFilePath ? 'active' : ''
联动验证:
- 点击目录树文件 → 标签栏新增标签 → 侧边栏高亮 ✓
- 切换标签 → 侧边栏高亮跟随切换 ✓
- 关闭标签 → 侧边栏高亮消失 ✓
- 独立文件区与目录树不重复 ✓
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
import { useSidebarStore } from '../../stores/sidebarStore'
|
|
import { getFileName } from '../../lib/fileUtils'
|
|
import type { FileNode } from '../../types/file'
|
|
|
|
export function Sidebar() {
|
|
const tabs = useTabStore(s => s.tabs)
|
|
const activeTabId = useTabStore(s => s.activeTabId)
|
|
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 activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
|
|
|
|
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)
|
|
const result = await window.electronAPI.readDirTree(dirPath)
|
|
if (result.success && result.tree) {
|
|
setTree(result.tree)
|
|
window.electronAPI.watchDir(dirPath)
|
|
}
|
|
}
|
|
}, [setRootPath, setTree])
|
|
|
|
// 刷新目录树
|
|
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
|
|
window.electronAPI.onDirChanged(() => {
|
|
refreshTree()
|
|
})
|
|
return () => {
|
|
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
|
|
}
|
|
}, [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])
|
|
|
|
// L-12: 提取为 useCallback 避免每次渲染重建
|
|
const handleFileClick = useCallback(async (path: string) => {
|
|
const existing = tabs.find(t => t.filePath === path)
|
|
if (existing) {
|
|
switchToTab(existing.id)
|
|
} else if (window.electronAPI) {
|
|
const result = await window.electronAPI.readFile(path)
|
|
if (result.success && result.content) {
|
|
createTab(path, result.content)
|
|
}
|
|
}
|
|
}, [tabs, switchToTab, createTab])
|
|
const independentFiles = tabs.filter(t => {
|
|
if (!t.filePath) return false
|
|
if (!rootPath) return true
|
|
return !t.filePath.startsWith(rootPath)
|
|
})
|
|
|
|
if (!isVisible) return null
|
|
|
|
return (
|
|
<div id="sidebar" ref={sidebarRef}>
|
|
<div id="sidebar-header">
|
|
<span id="sidebar-title">资源管理器</span>
|
|
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="sidebar-tree">
|
|
{/* 独立文件区 */}
|
|
{independentFiles.length > 0 && (
|
|
<div className="independent-files-section">
|
|
<div className="independent-files-header">已打开的文件</div>
|
|
{independentFiles.map(tab => (
|
|
<div
|
|
key={tab.id}
|
|
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
|
style={{ paddingLeft: '8px' }}
|
|
onClick={() => switchToTab(tab.id)}
|
|
>
|
|
<span className="tree-icon">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<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" />
|
|
</svg>
|
|
</span>
|
|
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
|
{tab.isModified && <span className="independent-modified-dot"> •</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 文件夹目录树 */}
|
|
{rootPath && (
|
|
<>
|
|
<div className="sidebar-section-header">文件夹目录树</div>
|
|
<FileTree
|
|
nodes={tree}
|
|
depth={0}
|
|
expandedDirs={expandedDirs}
|
|
toggleDir={toggleDir}
|
|
activeTabId={activeTabId}
|
|
activeFilePath={activeFilePath}
|
|
onFileClick={handleFileClick}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 调节手柄 */}
|
|
<div
|
|
className="sidebar-resize-handle"
|
|
onMouseDown={() => setIsResizing(true)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 文件树递归组件
|
|
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, activeFilePath, onFileClick }: {
|
|
nodes: FileNode[]
|
|
depth: number
|
|
expandedDirs: Set<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' }}
|
|
onClick={() => {
|
|
if (node.type === 'dir') {
|
|
toggleDir(node.path)
|
|
} else {
|
|
onFileClick(node.path)
|
|
}
|
|
}}
|
|
>
|
|
{node.type === 'dir' ? (
|
|
<>
|
|
<span className={`tree-arrow ${expandedDirs.has(node.path) ? 'expanded' : ''}`}>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</span>
|
|
<span className="tree-icon">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span style={{ width: '16px', flexShrink: 0 }} />
|
|
<span className="tree-icon">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<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" />
|
|
</svg>
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="tree-name">{node.name}</span>
|
|
</div>
|
|
{node.type === 'dir' && expandedDirs.has(node.path) && node.children && (
|
|
<FileTree
|
|
nodes={node.children}
|
|
depth={depth + 1}
|
|
expandedDirs={expandedDirs}
|
|
toggleDir={toggleDir}
|
|
activeTabId={activeTabId}
|
|
activeFilePath={activeFilePath}
|
|
onFileClick={onFileClick}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</>
|
|
)
|
|
}
|