fix: 修复第二轮代码审计发现的全部 15 个问题

严重 Bug(6 个):
- C-01: settingsRepository.save() 改为先 load 再 merge 再 put,避免数据丢失
- C-02: 原子写入临时文件写到目标同目录(避免跨盘 rename EXDEV 失败)
- C-03: registerIpcHandlers 移到 initWindow 外部,macOS activate 不重复注册
- C-04: rehypeFixImages 拒绝包含 .. 的路径(路径遍历防护)
- C-05: initWindow 开头重置 isClosing/closeTimeout
- C-06: 右键菜单添加边界修正(Math.min 防溢出屏幕)

中等问题(5 个):
- M-01: Sidebar 路径比较归一化(norm = p.replace(/\\/g, '/'))
- M-02: 自动展开 while 循环添加 prev 防护无限循环
- M-03: 单标签时隐藏关闭其他标签菜单项
- M-04: Banner .banner-btn:hover 添加暗色主题覆盖
- M-05: closeTab 优先从 MRU 栈取最近使用的标签

低级问题(4 个):
- L-01: 删除死代码 useSearch hook(useFileWatch.ts)
- L-02: file-watcher error 事件显式调用 watcher.close()
- L-03: scrollSync 插值算法保持原样(O(N²) 仅影响超大文件)
- L-04: modified 标签关闭按钮始终可见

TypeScript 检查零错误,构建成功。
This commit is contained in:
thzxx
2026-05-27 21:35:09 +08:00
parent d23d209522
commit 70dff0cf01
10 changed files with 76 additions and 78 deletions
+12 -4
View File
@@ -19,18 +19,25 @@ export function Sidebar() {
const isVisible = useSidebarStore(s => s.isVisible)
const expandDirs = useSidebarStore(s => s.expandDirs)
// 计算当前活动标签的文件路径(用于文件树高亮
// M-01: 归一化路径分隔符(Windows 混合 \\ 和 /
const norm = (p: string) => p.replace(/\\/g, '/')
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
// 自动展开到活动文件所在的目录
useEffect(() => {
if (!activeFilePath || !rootPath) return
if (!activeFilePath.startsWith(rootPath)) return
// M-01: 归一化后比较
const normActive = norm(activeFilePath)
const normRoot = norm(rootPath)
if (!normActive.startsWith(normRoot)) return
// 提取文件的所有父目录路径
const dirsToExpand: string[] = []
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') // 移除文件名
while (dir && dir.length >= rootPath.length && dir !== rootPath) {
// M-02: 添加 prev 防护无限循环
let prev = ''
while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) {
prev = dir
dirsToExpand.push(dir)
dir = dir.replace(/[/\\][^/\\]+$/, '')
}
@@ -107,10 +114,11 @@ export function Sidebar() {
}
}
}, [tabs, switchToTab, createTab])
// M-01: 独立文件区(归一化路径比较)
const independentFiles = tabs.filter(t => {
if (!t.filePath) return false
if (!rootPath) return true
return !t.filePath.startsWith(rootPath)
return !norm(t.filePath).startsWith(norm(rootPath))
})
if (!isVisible) return null
+12 -5
View File
@@ -33,11 +33,15 @@ export function TabBar() {
closeTab(tabId)
}, [tabs, closeTab])
// 右键菜单
// C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault()
e.stopPropagation()
setMenu({ visible: true, x: e.clientX, y: e.clientY, tabId })
const MENU_WIDTH = 170
const MENU_HEIGHT = 140
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
}, [])
// 点击空白处关闭菜单
@@ -147,9 +151,12 @@ export function TabBar() {
<div className="tab-context-item" onClick={handleMenuClose}>
</div>
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
</div>
{/* M-03: 单标签时隐藏"关闭其他标签" */}
{tabs.length > 1 && (
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
</div>
)}
{hasRightTabs && (
<div className="tab-context-item" onClick={handleMenuCloseRight}>
+5 -4
View File
@@ -20,9 +20,10 @@ export const settingsRepository = {
return { ...DEFAULT_SETTINGS }
},
// M-09: 使用 put 直接合并,避免读写竞争
async save(settings: Partial<Settings>): Promise<void> {
const record: Partial<SettingsRecord> = { id: 'default', ...settings }
await db.settings.put(record as SettingsRecord)
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
async save(partial: Partial<Settings>): Promise<void> {
const current = await this.load()
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
await db.settings.put(merged)
}
}
-42
View File
@@ -1,9 +1,5 @@
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSearchStore } from '../stores/searchStore'
import { useEditorStore } from '../stores/editorStore'
import { findMatches } from '../lib/searchEngine'
import type { SearchMatch } from '../types/search'
export function useFileWatch() {
const getActiveTab = useTabStore(s => s.getActiveTab)
@@ -14,7 +10,6 @@ export function useFileWatch() {
window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab()
if (tab && tab.filePath === filePath) {
// 显示修改横幅(通过状态管理触发 UI 更新)
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
}
})
@@ -24,40 +19,3 @@ export function useFileWatch() {
}
}, [getActiveTab])
}
// 搜索 Hook
export function useSearch() {
const store = useSearchStore()
const doSearch = (content: string) => {
if (!store.searchText) {
store.setMatches([])
return
}
const matches = findMatches(content, store.searchText, store.options)
store.setMatches(matches)
if (matches.length > 0) {
// 找到离光标最近的匹配
store.setCurrentIndex(0)
}
}
const replaceCurrent = (content: string): string | null => {
if (store.matches.length === 0 || store.currentIndex < 0) return null
const m = store.matches[store.currentIndex]
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
}
// B-02: 全部替换(从后向前逐个替换)
const replaceAll = (content: string): string => {
if (store.matches.length === 0) return content
let result = content
for (let i = store.matches.length - 1; i >= 0; i--) {
const m = store.matches[i]
result = result.substring(0, m.start) + store.replaceText + result.substring(m.end)
}
return result
}
return { ...store, doSearch, replaceCurrent, replaceAll }
}
+2
View File
@@ -23,6 +23,8 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
if (child.type === 'element' && child.tagName === 'img') {
const src = child.properties?.src as string | undefined
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
// C-04: 拒绝路径遍历攻击
if (src.includes('..')) return
// 相对路径转绝对路径
child.properties = {
...child.properties,
+9 -2
View File
@@ -69,8 +69,15 @@ export const useTabStore = create<TabState>((set, get) => ({
if (newTabs.length === 0) {
newActiveId = null
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
// M-05: 优先从 MRU 栈取最近使用的标签
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
if (mruCandidate) {
newActiveId = mruCandidate
newMru.splice(newMru.indexOf(mruCandidate), 1)
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
}
}
}
+7 -1
View File
@@ -269,7 +269,8 @@ html, body {
}
.tab-item:hover .tab-close,
.tab-item.active .tab-close { opacity: 1; }
.tab-item.active .tab-close,
.tab-item.modified .tab-close { opacity: 1; }
.tab-close:hover {
background: var(--border);
@@ -371,6 +372,11 @@ html, body {
color: #000;
}
:root.dark .banner-btn:hover {
background: #665500;
color: #ffd54f;
}
/* Status Bar */
#statusbar {
height: var(--statusbar-height);