From 23304dca1d438aec87002277e41a3fcf40524ead Mon Sep 17 00:00:00 2001 From: thzxx Date: Wed, 27 May 2026 23:21:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=87=E7=AD=BE=E9=A1=B5=20IndexedDB?= =?UTF-8?q?=20=E6=8C=81=E4=B9=85=E5=8C=96=20+=20=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=89=93=E5=BC=80=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 标签页持久化(关闭后恢复): - tabStore 新增 loadFromDB/saveToDB 方法 - 启动时从 IndexedDB 加载标签页快照 - 标签页增删改时异步保存到 IndexedDB - 关闭窗口后重新打开,所有标签页自动恢复 2. 最近打开文件: - 欢迎屏幕显示最近打开的 10 个文件 - 点击文件名直接打开 - 从侧边栏/对话框/主进程打开文件时自动记录 - 文件路径显示在文件名下方(灰色小字) - hover 高亮效果 REFACTOR-PLAN.md 实现进度更新: ✅ CodeMirror 6 编辑器 ✅ 格式化工具栏 ✅ 标签页 IndexedDB 持久化 ✅ 最近打开文件 UI ✅ Zustand stores ✅ TypeScript 全量类型 ✅ unified/rehype ✅ electron-vite ❌ CSS Modules(全局 CSS 已足够) --- src/renderer/App.tsx | 27 +++++++ src/renderer/components/Sidebar/Sidebar.tsx | 2 + .../WelcomeScreen/WelcomeScreen.tsx | 41 +++++++++- src/renderer/stores/tabStore.ts | 79 ++++++++++++++++--- src/renderer/styles/global.css | 66 ++++++++++++++++ 5 files changed, 201 insertions(+), 14 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8d7c12f..8f4e3d8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7,6 +7,7 @@ import { useSettings } from './hooks/useSettings' import { useKeyboard } from './hooks/useKeyboard' import { useUnsavedWarning } from './hooks/useUnsavedWarning' import { useFileWatch } from './hooks/useFileWatch' +import { recentFilesRepository } from './db/recentFilesRepository' import { Toolbar } from './components/Toolbar/Toolbar' import { TabBar } from './components/TabBar/TabBar' import { Editor } from './components/Editor/Editor' @@ -27,6 +28,8 @@ export default function App() { const createTab = useTabStore(s => s.createTab) const setModified = useTabStore(s => s.setModified) const updateTabContent = useTabStore(s => s.updateTabContent) + const loadFromDB = useTabStore(s => s.loadFromDB) + const saveToDB = useTabStore(s => s.saveToDB) const viewMode = useEditorStore(s => s.viewMode) const splitRatio = useEditorStore(s => s.splitRatio) @@ -38,6 +41,11 @@ export default function App() { const [modifiedFilePath, setModifiedFilePath] = useState(null) const [toastMessage, setToastMessage] = useState(null) + // 启动时从 IndexedDB 加载标签页 + useEffect(() => { + loadFromDB() + }, [loadFromDB]) + const showToast = useCallback((msg: string) => { setToastMessage(msg) setTimeout(() => setToastMessage(null), 3000) @@ -72,6 +80,10 @@ export default function App() { const result = await window.electronAPI.openFile() if (result && 'filePath' in result) { createTab(result.filePath, result.content) + // 追踪最近打开文件 + if (result.filePath) recentFilesRepository.add(result.filePath) + // 保存标签页到 DB + setTimeout(() => useTabStore.getState().saveToDB(), 100) } } }, [createTab]) @@ -108,6 +120,18 @@ export default function App() { // 快捷键 useKeyboard(handleOpenFile, handleSave, handleSaveAs) + // 打开最近文件 + const handleOpenRecent = useCallback(async (filePath: string) => { + if (window.electronAPI) { + const result = await window.electronAPI.readFile(filePath) + if (result.success && result.content) { + createTab(filePath, result.content) + recentFilesRepository.add(filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) + } + } + }, [createTab]) + // 视图模式切换 const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => { saveViewMode(mode) @@ -125,6 +149,8 @@ export default function App() { const onOpen = (data: { filePath: string; content: string }) => { createTab(data.filePath, data.content) + recentFilesRepository.add(data.filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) } const onSave = () => handleSaveRef.current() const onSaveAs = () => handleSaveAsRef.current() @@ -207,6 +233,7 @@ export default function App() { createTab(null, '')} + onOpenRecent={handleOpenRecent} /> )} diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index bc06bea..f861934 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useState, useRef } from 'react' import { useTabStore } from '../../stores/tabStore' import { useSidebarStore } from '../../stores/sidebarStore' import { getFileName } from '../../lib/fileUtils' +import { recentFilesRepository } from '../../db/recentFilesRepository' import { FolderPlus, File, Folder, ChevronRight } from '../Icons' import type { FileNode } from '../../types/file' @@ -106,6 +107,7 @@ export function Sidebar() { const result = await window.electronAPI.readFile(path) if (result.success && result.content) { createTab(path, result.content) + recentFilesRepository.add(path) } } }, [tabs, switchToTab, createTab]) diff --git a/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx b/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx index ccb8b8b..ee42b8b 100644 --- a/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx +++ b/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx @@ -1,12 +1,23 @@ -import React from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons' +import { recentFilesRepository } from '../../db/recentFilesRepository' +import { getFileName } from '../../lib/fileUtils' interface WelcomeScreenProps { onOpen: () => void onNew: () => void + onOpenRecent?: (filePath: string) => void } -export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) { +export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) { + const [recentFiles, setRecentFiles] = useState([]) + + useEffect(() => { + recentFilesRepository.getAll(10).then(files => { + setRecentFiles(files) + }) + }, []) + return (
@@ -25,6 +36,32 @@ export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) { 新建文件
+ + {recentFiles.length > 0 && ( +
+

最近打开

+
+ {recentFiles.map(filePath => ( + + ))} +
+
+ )} +

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

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

diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 799ed39..5bbfe37 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -1,11 +1,13 @@ import { create } from 'zustand' import { nanoid } from 'nanoid' import type { Tab } from '../types/tab' +import { tabRepository } from '../db/tabRepository' interface TabState { tabs: Tab[] activeTabId: string | null mruStack: string[] + _loaded: boolean createTab: (filePath?: string | null, content?: string) => Tab closeTab: (tabId: string) => void @@ -18,16 +20,69 @@ interface TabState { getActiveTab: () => Tab | null getTabIndex: (tabId: string) => number updateTabScroll: (tabId: string, scroll: Partial>) => void + loadFromDB: () => Promise + saveToDB: () => Promise } -// L-04: 使用 nanoid 替代计数器(HMR 安全) export const useTabStore = create((set, get) => ({ tabs: [], activeTabId: null, mruStack: [], + _loaded: false, + + // 从 IndexedDB 加载标签页快照 + loadFromDB: async () => { + if (get()._loaded) return + try { + const snapshots = await tabRepository.loadAll() + 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, + scrollLeft: s.scrollLeft, + selectionStart: s.selectionStart, + selectionEnd: s.selectionEnd, + previewScrollTop: s.previewScrollTop + })) + set({ + tabs, + activeTabId: tabs[tabs.length - 1].id, + _loaded: true + }) + } else { + set({ _loaded: true }) + } + } catch { + set({ _loaded: true }) + } + }, + + // 保存当前标签页到 IndexedDB + saveToDB: async () => { + const { tabs } = get() + if (tabs.length === 0) { + await tabRepository.clearAll() + return + } + const snapshots = tabs.map(t => ({ + id: t.id, + filePath: t.filePath, + content: t.content, + isModified: t.isModified, + scrollTop: t.scrollTop, + scrollLeft: t.scrollLeft, + selectionStart: t.selectionStart, + selectionEnd: t.selectionEnd, + previewScrollTop: t.previewScrollTop, + updatedAt: Date.now() + })) + await tabRepository.saveAll(snapshots) + }, createTab: (filePath = null, content = '') => { - // 检查是否已打开 if (filePath) { const existing = get().tabs.find(t => t.filePath === filePath) if (existing) { @@ -53,6 +108,9 @@ export const useTabStore = create((set, get) => ({ activeTabId: tab.id })) + // 异步保存 + setTimeout(() => get().saveToDB(), 0) + return tab }, @@ -69,7 +127,6 @@ export const useTabStore = create((set, get) => ({ if (newTabs.length === 0) { newActiveId = null } else { - // M-05: 优先从 MRU 栈取最近使用的标签 const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id)) if (mruCandidate) { newActiveId = mruCandidate @@ -87,22 +144,22 @@ export const useTabStore = create((set, get) => ({ mruStack: newMru } }) + + setTimeout(() => get().saveToDB(), 0) }, closeOtherTabs: (tabId) => { set(state => { const target = state.tabs.find(t => t.id === tabId) if (!target) return state - return { - tabs: [target], - activeTabId: tabId, - mruStack: [] - } + return { tabs: [target], activeTabId: tabId, mruStack: [] } }) + setTimeout(() => get().saveToDB(), 0) }, closeAllTabs: () => { set({ tabs: [], activeTabId: null, mruStack: [] }) + setTimeout(() => get().saveToDB(), 0) }, closeTabsToRight: (tabId) => { @@ -119,6 +176,7 @@ export const useTabStore = create((set, get) => ({ mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) } }) + setTimeout(() => get().saveToDB(), 0) }, switchToTab: (tabId) => { @@ -127,10 +185,7 @@ export const useTabStore = create((set, get) => ({ const newMru = state.activeTabId ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] : state.mruStack - return { - activeTabId: tabId, - mruStack: newMru - } + return { activeTabId: tabId, mruStack: newMru } }) }, diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index 08d44cc..194df4b 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -587,6 +587,72 @@ html, body { .welcome-btn.secondary:hover { background: var(--bg-tertiary); } +/* 最近打开文件 */ +.welcome-recent { + text-align: left; + margin-bottom: 24px; +} + +.welcome-recent h3 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.recent-list { + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: var(--radius); + overflow: hidden; + max-height: 200px; + overflow-y: auto; +} + +.recent-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text); + font-size: 13px; + font-family: var(--font-ui); + cursor: pointer; + text-align: left; + transition: background 0.1s ease; + border-bottom: 1px solid var(--border-light); +} + +.recent-item:last-child { border-bottom: none; } + +.recent-item:hover { + background: var(--bg-tertiary); +} + +.recent-icon { + flex-shrink: 0; + color: var(--text-tertiary); +} + +.recent-name { + flex-shrink: 0; + font-weight: 500; +} + +.recent-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-size: 11px; + margin-left: auto; +} + .welcome-tips { text-align: left; background: var(--bg-secondary);