chore: bump version to 0.3.8

Code audit fixes:
- CRITICAL: reorder Markdown pipeline (fixImages before sanitize)
- CRITICAL: fix path prefix separator check
- BLOCKING: remove duplicate useEffect in Editor
- BLOCKING: skip onChange when content unchanged
- BLOCKING: optimize Sidebar re-render with useMemo
- HIGH: cleanup FileWatcher polling intervals
- HIGH: improve validatePath segment check
- HIGH: fix isExternalUpdate race with counter
- HIGH: add will-navigate / setWindowOpenHandler
- HIGH: explicit strip in sanitize schema
- MEDIUM: random temp file suffix instead of Date.now()
- MEDIUM/LOW: add IndexedDB error boundaries
- LOW: support UTF-16 BOM detection
This commit is contained in:
thzxx
2026-06-18 21:28:35 +08:00
parent dd78ff15a9
commit 7f070eb11d
13 changed files with 165 additions and 63 deletions
+4 -14
View File
@@ -33,6 +33,10 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
if (!activeTabId) return
// B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated),
// 避免将文档误标记为已修改
const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId)
if (tab?.content === value) return
updateTabContent(activeTabId, value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
@@ -83,20 +87,6 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
return () => setEditorViewGetter(() => null)
}, [getView])
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => {
if (!activeTabId) return
updateTabScroll(activeTabId, {
scrollTop: getScrollTop(),
selectionStart: getSelection().from,
selectionEnd: getSelection().to
})
}
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+12 -2
View File
@@ -88,6 +88,8 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
const onChangeRef = useRef(onChange)
const isExternalUpdate = useRef(false)
const initialContentRef = useRef(content)
// H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件
const setContentRequestId = useRef(0)
// Keep onChangeRef fresh
useEffect(() => {
@@ -110,7 +112,9 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown === prevMarkdown) return
if (!isExternalUpdate.current) {
// H-03: 只有未被外部更新抑制时才触发 onChange。
// 计数器匹配确保用户按键不会在 setContent 期间被丢弃。
if (setContentRequestId.current === 0 && !isExternalUpdate.current) {
onChangeRef.current(markdown)
}
})
@@ -165,13 +169,19 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
const editor = editorRef.current
if (!editor) return
// H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制
const requestId = ++setContentRequestId.current
isExternalUpdate.current = true
try {
editor.action(milkdownReplaceAll(newContent))
} catch {
// replaceAll may fail if editor is not fully ready
} finally {
isExternalUpdate.current = false
// 仅当没有新的 setContent 启动时才重置标志
if (setContentRequestId.current === requestId) {
setContentRequestId.current = 0
isExternalUpdate.current = false
}
}
}, [])
+3 -1
View File
@@ -18,7 +18,9 @@ const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const activeTab = useTabStore(s => s.getActiveTab())
// 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)
+37 -19
View File
@@ -1,35 +1,53 @@
import { db, type RecentFile } from './schema'
import { logError } from '../lib/errorHandler'
export const recentFilesRepository = {
async add(filePath: string): Promise<void> {
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
if (existing) {
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
} else {
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
}
// L-06: 清理超过 50 条的旧记录
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
if (all.length > 50) {
const toDelete = all.slice(50)
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
try {
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
if (existing) {
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
} else {
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
}
// L-06: 清理超过 50 条的旧记录
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
if (all.length > 50) {
const toDelete = all.slice(50)
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
}
} catch (error) {
logError('添加最近文件失败', error)
}
},
async getAll(limit: number = 20): Promise<string[]> {
const files: RecentFile[] = await db.recentFiles
.orderBy('lastOpened')
.reverse()
.limit(limit)
.toArray()
return files.map((f: RecentFile) => f.filePath)
try {
const files: RecentFile[] = await db.recentFiles
.orderBy('lastOpened')
.reverse()
.limit(limit)
.toArray()
return files.map((f: RecentFile) => f.filePath)
} catch (error) {
logError('读取最近文件失败', error)
return []
}
},
async remove(filePath: string): Promise<void> {
await db.recentFiles.where('filePath').equals(filePath).delete()
try {
await db.recentFiles.where('filePath').equals(filePath).delete()
} catch (error) {
logError('删除最近文件失败', error)
}
},
async clear(): Promise<void> {
await db.recentFiles.clear()
try {
await db.recentFiles.clear()
} catch (error) {
logError('清空最近文件失败', error)
}
}
}
+8 -3
View File
@@ -21,9 +21,14 @@ export const settingsRepository = {
},
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
// M-04: 添加错误处理与日志
async save(partial: Partial<Settings>): Promise<void> {
const current: Settings = await this.load()
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
await db.settings.put(merged)
try {
const current: Settings = await this.load()
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
await db.settings.put(merged)
} catch (error) {
logError('保存设置失败', error)
}
}
}
+32 -9
View File
@@ -1,27 +1,50 @@
import { db, type TabSnapshot } from './schema'
import { logError } from '../lib/errorHandler'
export const tabRepository = {
async saveAll(tabs: TabSnapshot[]): Promise<void> {
await db.transaction('rw', db.tabSnapshots, async () => {
await db.tabSnapshots.clear()
await db.tabSnapshots.bulkAdd(tabs)
})
try {
await db.transaction('rw', db.tabSnapshots, async () => {
await db.tabSnapshots.clear()
await db.tabSnapshots.bulkAdd(tabs)
})
} catch (error) {
logError('保存标签快照失败', error)
}
},
async loadAll(): Promise<TabSnapshot[]> {
return db.tabSnapshots.orderBy('updatedAt').toArray()
try {
return await db.tabSnapshots.orderBy('updatedAt').toArray()
} catch (error) {
logError('加载标签快照失败', error)
return []
}
},
async clearAll(): Promise<void> {
await db.tabSnapshots.clear()
try {
await db.tabSnapshots.clear()
} catch (error) {
logError('清空标签快照失败', error)
}
},
async saveActiveTabId(tabId: string | null): Promise<void> {
await db.activeTab.put({ id: 'current', activeTabId: tabId })
try {
await db.activeTab.put({ id: 'current', activeTabId: tabId })
} catch (error) {
logError('保存活动标签ID失败', error)
}
},
async loadActiveTabId(): Promise<string | null> {
const record = await db.activeTab.get('current')
return record?.activeTabId ?? null
try {
const record = await db.activeTab.get('current')
return record?.activeTabId ?? null
} catch (error) {
logError('加载活动标签ID失败', error)
return null
}
}
}
+15 -3
View File
@@ -56,7 +56,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
const normalizedBase = normalizedDir.includes('\\')
? normalizedDir.replace(/\\/g, '/')
: normalizedDir
if (!normalizedResolved.startsWith(normalizedBase)) return
if (!normalizedResolved.startsWith(normalizedBase + '/')) return
child.properties = {
...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
@@ -83,14 +83,26 @@ function buildProcessor(filePath: string | null) {
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
.use(rehypeFixImages(filePath ?? null))
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
}
},
protocols: {
...(defaultSchema.protocols ?? {}),
src: [
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
'file:',
],
},
// H-05: 显式 strip 事件处理器属性,纵深防御
strip: ['script', 'on*', 'javascript:'],
})
.use(rehypeFixImages(filePath ?? null))
.use(rehypeHighlight)
.use(rehypeStringify)
}