refactor: 删除分屏功能 + 代码审计修复 + 安全加固
## 删除分屏功能 - 删除 ViewMode 'split' 类型,仅保留 editor/preview - 删除 splitRatio 状态和 Resizer 组件 - 删除滚动同步模块 (scrollSync.ts, rehypeSourceLine.ts) - 更新快捷键: Ctrl+1 编辑, Ctrl+2 预览 ## Bug 修复 - 修复 Toast setTimeout 内存泄漏 (App.tsx) - 修复 ModifiedBanner reload 更新错误标签 (改用 modifiedFilePath 匹配) - 修复 FileWatcher error 未重置 isSelfWriting (file-watcher.ts) - 修复另存为时未 stop watcher (ipc-handlers.ts) ## 死代码清理 (10 项) - 删除 main/ipc-channels.ts (与 shared/ 重复) - 删除 main/file-system.ts 未使用的 formatBytes - 删除 constants.ts 6 个未使用常量 - 删除 fileUtils.ts 未使用的 formatBytes - 删除 Icons.tsx 5 个未使用图标 (SplitView/ChevronUp/ChevronDown/X/ArrowUp/ArrowDown) - 删除 useCodeMirror 未使用的 getContent/scrollTo - 删除 TabBar 未使用的 menuRef - 删除 Tab.scrollLeft/previewScrollTop 字段 - 删除 tabStore 未使用的 getTabIndex - 删除 Toast/ModifiedBanner 多余 React import ## 安全加固 - ipc-handlers: 添加路径遍历防护 (validatePath 函数) - preload: openExternal 仅允许 http/https 协议 - window-manager: 启用 sandbox: true - preload: removeAllListeners 改为精确取消订阅 (返回 Unsubscribe 函数) ## 状态持久化 - activeTabId 持久化到 IndexedDB (刷新后恢复正确标签) - sidebar 状态持久化到 IndexedDB (isVisible/sidebarWidth) ## 代码优化 - preload 使用 IPC_CHANNELS 常量替代硬编码字符串 - ipc-handlers _event 类型改为 IpcMainInvokeEvent - settingsRepository 删除 splitRatio 字段
This commit is contained in:
@@ -22,8 +22,7 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
setLineAtTop
|
||||
setSelection
|
||||
} = useCodeMirror({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
@@ -31,25 +30,9 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode,
|
||||
onScroll: useCallback((line: number) => {
|
||||
// 派发行号给预览侧做滚动同步
|
||||
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { line } }))
|
||||
}, [])
|
||||
darkMode
|
||||
})
|
||||
|
||||
// 反向同步:监听预览滚动事件
|
||||
useEffect(() => {
|
||||
const handlePreviewScroll = (e: Event) => {
|
||||
const line = (e as CustomEvent).detail?.line
|
||||
if (typeof line === 'number') {
|
||||
setLineAtTop(Math.floor(line) + 1) // setLineAtTop 是 1-indexed
|
||||
}
|
||||
}
|
||||
window.addEventListener('preview-scroll', handlePreviewScroll)
|
||||
return () => window.removeEventListener('preview-scroll', handlePreviewScroll)
|
||||
}, [setLineAtTop])
|
||||
|
||||
// 切换标签时加载内容
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
|
||||
@@ -11,10 +11,9 @@ interface UseCodeMirrorOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
onScroll?: (line: number) => void
|
||||
}
|
||||
|
||||
export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) {
|
||||
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const isExternalUpdate = useRef(false)
|
||||
@@ -81,23 +80,7 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
|
||||
|
||||
viewRef.current = view
|
||||
|
||||
// 滚动事件监听:派发行号(支持小数表示行内偏移)
|
||||
const handleScroll = () => {
|
||||
if (onScroll) {
|
||||
const dom = view.scrollDOM
|
||||
const scrollTop = dom.scrollTop
|
||||
const block = view.lineBlockAtHeight(scrollTop)
|
||||
const line = view.state.doc.lineAt(block.from)
|
||||
const fraction = block.height > 0
|
||||
? (scrollTop - block.top) / block.height
|
||||
: 0
|
||||
onScroll(line.number - 1 + Math.max(0, fraction)) // 0-indexed + 行内比例
|
||||
}
|
||||
}
|
||||
view.scrollDOM.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
view.scrollDOM.removeEventListener('scroll', handleScroll)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
@@ -116,12 +99,6 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取当前内容
|
||||
const getContent = useCallback(() => {
|
||||
return viewRef.current?.state.doc.toString() ?? ''
|
||||
}, [])
|
||||
|
||||
// 获取/设置滚动位置
|
||||
const getScrollTop = useCallback(() => {
|
||||
return viewRef.current?.scrollDOM.scrollTop ?? 0
|
||||
@@ -147,35 +124,13 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
|
||||
view.dispatch({ selection: { anchor: from, head: to } })
|
||||
view.focus()
|
||||
}, [])
|
||||
|
||||
// 滚动到指定行
|
||||
const scrollTo = useCallback((pos: number) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
|
||||
}, [])
|
||||
|
||||
// 滚动使指定行出现在编辑器顶部(反向同步用)
|
||||
const setLineAtTop = useCallback((lineNumber: number) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const line = Math.max(1, Math.min(lineNumber, view.state.doc.lines))
|
||||
const lineInfo = view.state.doc.line(line)
|
||||
const block = view.lineBlockAt(lineInfo.from)
|
||||
const currentTop = view.lineBlockAtHeight(view.scrollDOM.scrollTop).top
|
||||
view.scrollDOM.scrollTop += block.top - currentTop
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewRef,
|
||||
setContent,
|
||||
getContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
scrollTo,
|
||||
setLineAtTop
|
||||
setSelection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,19 +58,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SplitView({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<line x1="12" y1="3" x2="12" y2="21"/>
|
||||
<rect x="5" y="7" width="5" height="2" rx="1" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="5" y="11" width="3" height="2" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="14" y="7" width="5" height="2" rx="1" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="14" y="11" width="3" height="2" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditMode({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -175,32 +162,6 @@ export function FolderPlus({ size = 14 }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 搜索栏图标 =====
|
||||
export function ChevronUp({ size = 12 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronDown({ size = 12 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function X({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 拖拽覆盖层图标 =====
|
||||
export function UploadCloud({ size = 64 }: IconProps) {
|
||||
return (
|
||||
@@ -213,25 +174,6 @@ export function UploadCloud({ size = 64 }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 箭头/导航图标 =====
|
||||
export function ArrowUp({ size = 12 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5"/>
|
||||
<polyline points="5 12 12 5 19 12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArrowDown({ size = 12 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<polyline points="19 12 12 19 5 12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 欢迎屏幕图标 =====
|
||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { scrollPreviewToLine, getLineAtScrollOffset, invalidateScrollCache } from '../../lib/scrollSync'
|
||||
|
||||
export function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const isSyncingRef = useRef(false)
|
||||
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
@@ -24,49 +22,10 @@ export function Preview() {
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
invalidateScrollCache()
|
||||
}
|
||||
})
|
||||
}, [activeTabId, activeTab?.content])
|
||||
|
||||
// 滚动同步:监听编辑器滚动事件(VS Code 方案:行号 → 二分查找)
|
||||
useEffect(() => {
|
||||
const handleEditorScroll = (e: Event) => {
|
||||
const line = (e as CustomEvent).detail?.line
|
||||
if (typeof line !== 'number') return
|
||||
|
||||
const container = previewRef.current?.parentElement
|
||||
if (!container || isSyncingRef.current) return
|
||||
|
||||
scrollPreviewToLine(line, container, isSyncingRef)
|
||||
}
|
||||
|
||||
window.addEventListener('editor-scroll', handleEditorScroll)
|
||||
return () => window.removeEventListener('editor-scroll', handleEditorScroll)
|
||||
}, [])
|
||||
|
||||
// 反向同步:预览滚动 → 通知编辑器
|
||||
useEffect(() => {
|
||||
const container = previewRef.current?.parentElement
|
||||
if (!container) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isSyncingRef.current) return
|
||||
|
||||
const line = getLineAtScrollOffset(container.scrollTop, container)
|
||||
if (line !== null) {
|
||||
isSyncingRef.current = true
|
||||
window.dispatchEvent(new CustomEvent('preview-scroll', { detail: { line } }))
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [activeTabId])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
|
||||
@@ -74,12 +74,10 @@ export function Sidebar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
window.electronAPI.onDirChanged(() => {
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => {
|
||||
refreshTree()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
|
||||
}
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import React, { useCallback, useState, useEffect } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
@@ -21,7 +21,6 @@ export function TabBar() {
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
@@ -133,8 +132,7 @@ export function TabBar() {
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="tab-context-menu"
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, SplitView, EditMode, PreviewMode, Moon, Sun } from '../Icons'
|
||||
import { FolderOpen, Save, EditMode, PreviewMode, Moon, Sun } from '../Icons'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
viewMode: 'split' | 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void
|
||||
viewMode: 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'editor' | 'preview') => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
}
|
||||
@@ -23,15 +23,11 @@ export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode,
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" />
|
||||
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)">
|
||||
<SplitView size={18} />
|
||||
<span>分屏</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
|
||||
<EditMode size={18} />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="纯预览 (Ctrl+3)">
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+2)">
|
||||
<PreviewMode size={18} />
|
||||
<span>预览</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user