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 { 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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user