import React, { useCallback, useMemo, useEffect, useRef } 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 } from '../Icons' import { FileTree } from '../FileTree' import { useSidebarResize } from '../../hooks/useSidebarResize' import { useFolderOperations } from '../../hooks/useFolderOperations' import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' import { useActiveHeading } from '../../hooks/useActiveHeading' import { OutlinePanel, parseHeadings } from '../OutlinePanel' import type { Heading } from '../OutlinePanel' import { getMetonaEditor, useEditorStore } from '../../stores/editorStore' const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export const Sidebar = React.memo(function Sidebar() { const tabs = useTabStore(s => s.tabs) const activeTabId = useTabStore(s => s.activeTabId) // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), // 后者每次返回新对象引用导致 Zustand 无条件重渲染 const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, 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 isVisible = useSidebarStore(s => s.isVisible) const activeFilePath = activeTab?.filePath ?? null const { sidebarRef, startResize } = useSidebarResize() const { handleOpenFolder } = useFolderOperations() const setLoading = useEditorStore(s => s.setLoading) const viewMode = useEditorStore(s => s.viewMode) useAutoExpandDir(activeFilePath) // Parse headings from active tab content const headings = useMemo(() => { if (!activeTab?.content) return [] return parseHeadings(activeTab.content) }, [activeTab?.content]) // D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview) const previewRef = useRef(null) useEffect(() => { if (viewMode === 'preview') { previewRef.current = document.querySelector('.me-preview') as HTMLElement | null } else { previewRef.current = null } }, [viewMode]) const activeHeadingIndex = useActiveHeading( viewMode === 'preview' ? previewRef : { current: null }, headings ) // Navigate to heading in MetonaEditor const handleHeadingNavigate = useCallback((heading: Heading) => { const editor = getMetonaEditor() if (!editor) return try { // 获取当前内容,查找标题文本在源代码中的位置 const content = editor.getValue() const headingPattern = new RegExp( `^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, 'm' ) const match = headingPattern.exec(content) if (!match) return const pos = match.index // 通过 DOM 操作滚动 textarea 到对应位置 const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null if (!container) return const textarea = container.querySelector('textarea') if (!textarea) return // 估算滚动位置(简单方法:按行数比例) const linesBefore = content.substring(0, pos).split('\n').length const lineHeight = 24 // 估算行高 textarea.scrollTop = linesBefore * lineHeight // 设置光标位置 textarea.focus() textarea.setSelectionRange(pos, pos) } catch { // 导航失败,静默忽略 } }, []) const handleFileClick = useCallback(async (path: string) => { const existing = tabs.find(t => t.filePath === path) if (existing) { switchToTab(existing.id); return } if (!window.electronAPI) return setLoading('file-open', true) try { const result = await window.electronAPI.readFile(path) if (result.success && result.content !== undefined) { createTab(path, result.content) recentFilesRepository.add(path) } } finally { setLoading('file-open', false) } }, [tabs, switchToTab, createTab, setLoading]) const independentFiles = tabs.filter(t => { if (!t.filePath) return false if (!rootPath) return true return !norm(t.filePath).startsWith(norm(rootPath)) }) if (!isVisible) return null return ( ) }) Sidebar.displayName = 'Sidebar'