-
+

欢迎使用 MarkLite

一款轻量级的 Markdown 编辑器

-
- - @@ -40,15 +40,17 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp {recentFiles.length > 0 && (

最近打开

-
- {recentFiles.map(filePath => ( +
+ {recentFiles.map((filePath: string) => (
)} -
+

💡 提示:可以直接拖拽 .md 文件到窗口打开

⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+F 搜索 | Ctrl+1/2 视图

) -} +}) + +WelcomeScreen.displayName = 'WelcomeScreen' diff --git a/src/renderer/db/recentFilesRepository.ts b/src/renderer/db/recentFilesRepository.ts index bc2a881..6224c12 100644 --- a/src/renderer/db/recentFilesRepository.ts +++ b/src/renderer/db/recentFilesRepository.ts @@ -1,28 +1,28 @@ -import { db } from './schema' +import { db, type RecentFile } from './schema' export const recentFilesRepository = { async add(filePath: string): Promise { - const existing = await db.recentFiles.where('filePath').equals(filePath).first() + 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 = await db.recentFiles.orderBy('lastOpened').reverse().toArray() + 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 => f.id!)) + await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!)) } }, - async getAll(limit = 20): Promise { - const files = await db.recentFiles + async getAll(limit: number = 20): Promise { + const files: RecentFile[] = await db.recentFiles .orderBy('lastOpened') .reverse() .limit(limit) .toArray() - return files.map((f: { filePath: string; lastOpened: number }) => f.filePath) + return files.map((f: RecentFile) => f.filePath) }, async remove(filePath: string): Promise { diff --git a/src/renderer/db/settingsRepository.ts b/src/renderer/db/settingsRepository.ts index 4174d68..8cc33af 100644 --- a/src/renderer/db/settingsRepository.ts +++ b/src/renderer/db/settingsRepository.ts @@ -1,10 +1,11 @@ import { db, type SettingsRecord } from './schema' import { DEFAULT_SETTINGS, type Settings } from '../types/settings' +import { logError } from '../lib/errorHandler' export const settingsRepository = { async load(): Promise { try { - const record = await db.settings.get('default') + const record: SettingsRecord | undefined = await db.settings.get('default') if (record) { return { darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode, @@ -13,15 +14,15 @@ export const settingsRepository = { sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth } } - } catch { - // fallback + } catch (error) { + logError('加载设置失败', error) } return { ...DEFAULT_SETTINGS } }, // C-01: 先 load 再 merge 再 put,避免部分字段丢失 async save(partial: Partial): Promise { - const current = await this.load() + const current: Settings = await this.load() const merged: SettingsRecord = { id: 'default', ...current, ...partial } await db.settings.put(merged) } diff --git a/src/renderer/hooks/useAutoExpandDir.ts b/src/renderer/hooks/useAutoExpandDir.ts new file mode 100644 index 0000000..5a3f0b4 --- /dev/null +++ b/src/renderer/hooks/useAutoExpandDir.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react' +import { useSidebarStore } from '../stores/sidebarStore' + +/** + * AR-02: 自动展开到活动文件所在的目录 + */ +export function useAutoExpandDir(activeFilePath: string | null) { + const rootPath = useSidebarStore(s => s.rootPath) + const expandDirs = useSidebarStore(s => s.expandDirs) + + useEffect(() => { + if (!activeFilePath || !rootPath) return + const norm = (p: string) => p.replace(/\\/g, '/') + if (!norm(activeFilePath).startsWith(norm(rootPath))) return + + const dirsToExpand: string[] = [] + let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') + let prev = '' + while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) { + prev = dir + dirsToExpand.push(dir) + dir = dir.replace(/[/\\][^/\\]+$/, '') + } + if (dirsToExpand.length > 0) expandDirs(dirsToExpand) + }, [activeFilePath, rootPath, expandDirs]) +} diff --git a/src/renderer/hooks/useConfirm.ts b/src/renderer/hooks/useConfirm.ts new file mode 100644 index 0000000..d58bf4b --- /dev/null +++ b/src/renderer/hooks/useConfirm.ts @@ -0,0 +1,71 @@ +import { useState, useCallback, useRef } from 'react' + +interface ConfirmOptions { + title: string + message: string + confirmLabel?: string + cancelLabel?: string + variant?: 'danger' | 'warning' | 'info' +} + +interface ConfirmState extends ConfirmOptions { + open: boolean + resolve: ((value: boolean) => void) | null +} + +/** + * UX-01: Promise-based 确认对话框 hook + * 替代原生 confirm(),返回 Promise + */ +export function useConfirm() { + const [state, setState] = useState({ + open: false, + title: '', + message: '', + resolve: null + }) + + // 使用 ref 确保回调中能拿到最新的 resolve + const resolveRef = useRef<((value: boolean) => void) | null>(null) + + const confirm = useCallback((options: ConfirmOptions): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve + setState({ + open: true, + title: options.title, + message: options.message, + confirmLabel: options.confirmLabel, + cancelLabel: options.cancelLabel, + variant: options.variant, + resolve + }) + }) + }, []) + + const handleConfirm = useCallback(() => { + resolveRef.current?.(true) + setState(prev => ({ ...prev, open: false, resolve: null })) + resolveRef.current = null + }, []) + + const handleCancel = useCallback(() => { + resolveRef.current?.(false) + setState(prev => ({ ...prev, open: false, resolve: null })) + resolveRef.current = null + }, []) + + return { + confirm, + confirmDialogProps: { + open: state.open, + title: state.title, + message: state.message, + confirmLabel: state.confirmLabel, + cancelLabel: state.cancelLabel, + variant: state.variant, + onConfirm: handleConfirm, + onCancel: handleCancel + } + } +} diff --git a/src/renderer/hooks/useDragDrop.ts b/src/renderer/hooks/useDragDrop.ts index 1e2feb8..ff2a3eb 100644 --- a/src/renderer/hooks/useDragDrop.ts +++ b/src/renderer/hooks/useDragDrop.ts @@ -2,6 +2,7 @@ import { useEffect, useCallback } from 'react' import { useTabStore } from '../stores/tabStore' import { isAllowedFile } from '../lib/fileUtils' import { MAX_FILE_SIZE } from '../lib/constants' +import { logError } from '../lib/errorHandler' export function useDragDrop(showToast: (msg: string) => void) { const createTab = useTabStore(s => s.createTab) @@ -15,7 +16,7 @@ export function useDragDrop(showToast: (msg: string) => void) { let rejected = 0 for (const file of Array.from(files)) { - const filePath = (file as File & { path?: string }).path || file.name + const filePath: string = (file as File & { path?: string }).path || file.name if (!isAllowedFile(filePath)) { rejected++ continue @@ -31,12 +32,12 @@ export function useDragDrop(showToast: (msg: string) => void) { if (result.success && result.content !== undefined) { createTab(filePath, result.content) } - } catch (err) { - console.error('Failed to read file via electronAPI:', err) + } catch (error) { + logError('拖拽读取文件失败', error) } } else { const reader = new FileReader() - reader.onload = (ev) => { + reader.onload = (ev: ProgressEvent) => { const content = ev.target?.result as string createTab(file.name, content) } diff --git a/src/renderer/hooks/useFileOperations.ts b/src/renderer/hooks/useFileOperations.ts new file mode 100644 index 0000000..7a90951 --- /dev/null +++ b/src/renderer/hooks/useFileOperations.ts @@ -0,0 +1,84 @@ +import { useCallback } from 'react' +import { useTabStore } from '../stores/tabStore' +import { useEditorStore } from '../stores/editorStore' +import { recentFilesRepository } from '../db/recentFilesRepository' +import { logError } from '../lib/errorHandler' +import type { ToastType } from '../components/Toast/Toast' + +/** + * AR-01: 从 App.tsx 提取的文件操作逻辑 + * UX-02: 添加 loading 状态指示 + */ +export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) { + const createTab = useTabStore(s => s.createTab) + const getActiveTab = useTabStore(s => s.getActiveTab) + const setLoading = useEditorStore(s => s.setLoading) + + const handleOpenFile = useCallback(async (): Promise => { + try { + if (!window.electronAPI) return + setLoading('file-open', true) + const result = await window.electronAPI.openFile() + if (result && 'filePath' in result) { + createTab(result.filePath, result.content) + if (result.filePath) recentFilesRepository.add(result.filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) + } + } catch (error) { + logError('打开文件失败', error) + showToast('打开文件失败', 'error') + } finally { + setLoading('file-open', false) + } + }, [createTab, showToast, setLoading]) + + const handleSave = useCallback(async (): Promise => { + try { + const tab = getActiveTab() + if (!tab || !window.electronAPI) return + const result = await window.electronAPI.saveFile({ + filePath: tab.filePath, + content: tab.content + }) + if (result.success) { + useTabStore.getState().setModified(tab.id, false) + showToast('已保存', 'success') + } + } catch (error) { + logError('保存文件失败', error) + showToast('保存失败', 'error') + } + }, [getActiveTab, showToast]) + + const handleSaveAs = useCallback(async (): Promise => { + try { + const tab = getActiveTab() + if (!tab || !window.electronAPI) return + const result = await window.electronAPI.saveFileAs({ content: tab.content }) + if (result.success) { + useTabStore.getState().setModified(tab.id, false) + showToast('已保存', 'success') + } + } catch (error) { + logError('另存为失败', error) + showToast('另存为失败', 'error') + } + }, [getActiveTab, showToast]) + + const handleOpenRecent = useCallback(async (filePath: string): Promise => { + if (!window.electronAPI) return + setLoading('file-open', true) + try { + const result = await window.electronAPI.readFile(filePath) + if (result.success && result.content) { + createTab(filePath, result.content) + recentFilesRepository.add(filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) + } + } finally { + setLoading('file-open', false) + } + }, [createTab, setLoading]) + + return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } +} diff --git a/src/renderer/hooks/useFileWatch.ts b/src/renderer/hooks/useFileWatch.ts index 1ea36aa..dfa6b15 100644 --- a/src/renderer/hooks/useFileWatch.ts +++ b/src/renderer/hooks/useFileWatch.ts @@ -1,8 +1,14 @@ import { useEffect } from 'react' import { useTabStore } from '../stores/tabStore' +import { useEditorStore } from '../stores/editorStore' +/** + * AR-03: 文件外部修改检测 + * 使用 Zustand store 状态替代 DOM CustomEvent + */ export function useFileWatch() { const getActiveTab = useTabStore(s => s.getActiveTab) + const setExternallyModified = useEditorStore(s => s.setExternallyModified) useEffect(() => { if (!window.electronAPI) return @@ -10,10 +16,11 @@ export function useFileWatch() { const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => { const tab = getActiveTab() if (tab && tab.filePath === filePath) { - window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath })) + // AR-03: 直接更新 store 状态,不再派发 CustomEvent + setExternallyModified({ filePath }) } }) return unsubscribe - }, [getActiveTab]) + }, [getActiveTab, setExternallyModified]) } diff --git a/src/renderer/hooks/useFolderOperations.ts b/src/renderer/hooks/useFolderOperations.ts new file mode 100644 index 0000000..8cb77f0 --- /dev/null +++ b/src/renderer/hooks/useFolderOperations.ts @@ -0,0 +1,54 @@ +import { useEffect, useCallback } from 'react' +import { useSidebarStore } from '../stores/sidebarStore' +import { useEditorStore } from '../stores/editorStore' + +/** + * AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑 + * UX-02: 添加目录加载 loading 状态 + */ +export function useFolderOperations() { + const rootPath = useSidebarStore(s => s.rootPath) + const setRootPath = useSidebarStore(s => s.setRootPath) + const setTree = useSidebarStore(s => s.setTree) + const expandDirs = useSidebarStore(s => s.expandDirs) + const setLoading = useEditorStore(s => s.setLoading) + + const handleOpenFolder = useCallback(async () => { + if (!window.electronAPI) return + const result = await window.electronAPI.openFolderDialog() + if (!result.canceled && result.filePaths[0]) { + const dirPath = result.filePaths[0] + setRootPath(dirPath) + expandDirs([dirPath]) + setLoading('dir-load', true) + try { + const dirTree = await window.electronAPI.readDirTree(dirPath) + if (dirTree.success && dirTree.tree) { + setTree(dirTree.tree) + window.electronAPI.watchDir(dirPath) + } + } finally { + setLoading('dir-load', false) + } + } + }, [setRootPath, setTree, expandDirs, setLoading]) + + const refreshTree = useCallback(async () => { + if (!rootPath || !window.electronAPI) return + setLoading('dir-load', true) + try { + const result = await window.electronAPI.readDirTree(rootPath) + if (result.success && result.tree) setTree(result.tree) + } finally { + setLoading('dir-load', false) + } + }, [rootPath, setTree, setLoading]) + + useEffect(() => { + if (!window.electronAPI) return + const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree()) + return unsubscribe + }, [refreshTree]) + + return { handleOpenFolder } +} diff --git a/src/renderer/hooks/useIpcListeners.ts b/src/renderer/hooks/useIpcListeners.ts new file mode 100644 index 0000000..f77bb1c --- /dev/null +++ b/src/renderer/hooks/useIpcListeners.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react' +import { useTabStore } from '../stores/tabStore' +import { recentFilesRepository } from '../db/recentFilesRepository' + +/** + * 主进程事件注册 hook + * 处理菜单触发的文件打开、保存、另存为事件 + */ +export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) { + const createTab = useTabStore(s => s.createTab) + const handleSaveRef = useRef(handleSave) + const handleSaveAsRef = useRef(handleSaveAs) + handleSaveRef.current = handleSave + handleSaveAsRef.current = handleSaveAs + + useEffect(() => { + if (!window.electronAPI) return + const api = window.electronAPI + const onOpen = (data: { filePath: string; content: string }) => { + createTab(data.filePath, data.content) + if (data.filePath) recentFilesRepository.add(data.filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) + } + const unsub1 = api.onFileOpenInTab(onOpen) + const unsub2 = api.onMenuSave(() => handleSaveRef.current()) + const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current()) + return () => { unsub1(); unsub2(); unsub3() } + }, [createTab]) +} diff --git a/src/renderer/hooks/useKeyboard.ts b/src/renderer/hooks/useKeyboard.ts index 6829cad..bfd8569 100644 --- a/src/renderer/hooks/useKeyboard.ts +++ b/src/renderer/hooks/useKeyboard.ts @@ -1,7 +1,6 @@ import { useEffect, useCallback } from 'react' import { useTabStore } from '../stores/tabStore' import { useEditorStore } from '../stores/editorStore' -import type { ViewMode } from '../types/settings' export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) { const setViewMode = useEditorStore(s => s.setViewMode) diff --git a/src/renderer/hooks/useSettings.ts b/src/renderer/hooks/useSettings.ts index ef9f900..7ad545b 100644 --- a/src/renderer/hooks/useSettings.ts +++ b/src/renderer/hooks/useSettings.ts @@ -1,20 +1,17 @@ -import { useEffect, useCallback } from 'react' +import { useCallback } from 'react' import { useEditorStore } from '../stores/editorStore' import { settingsRepository } from '../db/settingsRepository' import type { ViewMode } from '../types/settings' +/** + * AR-04: 设置 hook + * 不再独立加载设置(由 useSettingsInit 统一加载) + * 仅负责视图模式的读取和保存 + */ export function useSettings() { const viewMode = useEditorStore(s => s.viewMode) const setViewMode = useEditorStore(s => s.setViewMode) - // 初始化 - useEffect(() => { - settingsRepository.load().then(settings => { - setViewMode(settings.viewMode ?? 'editor') - }).catch(err => console.error('Failed to load settings:', err)) - }, [setViewMode]) - - // M-10: 使用 useCallback 稳定函数引用 const saveViewMode = useCallback((mode: ViewMode) => { setViewMode(mode) settingsRepository.save({ viewMode: mode }) diff --git a/src/renderer/hooks/useSettingsInit.ts b/src/renderer/hooks/useSettingsInit.ts new file mode 100644 index 0000000..7a269c0 --- /dev/null +++ b/src/renderer/hooks/useSettingsInit.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react' +import { useEditorStore } from '../stores/editorStore' +import { useSidebarStore } from '../stores/sidebarStore' +import { settingsRepository } from '../db/settingsRepository' +import { logError } from '../lib/errorHandler' + +/** + * AR-04: 统一设置加载 hook + * 一次性从 IndexedDB 加载所有设置,分发到各 store + * 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式 + */ +export function useSettingsInit() { + const setDarkMode = useEditorStore(s => s.setDarkMode) + const setViewMode = useEditorStore(s => s.setViewMode) + const isInitialized = useRef(false) + + useEffect(() => { + if (isInitialized.current) return + isInitialized.current = true + + settingsRepository.load() + .then((settings) => { + // 主题 + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark + setDarkMode(darkMode) + document.documentElement.classList.toggle('dark', darkMode) + + // 视图模式 + setViewMode(settings.viewMode ?? 'editor') + + // Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB) + const sidebarStore = useSidebarStore.getState() + if (!sidebarStore._loaded) { + useSidebarStore.setState({ + isVisible: !settings.sidebarCollapsed, + sidebarWidth: settings.sidebarWidth, + _loaded: true + }) + } + }) + .catch((error: unknown) => { + logError('加载设置失败', error) + }) + }, [setDarkMode, setViewMode]) + + return { isInitialized } +} diff --git a/src/renderer/hooks/useSidebarResize.ts b/src/renderer/hooks/useSidebarResize.ts new file mode 100644 index 0000000..9cf6f7d --- /dev/null +++ b/src/renderer/hooks/useSidebarResize.ts @@ -0,0 +1,33 @@ +import { useState, useRef, useEffect } from 'react' + +/** + * AR-02: 从 Sidebar.tsx 提取的 resize 逻辑 + */ +export function useSidebarResize() { + const [isResizing, setIsResizing] = useState(false) + const sidebarRef = useRef(null) + + useEffect(() => { + if (!isResizing) return + + const handleMouseMove = (e: MouseEvent): void => { + if (sidebarRef.current) { + const newWidth = Math.max(180, Math.min(500, e.clientX)) + sidebarRef.current.style.width = newWidth + 'px' + } + } + + const handleMouseUp = (): void => setIsResizing(false) + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, [isResizing]) + + const startResize = (): void => setIsResizing(true) + + return { sidebarRef, isResizing, startResize } +} diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts index dad55ab..5255a9b 100644 --- a/src/renderer/hooks/useTheme.ts +++ b/src/renderer/hooks/useTheme.ts @@ -1,29 +1,21 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { useEditorStore } from '../stores/editorStore' import { settingsRepository } from '../db/settingsRepository' +/** + * AR-04: 主题 hook + * 不再独立加载设置(由 useSettingsInit 统一加载) + * 仅负责主题切换时的同步和保存 + */ export function useTheme() { const darkMode = useEditorStore(s => s.darkMode) - const setDarkMode = useEditorStore(s => s.setDarkMode) - const isInitialized = useRef(false) + const toggleDarkMode = useEditorStore(s => s.toggleDarkMode) - // B-05: 从 IndexedDB 加载主题设置 + // darkMode 变化时同步 class 并保存 useEffect(() => { - settingsRepository.load().then(settings => { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches - setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark) - isInitialized.current = true - }).catch(err => console.error('Failed to load theme settings:', err)) - }, [setDarkMode]) - - // B-05: 首次加载完成后才保存主题设置 - useEffect(() => { - if (!isInitialized.current) return document.documentElement.classList.toggle('dark', darkMode) settingsRepository.save({ darkMode }) }, [darkMode]) - const toggleDarkMode = useEditorStore(s => s.toggleDarkMode) - return { darkMode, toggleDarkMode } } diff --git a/src/renderer/hooks/useToast.ts b/src/renderer/hooks/useToast.ts new file mode 100644 index 0000000..d854fb3 --- /dev/null +++ b/src/renderer/hooks/useToast.ts @@ -0,0 +1,43 @@ +import { useState, useCallback, useRef } from 'react' +import type { ToastItem, ToastType } from '../components/Toast/Toast' + +/** 默认自动消失时间 (ms) */ +const DEFAULT_DURATION = 3000 + +/** 最大同时显示数量 */ +const MAX_TOASTS = 5 + +/** + * UX-05: 升级版 Toast 状态管理 hook + * 支持多条堆叠、类型区分、自动消失 + */ +export function useToast() { + const [toasts, setToasts] = useState([]) + const counterRef = useRef(0) + + const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => { + const id = `toast-${++counterRef.current}` + const newToast: ToastItem = { id, message: msg, type, duration } + + setToasts(prev => { + const next = [...prev, newToast] + // 超过最大数量时移除最旧的 + if (next.length > MAX_TOASTS) { + return next.slice(next.length - MAX_TOASTS) + } + return next + }) + + return id + }, []) + + const dismissToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + const cleanup = useCallback(() => { + setToasts([]) + }, []) + + return { toasts, showToast, dismissToast, cleanup } +} diff --git a/src/renderer/hooks/useUnsavedWarning.ts b/src/renderer/hooks/useUnsavedWarning.ts index c51b171..fe5477e 100644 --- a/src/renderer/hooks/useUnsavedWarning.ts +++ b/src/renderer/hooks/useUnsavedWarning.ts @@ -1,6 +1,24 @@ -import { useEffect } from 'react' +import { useEffect, useCallback, useRef } from 'react' + +/** + * 未保存提醒 hook + * UX-01: 接受外部 confirm 函数,替代原生 confirm() + */ +export function useUnsavedWarning( + hasUnsaved: () => boolean, + confirmFn?: (message: string) => Promise +) { + const confirmFnRef = useRef(confirmFn) + confirmFnRef.current = confirmFn + + const doConfirm = useCallback(async (message: string): Promise => { + if (confirmFnRef.current) { + return confirmFnRef.current(message) + } + // 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为 + return true + }, []) -export function useUnsavedWarning(hasUnsaved: () => boolean) { useEffect(() => { if (!window.electronAPI) { const handler = (e: BeforeUnloadEvent) => { @@ -15,12 +33,12 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) { const api = window.electronAPI - const unsubscribe = api.onConfirmClose(() => { + const unsubscribe = api.onConfirmClose(async () => { if (!hasUnsaved()) { api.forceClose() return } - const shouldClose = confirm('有文件尚未保存,确定要关闭吗?') + const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?') if (shouldClose) { api.forceClose() } else { @@ -29,5 +47,5 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) { }) return unsubscribe - }, [hasUnsaved]) + }, [hasUnsaved, doConfirm]) } diff --git a/src/renderer/lib/__tests__/errorHandler.test.ts b/src/renderer/lib/__tests__/errorHandler.test.ts new file mode 100644 index 0000000..8cedb3c --- /dev/null +++ b/src/renderer/lib/__tests__/errorHandler.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest' +import { AppError, logError, getErrorMessage, tryAsync } from '../errorHandler' + +describe('errorHandler', () => { + describe('AppError', () => { + it('should create an error with code and message', () => { + const error = new AppError('Test error', 'TEST_CODE') + expect(error.message).toBe('Test error') + expect(error.code).toBe('TEST_CODE') + expect(error.name).toBe('AppError') + expect(error).toBeInstanceOf(Error) + }) + + it('should store cause', () => { + const cause = new Error('root cause') + const error = new AppError('Test error', 'TEST_CODE', cause) + expect(error.cause).toBe(cause) + }) + }) + + describe('logError', () => { + it('should log error with context', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const error = new Error('test error') + + logError('TestContext', error) + + expect(consoleSpy).toHaveBeenCalledWith( + '[TestContext] test error', + error + ) + consoleSpy.mockRestore() + }) + + it('should handle non-Error values', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + logError('TestContext', 'string error') + + expect(consoleSpy).toHaveBeenCalledWith( + '[TestContext] string error', + 'string error' + ) + consoleSpy.mockRestore() + }) + }) + + describe('getErrorMessage', () => { + it('should return message for AppError', () => { + const error = new AppError('Custom error', 'CODE') + expect(getErrorMessage(error, 'Fallback')).toBe('Custom error') + }) + + it('should return fallback + message for standard Error', () => { + const error = new Error('details') + expect(getErrorMessage(error, 'Fallback')).toBe('Fallback: details') + }) + + it('should return fallback for non-Error values', () => { + expect(getErrorMessage('unknown', 'Fallback')).toBe('Fallback') + }) + + it('should return fallback for null', () => { + expect(getErrorMessage(null, 'Fallback')).toBe('Fallback') + }) + }) + + describe('tryAsync', () => { + it('should return result on success', async () => { + const [result, error] = await tryAsync(async () => 42, 'Test') + expect(result).toBe(42) + expect(error).toBeNull() + }) + + it('should return AppError on failure', async () => { + const [result, error] = await tryAsync( + async () => { throw new Error('fail') }, + 'TestContext' + ) + expect(result).toBeNull() + expect(error).toBeInstanceOf(AppError) + expect(error?.message).toBe('TestContext: fail') + expect(error?.code).toBe('TestContext') + }) + + it('should handle non-Error thrown values', async () => { + const [result, error] = await tryAsync( + async () => { throw 'string error' }, + 'TestContext' + ) + expect(result).toBeNull() + expect(error).toBeInstanceOf(AppError) + expect(error?.message).toBe('TestContext: string error') + }) + + it('should preserve AppError cause', async () => { + const originalError = new Error('original') + const [, error] = await tryAsync( + async () => { throw originalError }, + 'Ctx' + ) + expect(error?.cause).toBe(originalError) + }) + }) +}) diff --git a/src/renderer/lib/__tests__/fileUtils.test.ts b/src/renderer/lib/__tests__/fileUtils.test.ts new file mode 100644 index 0000000..0836033 --- /dev/null +++ b/src/renderer/lib/__tests__/fileUtils.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils' + +describe('fileUtils', () => { + describe('getFileName', () => { + it('should extract filename from Unix path', () => { + expect(getFileName('/home/user/documents/file.md')).toBe('file.md') + }) + + it('should extract filename from Windows path', () => { + expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md') + }) + + it('should return the input if it is already a filename', () => { + expect(getFileName('file.md')).toBe('file.md') + }) + + it('should handle path with trailing slash', () => { + // getFileName uses pop() || filePath, empty string is falsy so returns filePath + expect(getFileName('/path/to/dir/')).toBe('/path/to/dir/') + }) + + it('should handle mixed path separators', () => { + expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md') + }) + }) + + describe('getFileExtension', () => { + it('should return lowercase extension', () => { + expect(getFileExtension('file.MD')).toBe('.md') + }) + + it('should return .markdown extension', () => { + expect(getFileExtension('file.markdown')).toBe('.markdown') + }) + + it('should return .txt extension', () => { + expect(getFileExtension('notes.txt')).toBe('.txt') + }) + + it('should return empty string for no extension', () => { + expect(getFileExtension('Makefile')).toBe('') + }) + + it('should return empty string for hidden files starting with dot', () => { + expect(getFileExtension('.gitignore')).toBe('') + }) + + it('should extract extension from full path', () => { + expect(getFileExtension('/home/user/file.md')).toBe('.md') + }) + + it('should extract extension from Windows path', () => { + expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown') + }) + + it('should handle file with multiple dots', () => { + expect(getFileExtension('my.file.md')).toBe('.md') + }) + }) + + describe('isAllowedFile', () => { + it('should return true for .md files', () => { + expect(isAllowedFile('file.md')).toBe(true) + }) + + it('should return true for .markdown files', () => { + expect(isAllowedFile('file.markdown')).toBe(true) + }) + + it('should return true for .txt files', () => { + expect(isAllowedFile('file.txt')).toBe(true) + }) + + it('should return true for uppercase extensions', () => { + expect(isAllowedFile('file.MD')).toBe(true) + }) + + it('should return false for .js files', () => { + expect(isAllowedFile('file.js')).toBe(false) + }) + + it('should return false for .json files', () => { + expect(isAllowedFile('file.json')).toBe(false) + }) + + it('should return false for files without extension', () => { + expect(isAllowedFile('Makefile')).toBe(false) + }) + + it('should work with full paths', () => { + expect(isAllowedFile('/home/user/doc.md')).toBe(true) + expect(isAllowedFile('/home/user/script.py')).toBe(false) + }) + }) +}) diff --git a/src/renderer/lib/__tests__/markdown.test.ts b/src/renderer/lib/__tests__/markdown.test.ts new file mode 100644 index 0000000..d68cec4 --- /dev/null +++ b/src/renderer/lib/__tests__/markdown.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { renderMarkdown } from '../markdown' + +describe('renderMarkdown', () => { + it('should render a simple heading', async () => { + const result = await renderMarkdown('# Hello World') + expect(result).toContain(' { + const result = await renderMarkdown('**bold**') + expect(result).toContain(' { + const result = await renderMarkdown('*italic*') + expect(result).toContain(' { + const result = await renderMarkdown('`code`') + expect(result).toContain(' { + const md = '```javascript\nconst x = 1;\n```' + const result = await renderMarkdown(md) + expect(result).toContain(' { + const result = await renderMarkdown('[link](https://example.com)') + expect(result).toContain(' { + const result = await renderMarkdown('- item 1\n- item 2') + expect(result).toContain(' { + const result = await renderMarkdown('1. first\n2. second') + expect(result).toContain(' { + const result = await renderMarkdown('> quote') + expect(result).toContain(' { + const md = '| A | B |\n|---|---|\n| 1 | 2 |' + const result = await renderMarkdown(md) + expect(result).toContain(' { + const result = await renderMarkdown('~~deleted~~') + expect(result).toContain(' { + const result = await renderMarkdown('') + expect(result).toBe('') + }) + + it('should return error HTML on rendering failure with invalid input', async () => { + // unified should still handle gracefully, but verify error path works + const result = await renderMarkdown('normal text') + expect(result).not.toContain('渲染错误') + }) + + it('should cache processors for same filePath', async () => { + // Call twice with same filePath to test caching + const result1 = await renderMarkdown('# Test', '/test/file.md') + const result2 = await renderMarkdown('# Test 2', '/test/file.md') + expect(result1).toContain(' { + const result = await renderMarkdown('# No File', null) + expect(result).toContain('No File') + }) + + it('should handle undefined filePath', async () => { + const result = await renderMarkdown('# No File') + expect(result).toContain('No File') + }) +}) diff --git a/src/renderer/lib/errorHandler.ts b/src/renderer/lib/errorHandler.ts new file mode 100644 index 0000000..6eca08d --- /dev/null +++ b/src/renderer/lib/errorHandler.ts @@ -0,0 +1,42 @@ +/** + * CQ-05: 统一错误处理模块 + * 提供一致的错误处理模式,替换分散的 try-catch + */ + +export class AppError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly cause?: unknown + ) { + super(message) + this.name = 'AppError' + } +} + +/** 日志级别的错误处理(仅 console.error,不中断流程) */ +export function logError(context: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + console.error(`[${context}] ${message}`, error) +} + +/** 用户操作级错误处理(返回友好的错误信息给 showToast) */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof AppError) return error.message + if (error instanceof Error) return `${fallback}: ${error.message}` + return fallback +} + +/** 通用 try-catch 包装器,返回 [result, error] */ +export async function tryAsync( + fn: () => Promise, + context: string +): Promise<[T | null, null] | [null, AppError]> { + try { + const result = await fn() + return [result, null] + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return [null, new AppError(`${context}: ${message}`, context, error)] + } +} diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index eb5b5e9..13c6a86 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -13,9 +13,8 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { return () => (tree: Root) => { if (!filePath) return - // 获取文件所在目录 - const dir = filePath.replace(/[/\\][^/\\]+$/, '') - const sep = dir.includes('\\') ? '\\' : '/' + const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') + const sep: string = dir.includes('\\') ? '\\' : '/' function visit(node: Element | Root): void { if (!node.children) return @@ -23,9 +22,7 @@ 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, src: 'file://' + (dir + sep + src).replace(/\\/g, '/') @@ -42,27 +39,58 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { } } +// PF-01: Markdown处理器LRU缓存 +const MAX_CACHE_SIZE = 10 +const processorCache = new Map>() + +function buildProcessor(filePath: string | null) { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeRaw) + .use(rehypeSanitize, { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + img: [...(defaultSchema.attributes?.img ?? []), ['src']], + } + }) + .use(rehypeFixImages(filePath ?? null)) + .use(rehypeHighlight) + .use(rehypeStringify) +} + +function getCachedProcessor(filePath: string | null): ReturnType { + const key: string = filePath ?? '__null__' + + if (processorCache.has(key)) { + const cached = processorCache.get(key)! + processorCache.delete(key) + processorCache.set(key, cached) + return cached + } + + const processor = buildProcessor(filePath) + + if (processorCache.size >= MAX_CACHE_SIZE) { + const oldestKey = processorCache.keys().next().value + if (oldestKey !== undefined) { + processorCache.delete(oldestKey) + } + } + + processorCache.set(key, processor) + return processor +} + export async function renderMarkdown(content: string, filePath?: string | null): Promise { try { - const processor = unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeRaw) - .use(rehypeSanitize, { - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - img: [...(defaultSchema.attributes?.img ?? []), ['src']], - } - }) - .use(rehypeFixImages(filePath ?? null)) - .use(rehypeHighlight) - .use(rehypeStringify) - + const processor = getCachedProcessor(filePath ?? null) const result = await processor.process(content) return String(result) } catch (e) { - return `

渲染错误: ${e instanceof Error ? e.message : String(e)}

` + const errorMsg: string = e instanceof Error ? e.message : String(e) + return `

渲染错误: ${errorMsg}

` } } diff --git a/src/renderer/stores/__tests__/editorStore.test.ts b/src/renderer/stores/__tests__/editorStore.test.ts new file mode 100644 index 0000000..eb3828f --- /dev/null +++ b/src/renderer/stores/__tests__/editorStore.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useEditorStore } from '../editorStore' + +describe('editorStore', () => { + beforeEach(() => { + useEditorStore.setState({ + viewMode: 'editor', + darkMode: false, + externallyModified: null, + loadingStates: {}, + }) + }) + + describe('setViewMode', () => { + it('should set view mode to preview', () => { + useEditorStore.getState().setViewMode('preview') + expect(useEditorStore.getState().viewMode).toBe('preview') + }) + + it('should set view mode to editor', () => { + useEditorStore.setState({ viewMode: 'preview' }) + useEditorStore.getState().setViewMode('editor') + expect(useEditorStore.getState().viewMode).toBe('editor') + }) + }) + + describe('setDarkMode', () => { + it('should enable dark mode', () => { + useEditorStore.getState().setDarkMode(true) + expect(useEditorStore.getState().darkMode).toBe(true) + }) + + it('should disable dark mode', () => { + useEditorStore.setState({ darkMode: true }) + useEditorStore.getState().setDarkMode(false) + expect(useEditorStore.getState().darkMode).toBe(false) + }) + }) + + describe('toggleDarkMode', () => { + it('should toggle from false to true', () => { + useEditorStore.getState().toggleDarkMode() + expect(useEditorStore.getState().darkMode).toBe(true) + }) + + it('should toggle from true to false', () => { + useEditorStore.setState({ darkMode: true }) + useEditorStore.getState().toggleDarkMode() + expect(useEditorStore.getState().darkMode).toBe(false) + }) + }) + + describe('setExternallyModified', () => { + it('should set externally modified info', () => { + useEditorStore.getState().setExternallyModified({ filePath: '/test.md' }) + expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' }) + }) + + it('should clear externally modified info', () => { + useEditorStore.setState({ externallyModified: { filePath: '/test.md' } }) + useEditorStore.getState().setExternallyModified(null) + expect(useEditorStore.getState().externallyModified).toBeNull() + }) + }) + + describe('loading states', () => { + it('should set loading state', () => { + useEditorStore.getState().setLoading('file-open', true) + expect(useEditorStore.getState().isLoading('file-open')).toBe(true) + }) + + it('should return false for unset loading key', () => { + expect(useEditorStore.getState().isLoading('non-existent')).toBe(false) + }) + + it('should update loading state to false', () => { + useEditorStore.getState().setLoading('file-open', true) + useEditorStore.getState().setLoading('file-open', false) + expect(useEditorStore.getState().isLoading('file-open')).toBe(false) + }) + + it('should handle multiple independent loading states', () => { + const { setLoading } = useEditorStore.getState() + setLoading('file-open', true) + setLoading('markdown-render', true) + + const state = useEditorStore.getState() + expect(state.isLoading('file-open')).toBe(true) + expect(state.isLoading('markdown-render')).toBe(true) + + setLoading('file-open', false) + expect(useEditorStore.getState().isLoading('file-open')).toBe(false) + expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true) + }) + }) +}) diff --git a/src/renderer/stores/__tests__/tabStore.test.ts b/src/renderer/stores/__tests__/tabStore.test.ts new file mode 100644 index 0000000..d83e673 --- /dev/null +++ b/src/renderer/stores/__tests__/tabStore.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock external dependencies before importing store +vi.mock('../../db/tabRepository', () => ({ + tabRepository: { + loadAll: vi.fn().mockResolvedValue([]), + loadActiveTabId: vi.fn().mockResolvedValue(null), + saveAll: vi.fn().mockResolvedValue(undefined), + saveActiveTabId: vi.fn().mockResolvedValue(undefined), + clearAll: vi.fn().mockResolvedValue(undefined), + }, +})) + +vi.mock('nanoid', () => { + let counter = 0 + return { + nanoid: vi.fn(() => `test-id-${++counter}`), + } +}) + +import { useTabStore } from '../tabStore' + +describe('tabStore', () => { + beforeEach(() => { + // Reset store state between tests + useTabStore.setState({ + tabs: [], + activeTabId: null, + mruStack: [], + _loaded: false, + }) + }) + + describe('createTab', () => { + it('should create a new tab with default values', () => { + const tab = useTabStore.getState().createTab() + const state = useTabStore.getState() + + expect(tab.id).toBeTruthy() + expect(tab.filePath).toBeNull() + expect(tab.content).toBe('') + expect(tab.isModified).toBe(false) + expect(state.tabs).toHaveLength(1) + expect(state.activeTabId).toBe(tab.id) + }) + + it('should create a tab with file path and content', () => { + const tab = useTabStore.getState().createTab('/test.md', '# Hello') + + expect(tab.filePath).toBe('/test.md') + expect(tab.content).toBe('# Hello') + }) + + it('should switch to existing tab if same filePath is opened', () => { + const { createTab } = useTabStore.getState() + createTab('/test.md', '# First') + + // Create another tab to make it active + createTab('/other.md', '# Other') + + // Now try to open the first file again + const existing = createTab('/test.md', '# Updated') + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(2) + expect(state.activeTabId).toBe(existing.id) + }) + }) + + describe('closeTab', () => { + it('should close a tab and update activeTabId', () => { + const { createTab, closeTab } = useTabStore.getState() + const tab1 = createTab('/file1.md') + const tab2 = createTab('/file2.md') + + closeTab(tab1.id) + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].id).toBe(tab2.id) + expect(state.activeTabId).toBe(tab2.id) + }) + + it('should set activeTabId to null when closing last tab', () => { + const { createTab, closeTab } = useTabStore.getState() + const tab = createTab('/file1.md') + + closeTab(tab.id) + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(0) + expect(state.activeTabId).toBeNull() + }) + + it('should handle closing non-existent tab gracefully', () => { + const { createTab, closeTab } = useTabStore.getState() + createTab('/file1.md') + + closeTab('non-existent') + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(1) + }) + }) + + describe('closeOtherTabs', () => { + it('should close all tabs except the specified one', () => { + const { createTab, closeOtherTabs } = useTabStore.getState() + const tab1 = createTab('/file1.md') + createTab('/file2.md') + createTab('/file3.md') + + closeOtherTabs(tab1.id) + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].id).toBe(tab1.id) + expect(state.activeTabId).toBe(tab1.id) + }) + }) + + describe('closeAllTabs', () => { + it('should close all tabs', () => { + const { createTab, closeAllTabs } = useTabStore.getState() + createTab('/file1.md') + createTab('/file2.md') + + closeAllTabs() + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(0) + expect(state.activeTabId).toBeNull() + expect(state.mruStack).toHaveLength(0) + }) + }) + + describe('closeTabsToRight', () => { + it('should close tabs to the right of the specified tab', () => { + const { createTab, closeTabsToRight } = useTabStore.getState() + const tab1 = createTab('/file1.md') + createTab('/file2.md') + createTab('/file3.md') + + closeTabsToRight(tab1.id) + const state = useTabStore.getState() + + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].id).toBe(tab1.id) + }) + + it('should keep active tab if it is in the remaining set', () => { + const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState() + const tab1 = createTab('/file1.md') + createTab('/file2.md') + createTab('/file3.md') + + switchToTab(tab1.id) + closeTabsToRight(tab1.id) + const state = useTabStore.getState() + + expect(state.activeTabId).toBe(tab1.id) + }) + }) + + describe('switchToTab', () => { + it('should switch active tab and update MRU stack', () => { + const { createTab, switchToTab } = useTabStore.getState() + const tab1 = createTab('/file1.md') + const tab2 = createTab('/file2.md') + + switchToTab(tab1.id) + const state = useTabStore.getState() + + expect(state.activeTabId).toBe(tab1.id) + expect(state.mruStack).toContain(tab2.id) + }) + + it('should not update if switching to already active tab', () => { + const { createTab, switchToTab } = useTabStore.getState() + createTab('/file1.md') + + const stateBefore = useTabStore.getState() + switchToTab(stateBefore.activeTabId!) + const stateAfter = useTabStore.getState() + + expect(stateAfter.mruStack).toEqual(stateBefore.mruStack) + }) + }) + + describe('updateTabContent', () => { + it('should update content and mark as modified', () => { + const { createTab, updateTabContent } = useTabStore.getState() + const tab = createTab('/file.md', '# Old') + + updateTabContent(tab.id, '# New Content') + const state = useTabStore.getState() + const updated = state.tabs.find(t => t.id === tab.id)! + + expect(updated.content).toBe('# New Content') + expect(updated.isModified).toBe(true) + }) + }) + + describe('getActiveTab', () => { + it('should return the active tab', () => { + const { createTab, getActiveTab } = useTabStore.getState() + const tab = createTab('/file.md') + + const active = getActiveTab() + expect(active?.id).toBe(tab.id) + }) + + it('should return null when no tabs exist', () => { + const { getActiveTab } = useTabStore.getState() + expect(getActiveTab()).toBeNull() + }) + }) + + describe('setModified', () => { + it('should set modified flag on a tab', () => { + const { createTab, setModified } = useTabStore.getState() + const tab = createTab('/file.md') + + setModified(tab.id, true) + const state = useTabStore.getState() + const updated = state.tabs.find(t => t.id === tab.id)! + + expect(updated.isModified).toBe(true) + + setModified(tab.id, false) + const state2 = useTabStore.getState() + const updated2 = state2.tabs.find(t => t.id === tab.id)! + + expect(updated2.isModified).toBe(false) + }) + }) + + describe('updateTabScroll', () => { + it('should update scroll position', () => { + const { createTab, updateTabScroll } = useTabStore.getState() + const tab = createTab('/file.md') + + updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 }) + const state = useTabStore.getState() + const updated = state.tabs.find(t => t.id === tab.id)! + + expect(updated.scrollTop).toBe(100) + expect(updated.selectionStart).toBe(10) + expect(updated.selectionEnd).toBe(20) + }) + }) +}) diff --git a/src/renderer/stores/editorStore.ts b/src/renderer/stores/editorStore.ts index 78ee5b3..da51f61 100644 --- a/src/renderer/stores/editorStore.ts +++ b/src/renderer/stores/editorStore.ts @@ -4,17 +4,32 @@ import type { ViewMode } from '../types/settings' interface EditorState { viewMode: ViewMode darkMode: boolean + // AR-03: 外部修改检测状态,替代 DOM CustomEvent + externallyModified: { filePath: string } | null + // UX-02: 全局加载状态 + loadingStates: Record setViewMode: (mode: ViewMode) => void setDarkMode: (dark: boolean) => void toggleDarkMode: () => void + setExternallyModified: (info: { filePath: string } | null) => void + // UX-02: 加载状态管理 + setLoading: (key: string, loading: boolean) => void + isLoading: (key: string) => boolean } -export const useEditorStore = create((set) => ({ +export const useEditorStore = create((set, get) => ({ viewMode: 'editor', darkMode: false, + externallyModified: null, + loadingStates: {}, - setViewMode: (mode) => set({ viewMode: mode }), - setDarkMode: (dark) => set({ darkMode: dark }), - toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })) + setViewMode: (mode: ViewMode) => set({ viewMode: mode }), + setDarkMode: (dark: boolean) => set({ darkMode: dark }), + toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })), + setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }), + setLoading: (key: string, loading: boolean) => set(state => ({ + loadingStates: { ...state.loadingStates, [key]: loading } + })), + isLoading: (key: string) => get().loadingStates[key] ?? false })) diff --git a/src/renderer/stores/sidebarStore.ts b/src/renderer/stores/sidebarStore.ts index 656fbb1..0f7f42a 100644 --- a/src/renderer/stores/sidebarStore.ts +++ b/src/renderer/stores/sidebarStore.ts @@ -16,6 +16,7 @@ interface SidebarState { toggleDir: (path: string) => void expandDirs: (paths: string[]) => void setSidebarWidth: (width: number) => void + /** @deprecated AR-04: 设置由 useSettingsInit 统一加载,此方法仅作兼容保留 */ loadFromDB: () => Promise } @@ -27,7 +28,7 @@ export const useSidebarStore = create((set, get) => ({ sidebarWidth: 240, _loaded: false, - // 从 IndexedDB 加载 sidebar 设置 + // AR-04: 设置由 useSettingsInit 统一加载 loadFromDB: async () => { if (get()._loaded) return try { @@ -42,29 +43,27 @@ export const useSidebarStore = create((set, get) => ({ } }, - setVisible: (visible) => { + setVisible: (visible: boolean) => { set({ isVisible: visible }) - // 异步保存到 DB settingsRepository.save({ sidebarCollapsed: !visible }) }, - setRootPath: (path) => set({ rootPath: path }), - setTree: (tree) => set({ tree }), - toggleDir: (path) => + setRootPath: (path: string | null) => set({ rootPath: path }), + setTree: (tree: FileNode[]) => set({ tree }), + toggleDir: (path: string) => set(state => ({ expandedDirs: state.expandedDirs.includes(path) ? state.expandedDirs.filter(p => p !== path) : [...state.expandedDirs, path] })), - expandDirs: (paths) => + expandDirs: (paths: string[]) => set(state => { const newDirs = paths.filter(p => !state.expandedDirs.includes(p)) return newDirs.length > 0 ? { expandedDirs: [...state.expandedDirs, ...newDirs] } : state }), - setSidebarWidth: (width) => { + setSidebarWidth: (width: number) => { set({ sidebarWidth: width }) - // 异步保存到 DB settingsRepository.save({ sidebarWidth: width }) } })) diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 10bd073..5461b83 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -3,6 +3,25 @@ import { nanoid } from 'nanoid' import type { Tab } from '../types/tab' import { tabRepository } from '../db/tabRepository' +// PF-08: 防抖工具函数 +function debounce void>(fn: F, delay: number): F & { cancel: () => void } { + let timer: ReturnType | null = null + const debounced = ((...args: unknown[]) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn(...args) + timer = null + }, delay) + }) as F & { cancel: () => void } + debounced.cancel = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + return debounced +} + interface TabState { tabs: Tab[] activeTabId: string | null @@ -23,50 +42,11 @@ interface TabState { saveToDB: () => Promise } -export const useTabStore = create((set, get) => ({ - tabs: [], - activeTabId: null, - mruStack: [], - _loaded: false, +// PF-08: 模块级防抖保存函数(500ms延迟) +let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null - // 从 IndexedDB 加载标签页快照 - loadFromDB: async () => { - if (get()._loaded) return - try { - const [snapshots, savedActiveTabId] = await Promise.all([ - tabRepository.loadAll(), - tabRepository.loadActiveTabId() - ]) - if (snapshots.length > 0) { - const tabs: Tab[] = snapshots.map(s => ({ - id: s.id, - filePath: s.filePath, - content: s.content, - isModified: s.isModified, - scrollTop: s.scrollTop, - selectionStart: s.selectionStart, - selectionEnd: s.selectionEnd - })) - // 使用保存的 activeTabId,如果不存在则取最后一个 - const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) - ? savedActiveTabId - : tabs[tabs.length - 1].id - set({ - tabs, - activeTabId, - _loaded: true - }) - } else { - set({ _loaded: true }) - } - } catch (err) { - console.error('Failed to load tabs from DB:', err) - set({ _loaded: true }) - } - }, - - // 保存当前标签页到 IndexedDB - saveToDB: async () => { +function getActualSaveToDB(get: () => TabState) { + return async () => { const { tabs } = get() if (tabs.length === 0) { await tabRepository.clearAll() @@ -84,141 +64,182 @@ export const useTabStore = create((set, get) => ({ })) await tabRepository.saveAll(snapshots) await tabRepository.saveActiveTabId(get().activeTabId) - }, + } +} - createTab: (filePath = null, content = '') => { - if (filePath) { - const existing = get().tabs.find(t => t.filePath === filePath) - if (existing) { - get().switchToTab(existing.id) - return existing - } - } +export const useTabStore = create((set, get) => { + const actualSave = getActualSaveToDB(get) + _debouncedSaveToDB = debounce(actualSave, 500) - const tab: Tab = { - id: nanoid(), - filePath, - content, - isModified: false, - scrollTop: 0, - selectionStart: 0, - selectionEnd: 0 - } + return { + tabs: [], + activeTabId: null, + mruStack: [], + _loaded: false, - set(state => ({ - tabs: [...state.tabs, tab], - activeTabId: tab.id - })) - - // 异步保存 - setTimeout(() => get().saveToDB(), 0) - - return tab - }, - - closeTab: (tabId) => { - set(state => { - const index = state.tabs.findIndex(t => t.id === tabId) - if (index === -1) return state - - const newTabs = state.tabs.filter(t => t.id !== tabId) - const newMru = state.mruStack.filter(id => id !== tabId) - - let newActiveId = state.activeTabId - if (state.activeTabId === tabId) { - if (newTabs.length === 0) { - newActiveId = null + loadFromDB: async () => { + if (get()._loaded) return + try { + const [snapshots, savedActiveTabId] = await Promise.all([ + tabRepository.loadAll(), + tabRepository.loadActiveTabId() + ]) + if (snapshots.length > 0) { + const tabs: Tab[] = snapshots.map(s => ({ + id: s.id, + filePath: s.filePath, + content: s.content, + isModified: s.isModified, + scrollTop: s.scrollTop, + selectionStart: s.selectionStart, + selectionEnd: s.selectionEnd + })) + const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) + ? savedActiveTabId + : tabs[tabs.length - 1].id + set({ tabs, activeTabId, _loaded: true }) } else { - 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 - } + set({ _loaded: true }) + } + } catch (err) { + console.error('Failed to load tabs from DB:', err) + set({ _loaded: true }) + } + }, + + saveToDB: async () => { + if (_debouncedSaveToDB) { + _debouncedSaveToDB() + } + }, + + createTab: (filePath: string | null = null, content: string = ''): Tab => { + if (filePath) { + const existing = get().tabs.find(t => t.filePath === filePath) + if (existing) { + get().switchToTab(existing.id) + return existing } } - return { - tabs: newTabs, - activeTabId: newActiveId, - mruStack: newMru + const tab: Tab = { + id: nanoid(), + filePath, + content, + isModified: false, + scrollTop: 0, + selectionStart: 0, + selectionEnd: 0 } - }) - setTimeout(() => get().saveToDB(), 0) - }, + set(state => ({ + tabs: [...state.tabs, tab], + activeTabId: tab.id + })) - closeOtherTabs: (tabId) => { - set(state => { - const target = state.tabs.find(t => t.id === tabId) - if (!target) return state - return { tabs: [target], activeTabId: tabId, mruStack: [] } - }) - setTimeout(() => get().saveToDB(), 0) - }, + _debouncedSaveToDB?.() + return tab + }, - closeAllTabs: () => { - set({ tabs: [], activeTabId: null, mruStack: [] }) - setTimeout(() => get().saveToDB(), 0) - }, + closeTab: (tabId: string) => { + set(state => { + const index = state.tabs.findIndex(t => t.id === tabId) + if (index === -1) return state - closeTabsToRight: (tabId) => { - set(state => { - const index = state.tabs.findIndex(t => t.id === tabId) - if (index === -1) return state - const newTabs = state.tabs.slice(0, index + 1) - const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) - ? state.activeTabId - : tabId - return { - tabs: newTabs, - activeTabId: newActiveId, - mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) - } - }) - setTimeout(() => get().saveToDB(), 0) - }, + const newTabs = state.tabs.filter(t => t.id !== tabId) + const newMru = state.mruStack.filter(id => id !== tabId) - switchToTab: (tabId) => { - set(state => { - if (state.activeTabId === tabId) return state - const newMru = state.activeTabId - ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] - : state.mruStack - return { activeTabId: tabId, mruStack: newMru } - }) - // 异步保存 activeTabId - setTimeout(() => tabRepository.saveActiveTabId(tabId), 0) - }, + let newActiveId = state.activeTabId + if (state.activeTabId === tabId) { + if (newTabs.length === 0) { + newActiveId = null + } else { + 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 + } + } + } - updateTabContent: (tabId, content) => { - set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, content, isModified: true } : t - ) - })) - }, + return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru } + }) - setModified: (tabId, modified) => { - set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, isModified: modified } : t - ) - })) - }, + _debouncedSaveToDB?.() + }, - getActiveTab: () => { - const { tabs, activeTabId } = get() - return tabs.find(t => t.id === activeTabId) ?? null - }, + closeOtherTabs: (tabId: string) => { + set(state => { + const target = state.tabs.find(t => t.id === tabId) + if (!target) return state + return { tabs: [target], activeTabId: tabId, mruStack: [] } + }) + _debouncedSaveToDB?.() + }, - updateTabScroll: (tabId, scroll) => { - set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, ...scroll } : t - ) - })) + closeAllTabs: () => { + set({ tabs: [], activeTabId: null, mruStack: [] }) + _debouncedSaveToDB?.() + }, + + closeTabsToRight: (tabId: string) => { + set(state => { + const index = state.tabs.findIndex(t => t.id === tabId) + if (index === -1) return state + const newTabs = state.tabs.slice(0, index + 1) + const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) + ? state.activeTabId + : tabId + return { + tabs: newTabs, + activeTabId: newActiveId, + mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) + } + }) + _debouncedSaveToDB?.() + }, + + switchToTab: (tabId: string) => { + set(state => { + if (state.activeTabId === tabId) return state + const newMru = state.activeTabId + ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] + : state.mruStack + return { activeTabId: tabId, mruStack: newMru } + }) + setTimeout(() => tabRepository.saveActiveTabId(tabId), 0) + }, + + updateTabContent: (tabId: string, content: string) => { + set(state => ({ + tabs: state.tabs.map(t => + t.id === tabId ? { ...t, content, isModified: true } : t + ) + })) + }, + + setModified: (tabId: string, modified: boolean) => { + set(state => ({ + tabs: state.tabs.map(t => + t.id === tabId ? { ...t, isModified: modified } : t + ) + })) + }, + + getActiveTab: (): Tab | null => { + const { tabs, activeTabId } = get() + return tabs.find(t => t.id === activeTabId) ?? null + }, + + updateTabScroll: (tabId: string, scroll: Partial>) => { + set(state => ({ + tabs: state.tabs.map(t => + t.id === tabId ? { ...t, ...scroll } : t + ) + })) + } } -})) +}) diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index b8993ff..6b41265 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -438,9 +438,30 @@ html, body { opacity: 1; } -/* Selection */ +/* Selection — 必须匹配oneDark主题的选择器特异性才能覆盖 */ .cm-selectionBackground { - background: rgba(26, 115, 232, 0.2) !important; + background: rgba(26, 115, 232, 0.25) !important; +} + +.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, +.cm-editor.cm-focused .cm-selectionBackground, +.cm-content ::selection { + background: rgba(26, 115, 232, 0.3) !important; +} + +:root.dark .cm-selectionBackground { + background: rgba(100, 160, 255, 0.25) !important; +} + +:root.dark .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, +:root.dark .cm-editor.cm-focused .cm-selectionBackground, +:root.dark .cm-content ::selection { + background: rgba(100, 160, 255, 0.35) !important; +} + +.cm-editor .cm-content { + user-select: text !important; + -webkit-user-select: text !important; } .cm-editor .cm-cursor { @@ -1208,3 +1229,342 @@ html, body { .about-close-btn:hover { background: var(--primary-dark); } + +/* ===== UX-01: ConfirmDialog ===== */ +.confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10001; + animation: fadeIn 0.15s ease; +} + +.confirm-dialog { + background: var(--bg); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + width: 380px; + max-width: 90vw; + overflow: hidden; + animation: fadeIn 0.15s ease; +} + +.confirm-header { + padding: 20px 24px 0; +} + +.confirm-header h3 { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.confirm-header.confirm-danger h3 { color: #d93025; } +.confirm-header.confirm-warning h3 { color: #e37400; } +:root.dark .confirm-header.confirm-warning h3 { color: #fdd663; } + +.confirm-body { + padding: 12px 24px 20px; +} + +.confirm-body p { + font-size: 14px; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 24px 20px; +} + +.confirm-btn { + padding: 8px 20px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + font-family: var(--font-ui); + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + background: var(--bg); + color: var(--text); +} + +.confirm-btn-cancel:hover { + background: var(--bg-tertiary); +} + +.confirm-btn-danger { + background: #d93025; + border-color: #d93025; + color: white; +} + +.confirm-btn-danger:hover { + background: #b5271d; +} + +.confirm-btn-warning { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.confirm-btn-warning:hover { + background: var(--primary-dark); +} + +.confirm-btn-info { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.confirm-btn-info:hover { + background: var(--primary-dark); +} + +.confirm-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* ===== UX-02: LoadingSpinner ===== */ +.loading-spinner { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); +} + +.loading-spinner-svg { + animation: spin 0.8s linear infinite; +} + +.loading-spinner-label { + font-size: 13px; + font-family: var(--font-ui); + color: var(--text-secondary); +} + +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +:root.dark .loading-overlay { + background: rgba(30, 30, 30, 0.8); +} + +.preview-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 0; + color: var(--text-tertiary); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Status bar loading indicator */ +.status-loading { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--primary); + font-size: 12px; +} + +.status-loading .loading-spinner-svg { + animation: spin 0.8s linear infinite; +} + +/* ===== UX-05: Toast Container (升级版) ===== */ +.toast-container { + position: fixed; + bottom: 44px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column-reverse; + gap: 8px; + z-index: 9999; + pointer-events: none; + max-width: 480px; + width: 100%; + padding: 0 16px; +} + +.toast-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + font-family: var(--font-ui); + box-shadow: var(--shadow-lg); + opacity: 0; + transform: translateY(10px) scale(0.95); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: auto; + max-width: 100%; +} + +.toast-item.toast-visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +/* Toast 类型样式 */ +.toast-success { + background: #e6f4ea; + color: #137333; + border: 1px solid #a8dab5; +} + +.toast-error { + background: #fce8e6; + color: #c5221f; + border: 1px solid #f28b82; +} + +.toast-warning { + background: #fef7e0; + color: #945700; + border: 1px solid #fdd663; +} + +.toast-info { + background: var(--text); + color: var(--bg); + border: 1px solid transparent; +} + +:root.dark .toast-success { + background: #0d3a1a; + color: #81c995; + border-color: #1e5a2e; +} + +:root.dark .toast-error { + background: #3c1214; + color: #f28b82; + border-color: #5c1a1a; +} + +:root.dark .toast-warning { + background: #3a3000; + color: #fdd663; + border-color: #5a4a00; +} + +:root.dark .toast-info { + background: var(--bg-secondary); + color: var(--text); + border-color: var(--border); +} + +.toast-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; +} + +.toast-message { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toast-close { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: inherit; + opacity: 0.6; + cursor: pointer; + font-size: 12px; + border-radius: 4px; + padding: 0; + transition: opacity 0.15s ease; +} + +.toast-close:hover { + opacity: 1; +} + +.toast-close:focus-visible { + outline: 2px solid currentColor; + outline-offset: 1px; +} + +/* ===== UX-07: 通用可访问性增强 ===== */ + +/* Focus visible 为所有交互元素提供清晰的焦点指示 */ +button:focus-visible, +[role="treeitem"]:focus-visible, +[role="tab"]:focus-visible, +[role="menuitem"]:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* 确保 tab 上的关闭按钮也有焦点指示 */ +.tab-close:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +/* 确保上下文菜单项有焦点指示 */ +.tab-context-item:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +/* Skip to content 链接(可选,用于键盘导航) */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/src/renderer/styles/variables.css b/src/renderer/styles/variables.css index ff99060..0556ade 100644 --- a/src/renderer/styles/variables.css +++ b/src/renderer/styles/variables.css @@ -6,8 +6,8 @@ --bg-secondary: #f8f9fa; --bg-tertiary: #f1f3f4; --text: #333333; - --text-secondary: #5f6368; - --text-tertiary: #9aa0a6; + --text-secondary: #555a5e; + --text-tertiary: #70757a; --border: #e1e4e8; --border-light: #f0f0f0; --code-bg: #f6f8fa; diff --git a/src/renderer/test-setup.ts b/src/renderer/test-setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/src/renderer/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/src/renderer/types/ipc.ts b/src/renderer/types/ipc.ts index c73a636..aa4abf9 100644 --- a/src/renderer/types/ipc.ts +++ b/src/renderer/types/ipc.ts @@ -21,7 +21,7 @@ export interface IpcInvokeMap { 'window:forceClose': [void, void] 'window:cancelClose': [void, void] 'dir:readTree': [string, ReadDirTreeResult] - 'dir:openDialog': [void, string | null] + 'dir:openDialog': [void, { canceled: boolean; filePaths: string[] }] 'dir:watch': [string, void] 'dir:unwatch': [void, void] } @@ -41,7 +41,7 @@ export interface ElectronAPI { cancelClose: () => Promise openExternal: (url: string) => void readDirTree: (dirPath: string) => Promise - openFolderDialog: () => Promise + openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }> watchDir: (dirPath: string) => Promise unwatchDir: () => Promise onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe @@ -53,8 +53,4 @@ export interface ElectronAPI { onConfirmClose: (callback: () => void) => Unsubscribe } -declare global { - interface Window { - electronAPI?: ElectronAPI - } -} + diff --git a/tsconfig.node.json b/tsconfig.node.json index c964fb0..360561e 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -12,5 +12,5 @@ "composite": true, "allowSyntheticDefaultImports": true }, - "include": ["electron.vite.config.ts"] + "include": ["electron.vite.config.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7791e2e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/renderer/test-setup.ts'], + include: ['src/**/__tests__/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + include: ['src/renderer/stores/**', 'src/renderer/lib/**', 'src/renderer/hooks/**'], + exclude: ['**/__tests__/**', '**/*.d.ts'], + }, + }, + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer'), + '@main': resolve(__dirname, 'src/main'), + '@shared': resolve(__dirname, 'src/shared'), + }, + }, +})