v0.3.7: 全面代码审计修复 - 20项Bug/安全/稳定性改进
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.3.6",
|
"version": "0.3.7",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+23
-4
@@ -1,4 +1,4 @@
|
|||||||
import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises'
|
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
|
||||||
import { join, extname, dirname, basename } from 'path'
|
import { join, extname, dirname, basename } from 'path'
|
||||||
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||||||
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
|
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
|
||||||
@@ -33,10 +33,29 @@ export async function saveFileContent(filePath: string, content: string): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M-05: 递归深度限制 + 错误边界
|
// M-05: 递归深度限制 + 错误边界 + 符号链接循环检测
|
||||||
export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise<FileNode[]> {
|
export async function buildDirTree(
|
||||||
|
dirPath: string,
|
||||||
|
depth = 0,
|
||||||
|
maxDepth = 10,
|
||||||
|
visited?: Set<string>
|
||||||
|
): Promise<FileNode[]> {
|
||||||
if (depth > maxDepth) return []
|
if (depth > maxDepth) return []
|
||||||
|
|
||||||
|
if (!visited) visited = new Set<string>()
|
||||||
|
|
||||||
|
// 检测符号链接循环
|
||||||
|
let realPath: string
|
||||||
|
try {
|
||||||
|
const ls = await lstat(dirPath)
|
||||||
|
if (ls.isSymbolicLink()) return []
|
||||||
|
realPath = await realpath(dirPath)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (visited.has(realPath)) return []
|
||||||
|
visited.add(realPath)
|
||||||
|
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await readdir(dirPath, { withFileTypes: true })
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
@@ -57,7 +76,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
|
|||||||
|
|
||||||
const childPath = join(dirPath, entry.name)
|
const childPath = join(dirPath, entry.name)
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const subChildren = await buildDirTree(childPath, depth + 1, maxDepth)
|
const subChildren = await buildDirTree(childPath, depth + 1, maxDepth, visited)
|
||||||
if (subChildren.length > 0) {
|
if (subChildren.length > 0) {
|
||||||
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export class FileWatcher {
|
|||||||
})
|
})
|
||||||
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
|
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
|
||||||
this.watcher.on('error', () => {
|
this.watcher.on('error', () => {
|
||||||
this.stop()
|
|
||||||
const originalPath = this.currentPath
|
const originalPath = this.currentPath
|
||||||
|
this.stop()
|
||||||
if (originalPath) {
|
if (originalPath) {
|
||||||
const pollInterval = setInterval(() => {
|
const pollInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ export function registerIpcHandlers(
|
|||||||
const win = getMainWindow()
|
const win = getMainWindow()
|
||||||
try {
|
try {
|
||||||
if (data.filePath) {
|
if (data.filePath) {
|
||||||
fileWatcher.stop()
|
// Mark self-writing before stopping watcher to suppress any events
|
||||||
|
// that arrive between stop and start
|
||||||
fileWatcher.setSelfWriting(true)
|
fileWatcher.setSelfWriting(true)
|
||||||
|
fileWatcher.stop()
|
||||||
const result = await saveFileContent(data.filePath, data.content)
|
const result = await saveFileContent(data.filePath, data.content)
|
||||||
// start watcher while selfWriting is still true so any immediate 'change'
|
// Restart watcher while selfWriting is still true so any immediate 'change'
|
||||||
// event from the restart is suppressed — prevents overwriting editor content
|
// event from the restart is suppressed — prevents overwriting editor content
|
||||||
fileWatcher.start(data.filePath)
|
fileWatcher.start(data.filePath)
|
||||||
fileWatcher.setSelfWriting(false)
|
fileWatcher.setSelfWriting(false)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AppIcon, Gitee } from '../Icons'
|
import { AppIcon, Gitee } from '../Icons'
|
||||||
|
import { APP_VERSION } from '../../lib/constants'
|
||||||
const APP_VERSION = 'v0.3.6'
|
|
||||||
|
|
||||||
interface AboutDialogProps {
|
interface AboutDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export const ConfirmDialog = React.memo(function ConfirmDialog({
|
|||||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
previousFocusRef.current?.focus()
|
// Only restore focus if the element is still in the DOM
|
||||||
|
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
|
||||||
|
previousFocusRef.current.focus()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,16 +44,39 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
|||||||
if (!activeTab) return
|
if (!activeTab) return
|
||||||
if (activeTab.content !== currentContentRef.current) {
|
if (activeTab.content !== currentContentRef.current) {
|
||||||
currentContentRef.current = activeTab.content
|
currentContentRef.current = activeTab.content
|
||||||
|
// Queue setContent after Milkdown editor has finished async create()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
setContent(activeTab.content)
|
setContent(activeTab.content)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setScrollTop(activeTab.scrollTop)
|
setScrollTop(activeTab.scrollTop)
|
||||||
setSelection()
|
setSelection()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
|
// Save current tab state on unmount or tab switch
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!activeTabId) return
|
||||||
|
// Snapshot the current editor state before the new tab replaces content
|
||||||
|
const scrollPos = getScrollTop()
|
||||||
|
const sel = getSelection()
|
||||||
|
// Use a microtask to ensure we save before React re-renders the new tab content
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
updateTabScroll(activeTabId, {
|
||||||
|
scrollTop: scrollPos,
|
||||||
|
selectionStart: sel.from,
|
||||||
|
selectionEnd: sel.to
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTabId])
|
||||||
|
|
||||||
// Register getView for OutlinePanel navigation
|
// Register getView for OutlinePanel navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditorViewGetter(getView)
|
setEditorViewGetter(getView)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
return this.props.fallback
|
return this.props.fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDev = (typeof import.meta !== 'undefined' && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -51,6 +53,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||||
应用遇到了错误
|
应用遇到了错误
|
||||||
</h2>
|
</h2>
|
||||||
|
{isDev && (
|
||||||
<pre
|
<pre
|
||||||
style={{
|
style={{
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
@@ -64,6 +67,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
>
|
>
|
||||||
{this.state.error?.message}
|
{this.state.error?.message}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={this.handleReset}
|
onClick={this.handleReset}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface FileTreeProps {
|
|||||||
depth: number
|
depth: number
|
||||||
expandedDirs: string[]
|
expandedDirs: string[]
|
||||||
toggleDir: (path: string) => void
|
toggleDir: (path: string) => void
|
||||||
activeTabId: string | null
|
|
||||||
activeFilePath: string | null
|
activeFilePath: string | null
|
||||||
onFileClick: (path: string) => void
|
onFileClick: (path: string) => void
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,6 @@ export const FileTree = React.memo(function FileTree({
|
|||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
toggleDir={toggleDir}
|
toggleDir={toggleDir}
|
||||||
activeTabId={null}
|
|
||||||
activeFilePath={activeFilePath}
|
activeFilePath={activeFilePath}
|
||||||
onFileClick={onFileClick}
|
onFileClick={onFileClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { Heading } from '../OutlinePanel'
|
|||||||
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
||||||
import { TextSelection } from '@milkdown/prose/state'
|
import { TextSelection } from '@milkdown/prose/state'
|
||||||
|
|
||||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
||||||
|
|
||||||
export const Sidebar = React.memo(function Sidebar() {
|
export const Sidebar = React.memo(function Sidebar() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
@@ -138,7 +138,7 @@ export const Sidebar = React.memo(function Sidebar() {
|
|||||||
<FileTree
|
<FileTree
|
||||||
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
|
|||||||
}, [activeTabId, updateTabContent, setModified])
|
}, [activeTabId, updateTabContent, setModified])
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
// Tab 插入两个空格而非跳转焦点
|
// Tab 插入两个空格而非跳转焦点
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -39,6 +41,40 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
|
|||||||
setModified(activeTabId, true)
|
setModified(activeTabId, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+B: 粗体
|
||||||
|
if (isCtrl && e.key === 'b') {
|
||||||
|
e.preventDefault()
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (!textarea || !activeTabId) return
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const value = textarea.value
|
||||||
|
const selected = value.substring(start, end)
|
||||||
|
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
|
||||||
|
textarea.value = newValue
|
||||||
|
textarea.selectionStart = start + 2
|
||||||
|
textarea.selectionEnd = end + 2
|
||||||
|
updateTabContent(activeTabId, newValue)
|
||||||
|
setModified(activeTabId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+I: 斜体
|
||||||
|
if (isCtrl && e.key === 'i') {
|
||||||
|
e.preventDefault()
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (!textarea || !activeTabId) return
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const value = textarea.value
|
||||||
|
const selected = value.substring(start, end)
|
||||||
|
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
|
||||||
|
textarea.value = newValue
|
||||||
|
textarea.selectionStart = start + 1
|
||||||
|
textarea.selectionEnd = end + 1
|
||||||
|
updateTabContent(activeTabId, newValue)
|
||||||
|
setModified(activeTabId, true)
|
||||||
|
}
|
||||||
}, [activeTabId, updateTabContent, setModified])
|
}, [activeTabId, updateTabContent, setModified])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export const TabBar = React.memo(function TabBar() {
|
|||||||
role="menu"
|
role="menu"
|
||||||
aria-label="标签操作"
|
aria-label="标签操作"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||||
关闭
|
关闭
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export function useAutoExpandDir(activeFilePath: string | null) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeFilePath || !rootPath) return
|
if (!activeFilePath || !rootPath) return
|
||||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
||||||
if (!norm(activeFilePath).startsWith(norm(rootPath))) return
|
const normalizedRoot = norm(rootPath) + '/'
|
||||||
|
if (!norm(activeFilePath).startsWith(normalizedRoot)) return
|
||||||
|
|
||||||
const dirsToExpand: string[] = []
|
const dirsToExpand: string[] = []
|
||||||
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
|
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const AUTO_SAVE_DELAY = 2000
|
|||||||
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
|
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
|
||||||
* re-render on every keystroke — the effect is triggered once and runs
|
* re-render on every keystroke — the effect is triggered once and runs
|
||||||
* reactively via the store subscription.
|
* reactively via the store subscription.
|
||||||
|
*
|
||||||
|
* Captures the tabId at debounce start so the timeout always saves the
|
||||||
|
* correct tab even if the user switches tabs during the debounce window.
|
||||||
*/
|
*/
|
||||||
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
|
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
|
||||||
const [isAutoSaving, setIsAutoSaving] = useState(false)
|
const [isAutoSaving, setIsAutoSaving] = useState(false)
|
||||||
@@ -19,6 +22,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
|||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isSavingRef = useRef(false)
|
const isSavingRef = useRef(false)
|
||||||
const enabledRef = useRef(true)
|
const enabledRef = useRef(true)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
const toggleAutoSave = useCallback(() => {
|
const toggleAutoSave = useCallback(() => {
|
||||||
setAutoSaveEnabled(prev => {
|
setAutoSaveEnabled(prev => {
|
||||||
@@ -33,6 +37,8 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
// Subscribe to Zustand store — fires on every state change
|
// Subscribe to Zustand store — fires on every state change
|
||||||
const unsub = useTabStore.subscribe((state) => {
|
const unsub = useTabStore.subscribe((state) => {
|
||||||
if (!enabledRef.current) return
|
if (!enabledRef.current) return
|
||||||
@@ -40,6 +46,9 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
|||||||
const tab = state.getActiveTab()
|
const tab = state.getActiveTab()
|
||||||
if (!tab || !tab.filePath || !tab.isModified) return
|
if (!tab || !tab.filePath || !tab.isModified) return
|
||||||
|
|
||||||
|
// Capture the tabId so the timeout saves the correct tab
|
||||||
|
const tabIdToSave = tab.id
|
||||||
|
|
||||||
// Debounce: clear previous timer, start new one
|
// Debounce: clear previous timer, start new one
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
@@ -48,33 +57,34 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
|||||||
timerRef.current = setTimeout(async () => {
|
timerRef.current = setTimeout(async () => {
|
||||||
if (isSavingRef.current) return
|
if (isSavingRef.current) return
|
||||||
|
|
||||||
// Re-read latest state inside the timeout callback
|
// Re-read latest state; verify the captured tab still exists and is modified
|
||||||
const currentState = useTabStore.getState()
|
const currentState = useTabStore.getState()
|
||||||
const currentTab = currentState.getActiveTab()
|
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
|
||||||
if (!currentTab || !currentTab.filePath || !currentTab.isModified) return
|
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
|
||||||
|
|
||||||
isSavingRef.current = true
|
isSavingRef.current = true
|
||||||
setIsAutoSaving(true)
|
if (mountedRef.current) setIsAutoSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
const result = await window.electronAPI.saveFile({
|
const result = await window.electronAPI.saveFile({
|
||||||
filePath: currentTab.filePath,
|
filePath: tabToSave.filePath,
|
||||||
content: currentTab.content
|
content: tabToSave.content
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success && mountedRef.current) {
|
||||||
currentState.setModified(currentTab.id, false)
|
currentState.setModified(tabToSave.id, false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('自动保存失败', error)
|
logError('自动保存失败', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoSaving(false)
|
if (mountedRef.current) setIsAutoSaving(false)
|
||||||
isSavingRef.current = false
|
isSavingRef.current = false
|
||||||
}
|
}
|
||||||
}, AUTO_SAVE_DELAY)
|
}, AUTO_SAVE_DELAY)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
unsub()
|
unsub()
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export function useDragDrop(showToast: (msg: string) => void) {
|
|||||||
|
|
||||||
let rejected = 0
|
let rejected = 0
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
const filePath: string = (file as File & { path?: string }).path || file.name
|
// Electron adds a `path` property to File objects
|
||||||
|
const electronFile = file as File & { path?: string }
|
||||||
|
const filePath: string = electronFile.path || file.name
|
||||||
if (!isAllowedFile(filePath)) {
|
if (!isAllowedFile(filePath)) {
|
||||||
rejected++
|
rejected++
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import type { ToastItem, ToastType } from '../components/Toast/Toast'
|
import type { ToastItem, ToastType } from '../components/Toast/Toast'
|
||||||
|
|
||||||
/** 默认自动消失时间 (ms) */
|
/** 默认自动消失时间 (ms) */
|
||||||
@@ -7,16 +7,18 @@ const DEFAULT_DURATION = 3000
|
|||||||
/** 最大同时显示数量 */
|
/** 最大同时显示数量 */
|
||||||
const MAX_TOASTS = 5
|
const MAX_TOASTS = 5
|
||||||
|
|
||||||
|
// Module-level counter survives component remounts
|
||||||
|
let toastCounter = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UX-05: 升级版 Toast 状态管理 hook
|
* UX-05: 升级版 Toast 状态管理 hook
|
||||||
* 支持多条堆叠、类型区分、自动消失
|
* 支持多条堆叠、类型区分、自动消失
|
||||||
*/
|
*/
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
const counterRef = useRef(0)
|
|
||||||
|
|
||||||
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
|
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
|
||||||
const id = `toast-${++counterRef.current}`
|
const id = `toast-${++toastCounter}`
|
||||||
const newToast: ToastItem = { id, message: msg, type, duration }
|
const newToast: ToastItem = { id, message: msg, type, duration }
|
||||||
|
|
||||||
setToasts(prev => {
|
setToasts(prev => {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// 重新导出共享常量
|
// 重新导出共享常量
|
||||||
export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../../shared/constants'
|
export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS, APP_VERSION } from '../../shared/constants'
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
|
|
||||||
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
|
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
|
||||||
const sep: string = dir.includes('\\') ? '\\' : '/'
|
const sep: string = dir.includes('\\') ? '\\' : '/'
|
||||||
|
const normalizedDir = dir + sep
|
||||||
|
|
||||||
function visit(node: Element | Root): void {
|
function visit(node: Element | Root): void {
|
||||||
if (!node.children) return
|
if (!node.children) return
|
||||||
@@ -49,7 +50,13 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
}
|
}
|
||||||
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
||||||
const resolvedPath = resolveRelativePath(dir, src)
|
const resolvedPath = resolveRelativePath(dir, src)
|
||||||
if (!resolvedPath.startsWith(dir + sep)) return
|
const normalizedResolved = resolvedPath.includes('\\')
|
||||||
|
? resolvedPath.replace(/\\/g, '/') + '/'
|
||||||
|
: resolvedPath + '/'
|
||||||
|
const normalizedBase = normalizedDir.includes('\\')
|
||||||
|
? normalizedDir.replace(/\\/g, '/')
|
||||||
|
: normalizedDir
|
||||||
|
if (!normalizedResolved.startsWith(normalizedBase)) return
|
||||||
child.properties = {
|
child.properties = {
|
||||||
...child.properties,
|
...child.properties,
|
||||||
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
||||||
@@ -67,7 +74,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PF-01: Markdown处理器LRU缓存
|
// PF-01: Markdown处理器LRU缓存
|
||||||
const MAX_CACHE_SIZE = 10
|
const MAX_CACHE_SIZE = 20
|
||||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||||
|
|
||||||
function buildProcessor(filePath: string | null) {
|
function buildProcessor(filePath: string | null) {
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ interface SidebarState {
|
|||||||
toggleDir: (path: string) => void
|
toggleDir: (path: string) => void
|
||||||
expandDirs: (paths: string[]) => void
|
expandDirs: (paths: string[]) => void
|
||||||
setSidebarWidth: (width: number) => void
|
setSidebarWidth: (width: number) => void
|
||||||
/** @deprecated AR-04: 设置由 useSettingsInit 统一加载,此方法仅作兼容保留 */
|
|
||||||
loadFromDB: () => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSidebarStore = create<SidebarState>((set, get) => ({
|
export const useSidebarStore = create<SidebarState>((set) => ({
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
rootPath: null,
|
rootPath: null,
|
||||||
tree: [],
|
tree: [],
|
||||||
@@ -28,21 +26,6 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
|
|||||||
sidebarWidth: 240,
|
sidebarWidth: 240,
|
||||||
_loaded: false,
|
_loaded: false,
|
||||||
|
|
||||||
// AR-04: 设置由 useSettingsInit 统一加载
|
|
||||||
loadFromDB: async () => {
|
|
||||||
if (get()._loaded) return
|
|
||||||
try {
|
|
||||||
const settings = await settingsRepository.load()
|
|
||||||
set({
|
|
||||||
isVisible: !settings.sidebarCollapsed,
|
|
||||||
sidebarWidth: settings.sidebarWidth,
|
|
||||||
_loaded: true
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
set({ _loaded: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setVisible: (visible: boolean) => {
|
setVisible: (visible: boolean) => {
|
||||||
set({ isVisible: visible })
|
set({ isVisible: visible })
|
||||||
settingsRepository.save({ sidebarCollapsed: !visible })
|
settingsRepository.save({ sidebarCollapsed: !visible })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// 共享常量 — 主进程和渲染进程共用
|
// 共享常量 — 主进程和渲染进程共用
|
||||||
|
export const APP_VERSION = 'v0.3.7'
|
||||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||||
export const SKIP_DIRS = new Set([
|
export const SKIP_DIRS = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user