Initial commit
This commit is contained in:
446
src/stores/tabStore.ts
Normal file
446
src/stores/tabStore.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
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
|
||||
|
||||
await saveFileContent(filePath, tab.unsavedContent)
|
||||
|
||||
set((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
const currentTab = newTabs.get(filePath)
|
||||
if (currentTab) {
|
||||
newTabs.set(filePath, {
|
||||
...currentTab,
|
||||
content: currentTab.unsavedContent,
|
||||
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
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user