Files
XCDesktop/src/stores/tabStore.ts
ssdfasd 371d4ce327 fix: 修复 markdown 编辑保存后内容丢失的问题
- 在 saveContent 中缓存 unsavedContent,避免 async 期间的竞态条件
- 在 useMarkdownLogic 中添加 lastContentRef 跟踪内容变化,防止不必要的编辑器更新
2026-03-13 18:38:38 +08:00

451 lines
12 KiB
TypeScript

import { create } from 'zustand'
import type { FileItem } from '@/lib/api'
import { getModule } from '@/lib/module-registry'
import { fetchFileContent, saveFileContent } from '@/lib/api'
import { isMarkdownFile } from '@/lib/utils'
import { matchModule } from '@/lib/tabs'
import { isAsyncImportProcessingContent } from '@shared/constants'
const getHomeFileItem = (): FileItem => {
const homeModule = getModule('home')
return homeModule?.fileItem ?? {
name: '首页',
path: 'home-tab',
type: 'file',
size: 0,
modified: new Date().toISOString(),
}
}
const getFileName = (path: string): string => {
const parts = path.replace(/\\/g, '/').split('/')
return parts[parts.length - 1] || ''
}
export interface TabState {
file: FileItem
content: string
unsavedContent: string
isEditing: boolean
loading: boolean
loaded: boolean
}
export interface TabStoreState {
tabs: Map<string, TabState>
activeTabId: string | null
}
interface TabActions {
selectFile: (file: FileItem) => void
setActiveFile: (file: FileItem | null) => void
closeFile: (file: FileItem) => void
closeOtherFiles: (fileToKeep: FileItem) => void
closeAllFiles: () => void
renamePath: (oldPath: string, newPath: string) => void
handleDeletePath: (path: string, type: 'file' | 'dir') => void
loadContent: (filePath: string) => Promise<void>
saveContent: (filePath: string) => Promise<void>
setEditing: (filePath: string, editing: boolean) => void
setUnsavedContent: (filePath: string, content: string) => void
toggleEditing: (filePath: string) => void
getTab: (filePath: string) => TabState | undefined
getActiveTab: () => TabState | undefined
getActiveFile: () => FileItem | null
getOpenFiles: () => FileItem[]
hasUnsavedChanges: (filePath: string) => boolean
isLoading: (filePath: string) => boolean
}
export type TabStore = TabStoreState & TabActions
const getInitialState = (): TabStoreState => {
const homeFileItem = getHomeFileItem()
const homeTab: TabState = {
file: homeFileItem,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: true,
}
const tabs = new Map<string, TabState>()
tabs.set(homeFileItem.path, homeTab)
return {
tabs,
activeTabId: homeFileItem.path,
}
}
export const useTabStore = create<TabStore>((set, get) => {
const initialState = getInitialState()
return {
...initialState,
selectFile: (file) => set((state) => {
const tabs = new Map(state.tabs)
const existingTab = tabs.get(file.path)
if (!existingTab) {
const newTab: TabState = {
file,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: false,
}
tabs.set(file.path, newTab)
}
return {
tabs,
activeTabId: file.path,
}
}),
setActiveFile: (file) => set((state) => {
if (!file) {
return { activeTabId: null }
}
const tabs = new Map(state.tabs)
tabs.forEach((tab, path) => {
tabs.set(path, { ...tab })
})
return {
tabs,
activeTabId: file.path,
}
}),
closeFile: (file) => set((state) => {
const homeFileItem = getHomeFileItem()
const tabs = new Map(state.tabs)
const filePath = file.path
const allPaths = Array.from(tabs.keys())
const currentIndex = allPaths.indexOf(filePath)
let newActiveTabId = state.activeTabId
if (state.activeTabId === filePath) {
tabs.delete(filePath)
const remainingPaths = Array.from(tabs.keys())
if (remainingPaths.length > 0) {
const prevIndex = Math.max(0, currentIndex - 1)
newActiveTabId = remainingPaths[prevIndex]
} else {
const homeTab: TabState = {
file: homeFileItem,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: true,
}
tabs.set(homeFileItem.path, homeTab)
newActiveTabId = homeFileItem.path
}
} else {
tabs.delete(filePath)
}
return {
tabs,
activeTabId: newActiveTabId,
}
}),
closeOtherFiles: (fileToKeep) => set((state) => {
const homeFileItem = getHomeFileItem()
const tabs = new Map(state.tabs)
const keepPath = fileToKeep.path
const tabToKeep = tabs.get(keepPath)
const newTabs = new Map<string, TabState>()
if (tabToKeep) {
newTabs.set(keepPath, tabToKeep)
} else {
newTabs.set(keepPath, {
file: fileToKeep,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: false,
})
}
if (newTabs.size === 0) {
const homeTab: TabState = {
file: homeFileItem,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: true,
}
newTabs.set(homeFileItem.path, homeTab)
}
return {
tabs: newTabs,
activeTabId: keepPath,
}
}),
closeAllFiles: () => set(() => {
const homeFileItem = getHomeFileItem()
const homeTab: TabState = {
file: homeFileItem,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: true,
}
const tabs = new Map<string, TabState>()
tabs.set(homeFileItem.path, homeTab)
return {
tabs,
activeTabId: homeFileItem.path,
}
}),
renamePath: (oldPath, newPath) => set((state) => {
const tabs = new Map(state.tabs)
const oldTab = tabs.get(oldPath)
if (oldTab) {
const newFile = { ...oldTab.file, path: newPath, name: getFileName(newPath) || oldTab.file.name }
tabs.set(newPath, { ...oldTab, file: newFile })
tabs.delete(oldPath)
}
return { tabs }
}),
handleDeletePath: (path, type) => set((state) => {
const homeFileItem = getHomeFileItem()
const tabs = new Map(state.tabs)
const isAffected = (filePath: string) => {
if (type === 'file') return filePath === path
return filePath === path || filePath.startsWith(path + '/')
}
const pathsToDelete: string[] = []
tabs.forEach((_, filePath) => {
if (isAffected(filePath)) {
pathsToDelete.push(filePath)
}
})
pathsToDelete.forEach(p => tabs.delete(p))
let newActiveTabId = state.activeTabId
if (state.activeTabId && isAffected(state.activeTabId)) {
const remainingPaths = Array.from(tabs.keys())
if (remainingPaths.length > 0) {
newActiveTabId = remainingPaths[0]
} else {
const homeTab: TabState = {
file: homeFileItem,
content: '',
unsavedContent: '',
isEditing: false,
loading: false,
loaded: true,
}
tabs.set(homeFileItem.path, homeTab)
newActiveTabId = homeFileItem.path
}
}
return {
tabs,
activeTabId: newActiveTabId,
}
}),
loadContent: async (filePath) => {
const { tabs } = get()
const tab = tabs.get(filePath)
if (!tab) return
set((state) => {
const newTabs = new Map(state.tabs)
newTabs.set(filePath, { ...tab, loading: true })
return { tabs: newTabs }
})
try {
const data = await fetchFileContent(filePath)
set((state) => {
const newTabs = new Map(state.tabs)
const currentTab = newTabs.get(filePath)
if (currentTab) {
newTabs.set(filePath, {
...currentTab,
content: data.content,
unsavedContent: data.content,
loading: false,
loaded: true,
})
}
return { tabs: newTabs }
})
} catch (error) {
console.error('Failed to load content', error)
const errorMsg = '# Error\nFailed to load file content.'
set((state) => {
const newTabs = new Map(state.tabs)
const currentTab = newTabs.get(filePath)
if (currentTab) {
newTabs.set(filePath, {
...currentTab,
content: errorMsg,
unsavedContent: errorMsg,
loading: false,
loaded: true,
})
}
return { tabs: newTabs }
})
}
},
saveContent: async (filePath) => {
const tab = get().tabs.get(filePath)
if (!tab) return
const isAsyncImportProcessing = isAsyncImportProcessingContent(tab.content)
if (isAsyncImportProcessing) return
const contentToSave = tab.unsavedContent
await saveFileContent(filePath, contentToSave)
set((state) => {
const newTabs = new Map(state.tabs)
const currentTab = newTabs.get(filePath)
if (currentTab) {
newTabs.set(filePath, {
...currentTab,
content: contentToSave,
unsavedContent: contentToSave,
isEditing: false,
})
}
return { tabs: newTabs }
})
},
setEditing: (filePath, editing) => set((state) => {
const tab = state.tabs.get(filePath)
if (!tab) return state
const newTabs = new Map(state.tabs)
newTabs.set(filePath, {
...tab,
isEditing: editing,
unsavedContent: editing ? tab.unsavedContent : tab.content,
})
return { tabs: newTabs }
}),
setUnsavedContent: (filePath, content) => set((state) => {
const tab = state.tabs.get(filePath)
if (!tab) return state
const newTabs = new Map(state.tabs)
newTabs.set(filePath, { ...tab, unsavedContent: content })
return { tabs: newTabs }
}),
toggleEditing: (filePath) => set((state) => {
const tab = state.tabs.get(filePath)
if (!tab) return state
const isAsyncImportProcessing = isAsyncImportProcessingContent(tab.content)
if (isAsyncImportProcessing) return state
const newTabs = new Map(state.tabs)
newTabs.set(filePath, {
...tab,
isEditing: !tab.isEditing,
unsavedContent: !tab.isEditing ? tab.content : tab.unsavedContent,
})
return { tabs: newTabs }
}),
getTab: (filePath) => get().tabs.get(filePath),
getActiveTab: () => {
const { tabs, activeTabId } = get()
return activeTabId ? tabs.get(activeTabId) : undefined
},
getActiveFile: () => {
const activeTab = get().getActiveTab()
return activeTab?.file ?? null
},
getOpenFiles: () => {
const { tabs } = get()
return Array.from(tabs.values()).map(tab => tab.file)
},
hasUnsavedChanges: (filePath) => {
const tab = get().tabs.get(filePath)
if (!tab) return false
return tab.unsavedContent !== tab.content
},
isLoading: (filePath) => {
const tab = get().tabs.get(filePath)
return tab?.loading ?? false
},
}
})
export const isSpecialTab = (file: FileItem | null): boolean => {
if (!file) return false
if (file.path.startsWith('remote-desktop://')) return true
if (file.path.startsWith('remote-git://')) return true
if (file.path.startsWith('file-transfer-panel')) return true
return matchModule(file) !== undefined
}
export const isRegularMarkdownFile = (file: FileItem | null): boolean => {
return !isSpecialTab(file) && !!file && isMarkdownFile(file.name)
}
export const getDerivedEditState = (selectedFilePath: string | null) => {
const { tabs } = useTabStore.getState()
const tab = selectedFilePath ? tabs.get(selectedFilePath) : undefined
const isEditing = tab?.isEditing || false
const hasUnsavedChanges = tab ? tab.unsavedContent !== tab.content : false
const content = isEditing ? (tab?.unsavedContent || '') : (tab?.content || '')
const wordCount = content.replace(/\s/g, '').length
return {
isCurrentTabEditing: isEditing,
hasCurrentTabUnsavedChanges: hasUnsavedChanges,
currentContent: content,
currentWordCount: wordCount,
isLoading: tab?.loading || false,
isLoaded: tab?.loaded || false,
}
}