feat: 标签页 IndexedDB 持久化 + 最近打开文件

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 已足够)
This commit is contained in:
thzxx
2026-05-27 23:21:31 +08:00
parent 1cc4206b32
commit 23304dca1d
5 changed files with 201 additions and 14 deletions
+27
View File
@@ -7,6 +7,7 @@ import { useSettings } from './hooks/useSettings'
import { useKeyboard } from './hooks/useKeyboard' import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning' import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch' import { useFileWatch } from './hooks/useFileWatch'
import { recentFilesRepository } from './db/recentFilesRepository'
import { Toolbar } from './components/Toolbar/Toolbar' import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar' import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor' import { Editor } from './components/Editor/Editor'
@@ -27,6 +28,8 @@ export default function App() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
const setModified = useTabStore(s => s.setModified) const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent) 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 viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio) const splitRatio = useEditorStore(s => s.splitRatio)
@@ -38,6 +41,11 @@ export default function App() {
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null) const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null) const [toastMessage, setToastMessage] = useState<string | null>(null)
// 启动时从 IndexedDB 加载标签页
useEffect(() => {
loadFromDB()
}, [loadFromDB])
const showToast = useCallback((msg: string) => { const showToast = useCallback((msg: string) => {
setToastMessage(msg) setToastMessage(msg)
setTimeout(() => setToastMessage(null), 3000) setTimeout(() => setToastMessage(null), 3000)
@@ -72,6 +80,10 @@ export default function App() {
const result = await window.electronAPI.openFile() const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) { if (result && 'filePath' in result) {
createTab(result.filePath, result.content) createTab(result.filePath, result.content)
// 追踪最近打开文件
if (result.filePath) recentFilesRepository.add(result.filePath)
// 保存标签页到 DB
setTimeout(() => useTabStore.getState().saveToDB(), 100)
} }
} }
}, [createTab]) }, [createTab])
@@ -108,6 +120,18 @@ export default function App() {
// 快捷键 // 快捷键
useKeyboard(handleOpenFile, handleSave, handleSaveAs) 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') => { const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => {
saveViewMode(mode) saveViewMode(mode)
@@ -125,6 +149,8 @@ export default function App() {
const onOpen = (data: { filePath: string; content: string }) => { const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content) createTab(data.filePath, data.content)
recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
} }
const onSave = () => handleSaveRef.current() const onSave = () => handleSaveRef.current()
const onSaveAs = () => handleSaveAsRef.current() const onSaveAs = () => handleSaveAsRef.current()
@@ -207,6 +233,7 @@ export default function App() {
<WelcomeScreen <WelcomeScreen
onOpen={handleOpenFile} onOpen={handleOpenFile}
onNew={() => createTab(null, '')} onNew={() => createTab(null, '')}
onOpenRecent={handleOpenRecent}
/> />
)} )}
</div> </div>
@@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useState, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore' import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { FolderPlus, File, Folder, ChevronRight } from '../Icons' import { FolderPlus, File, Folder, ChevronRight } from '../Icons'
import type { FileNode } from '../../types/file' import type { FileNode } from '../../types/file'
@@ -106,6 +107,7 @@ export function Sidebar() {
const result = await window.electronAPI.readFile(path) const result = await window.electronAPI.readFile(path)
if (result.success && result.content) { if (result.success && result.content) {
createTab(path, result.content) createTab(path, result.content)
recentFilesRepository.add(path)
} }
} }
}, [tabs, switchToTab, createTab]) }, [tabs, switchToTab, createTab])
@@ -1,12 +1,23 @@
import React from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons' import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { getFileName } from '../../lib/fileUtils'
interface WelcomeScreenProps { interface WelcomeScreenProps {
onOpen: () => void onOpen: () => void
onNew: () => void onNew: () => void
onOpenRecent?: (filePath: string) => void
} }
export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) { export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
const [recentFiles, setRecentFiles] = useState<string[]>([])
useEffect(() => {
recentFilesRepository.getAll(10).then(files => {
setRecentFiles(files)
})
}, [])
return ( return (
<div id="welcome-screen"> <div id="welcome-screen">
<div className="welcome-content"> <div className="welcome-content">
@@ -25,6 +36,32 @@ export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) {
</button> </button>
</div> </div>
{recentFiles.length > 0 && (
<div className="welcome-recent">
<h3></h3>
<div className="recent-list">
{recentFiles.map(filePath => (
<button
key={filePath}
className="recent-item"
onClick={() => onOpenRecent?.(filePath)}
title={filePath}
>
<span className="recent-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
<span className="recent-name">{getFileName(filePath)}</span>
<span className="recent-path">{filePath}</span>
</button>
))}
</div>
</div>
)}
<div className="welcome-tips"> <div className="welcome-tips">
<p>💡 .md </p> <p>💡 .md </p>
<p> Ctrl+O | Ctrl+S | Ctrl+F | Ctrl+1/2/3 </p> <p> Ctrl+O | Ctrl+S | Ctrl+F | Ctrl+1/2/3 </p>
+67 -12
View File
@@ -1,11 +1,13 @@
import { create } from 'zustand' import { create } from 'zustand'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import type { Tab } from '../types/tab' import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository'
interface TabState { interface TabState {
tabs: Tab[] tabs: Tab[]
activeTabId: string | null activeTabId: string | null
mruStack: string[] mruStack: string[]
_loaded: boolean
createTab: (filePath?: string | null, content?: string) => Tab createTab: (filePath?: string | null, content?: string) => Tab
closeTab: (tabId: string) => void closeTab: (tabId: string) => void
@@ -18,16 +20,69 @@ interface TabState {
getActiveTab: () => Tab | null getActiveTab: () => Tab | null
getTabIndex: (tabId: string) => number getTabIndex: (tabId: string) => number
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
loadFromDB: () => Promise<void>
saveToDB: () => Promise<void>
} }
// L-04: 使用 nanoid 替代计数器(HMR 安全)
export const useTabStore = create<TabState>((set, get) => ({ export const useTabStore = create<TabState>((set, get) => ({
tabs: [], tabs: [],
activeTabId: null, activeTabId: null,
mruStack: [], 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 = '') => { createTab: (filePath = null, content = '') => {
// 检查是否已打开
if (filePath) { if (filePath) {
const existing = get().tabs.find(t => t.filePath === filePath) const existing = get().tabs.find(t => t.filePath === filePath)
if (existing) { if (existing) {
@@ -53,6 +108,9 @@ export const useTabStore = create<TabState>((set, get) => ({
activeTabId: tab.id activeTabId: tab.id
})) }))
// 异步保存
setTimeout(() => get().saveToDB(), 0)
return tab return tab
}, },
@@ -69,7 +127,6 @@ export const useTabStore = create<TabState>((set, get) => ({
if (newTabs.length === 0) { if (newTabs.length === 0) {
newActiveId = null newActiveId = null
} else { } else {
// M-05: 优先从 MRU 栈取最近使用的标签
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id)) const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
if (mruCandidate) { if (mruCandidate) {
newActiveId = mruCandidate newActiveId = mruCandidate
@@ -87,22 +144,22 @@ export const useTabStore = create<TabState>((set, get) => ({
mruStack: newMru mruStack: newMru
} }
}) })
setTimeout(() => get().saveToDB(), 0)
}, },
closeOtherTabs: (tabId) => { closeOtherTabs: (tabId) => {
set(state => { set(state => {
const target = state.tabs.find(t => t.id === tabId) const target = state.tabs.find(t => t.id === tabId)
if (!target) return state if (!target) return state
return { return { tabs: [target], activeTabId: tabId, mruStack: [] }
tabs: [target],
activeTabId: tabId,
mruStack: []
}
}) })
setTimeout(() => get().saveToDB(), 0)
}, },
closeAllTabs: () => { closeAllTabs: () => {
set({ tabs: [], activeTabId: null, mruStack: [] }) set({ tabs: [], activeTabId: null, mruStack: [] })
setTimeout(() => get().saveToDB(), 0)
}, },
closeTabsToRight: (tabId) => { closeTabsToRight: (tabId) => {
@@ -119,6 +176,7 @@ export const useTabStore = create<TabState>((set, get) => ({
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
} }
}) })
setTimeout(() => get().saveToDB(), 0)
}, },
switchToTab: (tabId) => { switchToTab: (tabId) => {
@@ -127,10 +185,7 @@ export const useTabStore = create<TabState>((set, get) => ({
const newMru = state.activeTabId const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack : state.mruStack
return { return { activeTabId: tabId, mruStack: newMru }
activeTabId: tabId,
mruStack: newMru
}
}) })
}, },
+66
View File
@@ -587,6 +587,72 @@ html, body {
.welcome-btn.secondary:hover { background: var(--bg-tertiary); } .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 { .welcome-tips {
text-align: left; text-align: left;
background: var(--bg-secondary); background: var(--bg-secondary);