Files
MarkLite/src/renderer/components/Sidebar/Sidebar.tsx
T
thzxx 679cd4b0a0 feat: 打开文件时自动展开目录树到该文件所在位置
场景:已打开文件夹 /project,通过 Ctrl+O 打开
      /project/src/components/App.tsx,目录树自动展开
      src/ 和 components/ 目录,高亮显示该文件。

实现:
- sidebarStore 新增 expandDirs(paths) 方法,批量展开目录
- Sidebar 组件监听 activeFilePath 变化
- 提取文件的所有父目录路径(从文件到 rootPath)
- 自动调用 expandDirs 展开所有父目录
2026-05-27 21:07:18 +08:00

247 lines
8.8 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 expandDirs = useSidebarStore(s => s.expandDirs)
// 计算当前活动标签的文件路径(用于文件树高亮)
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
// 自动展开到活动文件所在的目录
useEffect(() => {
if (!activeFilePath || !rootPath) return
if (!activeFilePath.startsWith(rootPath)) return
// 提取文件的所有父目录路径
const dirsToExpand: string[] = []
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') // 移除文件名
while (dir && dir.length >= rootPath.length && dir !== rootPath) {
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)
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>
))}
</>
)
}