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:
@@ -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<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(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() {
|
||||
<WelcomeScreen
|
||||
onOpen={handleOpenFile}
|
||||
onNew={() => createTab(null, '')}
|
||||
onOpenRecent={handleOpenRecent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
recentFilesRepository.getAll(10).then(files => {
|
||||
setRecentFiles(files)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="welcome-screen">
|
||||
<div className="welcome-content">
|
||||
@@ -25,6 +36,32 @@ export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) {
|
||||
新建文件
|
||||
</button>
|
||||
</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">
|
||||
<p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p>
|
||||
<p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+F 搜索 | Ctrl+1/2/3 视图</p>
|
||||
|
||||
@@ -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<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) => ({
|
||||
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<TabState>((set, get) => ({
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
// 异步保存
|
||||
setTimeout(() => get().saveToDB(), 0)
|
||||
|
||||
return tab
|
||||
},
|
||||
|
||||
@@ -69,7 +127,6 @@ export const useTabStore = create<TabState>((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<TabState>((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<TabState>((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<TabState>((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 }
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user