v0.3.3: 代码安全审查与Bug修复
CRITICAL 修复: - 路径遍历防护绕过:validatePath在normalize()之后检查..,可被绕过读取任意文件 - 编辑器异步创建竞态条件:editor.create()完成后组件已卸载导致内存泄漏 - editor.create() Promise缺少.catch() → 未捕获的Promise拒绝 - handleOpenRecent空文件被跳过:result.content truthy检查阻塞空文件打开 MAJOR 修复: - DIR_WATCH/TAB_SWITCHED IPC缺少路径校验,可被用于监视任意文件 - AboutDialog内联箭头函数使React.memo完全失效 - ConfirmDialog关闭时焦点恢复逻辑反转 - markdown.ts一刀切屏蔽..导致合法相对路径图片不显示 安全加固: - 移除未被使用的path.normalize导入 - 所有文件操作IPC使用validatePath统一校验 - 编辑器初始化添加cancelled标志防止卸载后设置实例 验证: TypeScript零错误, ESLint零错误零警告, 78/78测试通过
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||||
<img src="https://img.shields.io/badge/Version-v0.1.1-orange?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/Version-v0.3.2-orange?style=flat-square" alt="Version">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function openFileInTab(filePath: string): void {
|
|||||||
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console -- IPC file open error
|
||||||
console.error('openFileInTab failed:', err)
|
console.error('openFileInTab failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
|||||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||||
import { stat } from 'fs/promises'
|
import { stat } from 'fs/promises'
|
||||||
import { basename, normalize, isAbsolute } from 'path'
|
import { basename, isAbsolute } from 'path'
|
||||||
|
|
||||||
// 安全校验:拒绝路径遍历攻击
|
// 安全校验:拒绝路径遍历攻击
|
||||||
function validatePath(filePath: string): boolean {
|
function validatePath(filePath: string): boolean {
|
||||||
if (!filePath || typeof filePath !== 'string') return false
|
if (!filePath || typeof filePath !== 'string') return false
|
||||||
// 拒绝空字节
|
// 拒绝空字节
|
||||||
if (filePath.includes('\0')) return false
|
if (filePath.includes('\0')) return false
|
||||||
// 规范化路径后检查是否包含 ..
|
// 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
|
||||||
const normalized = normalize(filePath)
|
// 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失
|
||||||
if (normalized.includes('..')) return false
|
if (filePath.includes('..')) return false
|
||||||
// 必须是绝对路径
|
// 必须是绝对路径
|
||||||
if (!isAbsolute(normalized)) return false
|
if (!isAbsolute(filePath)) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +185,7 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
// 目录监听
|
// 目录监听
|
||||||
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
|
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||||
|
if (!validatePath(dirPath)) return
|
||||||
sidebarWatcher.start(dirPath)
|
sidebarWatcher.start(dirPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -195,6 +196,10 @@ export function registerIpcHandlers(
|
|||||||
// 标签切换
|
// 标签切换
|
||||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||||
state.activeFilePath = filePath || null
|
state.activeFilePath = filePath || null
|
||||||
|
if (filePath && !validatePath(filePath)) {
|
||||||
|
fileWatcher.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
fileWatcher.start(filePath || '')
|
fileWatcher.start(filePath || '')
|
||||||
const win = getMainWindow()
|
const win = getMainWindow()
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function App() {
|
|||||||
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
||||||
const { confirm, confirmDialogProps } = useConfirm()
|
const { confirm, confirmDialogProps } = useConfirm()
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const [showAbout, setShowAbout] = useState(false)
|
||||||
|
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||||
|
|
||||||
useSettingsInit()
|
useSettingsInit()
|
||||||
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
||||||
@@ -109,7 +110,7 @@ export function App() {
|
|||||||
<StatusBar />
|
<StatusBar />
|
||||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||||
<DropOverlay />
|
<DropOverlay />
|
||||||
{showAbout && <AboutDialog onClose={() => setShowAbout(false)} />}
|
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AppIcon, Gitee } from '../Icons'
|
import { AppIcon, Gitee } from '../Icons'
|
||||||
|
|
||||||
const APP_VERSION = 'v0.3.1'
|
const APP_VERSION = 'v0.3.2'
|
||||||
|
|
||||||
interface AboutDialogProps {
|
interface AboutDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface ConfirmDialogProps {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmDialog({
|
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -24,22 +24,18 @@ export function ConfirmDialog({
|
|||||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 保存焦点并在打开时聚焦确认按钮
|
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
previousFocusRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
previousFocusRef.current = document.activeElement as HTMLElement
|
previousFocusRef.current = document.activeElement as HTMLElement
|
||||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
// 关闭时恢复焦点
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) return
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previousFocusRef.current?.focus()
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
@@ -107,6 +103,6 @@ export function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface EditorProps {
|
|||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ darkMode }: EditorProps) {
|
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
const activeTab = useTabStore(s => s.getActiveTab())
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||||
@@ -37,7 +37,7 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
darkMode
|
darkMode
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load content when switching tabs
|
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab) return
|
if (!activeTab) return
|
||||||
setContent(activeTab.content)
|
setContent(activeTab.content)
|
||||||
@@ -45,10 +45,11 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
setScrollTop(activeTab.scrollTop)
|
setScrollTop(activeTab.scrollTop)
|
||||||
setSelection()
|
setSelection()
|
||||||
})
|
})
|
||||||
|
// 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
|
// Save current tab state on unmount or tab switch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId) return
|
||||||
@@ -58,6 +59,7 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
selectionEnd: getSelection().to
|
selectionEnd: getSelection().to
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
@@ -98,6 +100,6 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
<div ref={containerRef} className="milkdown-wrapper" />
|
<div ref={containerRef} className="milkdown-wrapper" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
Editor.displayName = 'Editor'
|
Editor.displayName = 'Editor'
|
||||||
|
|||||||
@@ -128,11 +128,22 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
|||||||
.use(trailing)
|
.use(trailing)
|
||||||
.use(clipboard)
|
.use(clipboard)
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
editor.create().then((created) => {
|
editor.create().then((created) => {
|
||||||
|
if (cancelled) {
|
||||||
|
// 组件已卸载,立即销毁以避免内存泄漏
|
||||||
|
created.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
editorRef.current = created
|
editorRef.current = created
|
||||||
|
}).catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||||
|
console.error('Milkdown editor creation failed:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true
|
||||||
editorRef.current?.destroy()
|
editorRef.current?.destroy()
|
||||||
editorRef.current = null
|
editorRef.current = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
|||||||
// PF-07: 防抖延迟常量
|
// PF-07: 防抖延迟常量
|
||||||
const PREVIEW_DEBOUNCE_MS = 150
|
const PREVIEW_DEBOUNCE_MS = 150
|
||||||
|
|
||||||
export function Preview() {
|
export const Preview = React.memo(function Preview() {
|
||||||
const [html, setHtml] = useState('')
|
const [html, setHtml] = useState('')
|
||||||
const [rendering, setRendering] = useState(false)
|
const [rendering, setRendering] = useState(false)
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -50,6 +50,7 @@ export function Preview() {
|
|||||||
clearTimeout(debounceTimerRef.current)
|
clearTimeout(debounceTimerRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||||
|
|
||||||
// 拦截链接点击
|
// 拦截链接点击
|
||||||
@@ -96,6 +97,6 @@ export function Preview() {
|
|||||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
Preview.displayName = 'Preview'
|
Preview.displayName = 'Preview'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
|||||||
|
|
||||||
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
||||||
|
|
||||||
export function Sidebar() {
|
export const Sidebar = React.memo(function Sidebar() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
const activeTab = useTabStore(s => s.getActiveTab())
|
||||||
@@ -103,6 +103,6 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
Sidebar.displayName = 'Sidebar'
|
Sidebar.displayName = 'Sidebar'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface ContextMenuState {
|
|||||||
tabId: string
|
tabId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabBar() {
|
export const TabBar = React.memo(function TabBar() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const switchToTab = useTabStore(s => s.switchToTab)
|
const switchToTab = useTabStore(s => s.switchToTab)
|
||||||
@@ -248,6 +248,6 @@ export function TabBar() {
|
|||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
TabBar.displayName = 'TabBar'
|
TabBar.displayName = 'TabBar'
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
setLoading('file-open', true)
|
setLoading('file-open', true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.readFile(filePath)
|
const result = await window.electronAPI.readFile(filePath)
|
||||||
if (result.success && result.content) {
|
if (result.success) {
|
||||||
createTab(filePath, result.content)
|
createTab(filePath, result.content)
|
||||||
recentFilesRepository.add(filePath)
|
recentFilesRepository.add(filePath)
|
||||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class AppError extends Error {
|
|||||||
/** 日志级别的错误处理(仅 console.error,不中断流程) */
|
/** 日志级别的错误处理(仅 console.error,不中断流程) */
|
||||||
export function logError(context: string, error: unknown): void {
|
export function logError(context: string, error: unknown): void {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
// eslint-disable-next-line no-console -- intentional logging utility
|
||||||
console.error(`[${context}] ${message}`, error)
|
console.error(`[${context}] ${message}`, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ import rehypeStringify from 'rehype-stringify'
|
|||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
import type { Element, Root } from 'hast'
|
import type { Element, Root } from 'hast'
|
||||||
|
|
||||||
|
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
||||||
|
function resolveRelativePath(base: string, rel: string): string {
|
||||||
|
const sep = base.includes('\\') ? '\\' : '/'
|
||||||
|
const parts = base.split(sep)
|
||||||
|
parts.pop() // 移除文件名
|
||||||
|
const relParts = rel.split('/')
|
||||||
|
for (const p of relParts) {
|
||||||
|
if (p === '.' || p === '') continue
|
||||||
|
if (p === '..') {
|
||||||
|
if (parts.length > 0) parts.pop()
|
||||||
|
} else {
|
||||||
|
parts.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(sep)
|
||||||
|
}
|
||||||
|
|
||||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||||
return () => (tree: Root) => {
|
return () => (tree: Root) => {
|
||||||
@@ -22,7 +39,9 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
if (child.type === 'element' && child.tagName === 'img') {
|
if (child.type === 'element' && child.tagName === 'img') {
|
||||||
const src = child.properties?.src as string | undefined
|
const src = child.properties?.src as string | undefined
|
||||||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
||||||
if (src.includes('..')) return
|
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
||||||
|
const resolvedPath = resolveRelativePath(dir, src)
|
||||||
|
if (!resolvedPath.startsWith(dir + sep)) return
|
||||||
child.properties = {
|
child.properties = {
|
||||||
...child.properties,
|
...child.properties,
|
||||||
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const useTabStore = create<TabState>((set, get) => {
|
|||||||
set({ _loaded: true })
|
set({ _loaded: true })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console -- DB load error
|
||||||
console.error('Failed to load tabs from DB:', err)
|
console.error('Failed to load tabs from DB:', err)
|
||||||
set({ _loaded: true })
|
set({ _loaded: true })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user