- 在 saveContent 中缓存 unsavedContent,避免 async 期间的竞态条件 - 在 useMarkdownLogic 中添加 lastContentRef 跟踪内容变化,防止不必要的编辑器更新
451 lines
12 KiB
TypeScript
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,
|
|
}
|
|
}
|