251 lines
7.1 KiB
TypeScript
251 lines
7.1 KiB
TypeScript
/**
|
||
* 多文件预览 Tab 管理 Composable
|
||
* 管理预览 Tab 列表、激活状态、内容缓存、会话持久化
|
||
*/
|
||
|
||
import { ref, computed, watch } from 'vue'
|
||
import { STORAGE_KEYS } from '@/utils/constants'
|
||
import { connectionManager } from '@/api/connection-manager'
|
||
import type { FileItem } from '@/types/file-system'
|
||
|
||
export interface PreviewTab {
|
||
/** 唯一标识(基于路径) */
|
||
id: string
|
||
/** 文件信息 */
|
||
fileItem: FileItem
|
||
/** 所属连接 profileId */
|
||
profileId?: string
|
||
/** 缓存的预览 URL */
|
||
previewUrl: string
|
||
/** 缓存的文件内容 */
|
||
fileContent: string
|
||
/** 缓存的原始内容 */
|
||
originalContent: string
|
||
/** 缓存的编辑模式状态 */
|
||
isEditMode: boolean
|
||
/** 缓存的渲染内容 (HTML/Markdown) */
|
||
rendered: string
|
||
/** 是否已加载过内容 */
|
||
loaded: boolean
|
||
}
|
||
|
||
/** 持久化存储的精简结构 */
|
||
interface PersistedTab {
|
||
path: string
|
||
active: boolean
|
||
profileId?: string
|
||
/** 未保存的内容(有修改时才存) */
|
||
unsavedContent?: string
|
||
originalContent?: string
|
||
isEditMode?: boolean
|
||
}
|
||
|
||
const MAX_TABS = 10
|
||
const STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.MULTI_PREVIEW_TABS
|
||
|
||
interface UnsavedEntry {
|
||
content: string
|
||
original: string
|
||
editMode: boolean
|
||
}
|
||
|
||
interface RestoredSession {
|
||
paths: string[]
|
||
activePath: string | null
|
||
profileMap: Map<string, string | undefined>
|
||
unsavedMap: Map<string, UnsavedEntry>
|
||
}
|
||
|
||
function pathToId(path: string): string {
|
||
const normalized = path.replace(/\\/g, '/')
|
||
return connectionManager.isRemote() ? normalized : normalized.toLowerCase()
|
||
}
|
||
|
||
export function isDirty(tab: PreviewTab): boolean {
|
||
return !!(tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent)
|
||
}
|
||
|
||
export function useMultiPreview() {
|
||
const tabs = ref<PreviewTab[]>([])
|
||
const activeTabId = ref<string | null>(null)
|
||
|
||
const activeTab = computed<PreviewTab | null>(() => {
|
||
if (!activeTabId.value) return null
|
||
return tabs.value.find(t => t.id === activeTabId.value) || null
|
||
})
|
||
|
||
/** 创建一个新 tab */
|
||
const createTab = (fileItem: FileItem, profileId?: string): PreviewTab => ({
|
||
id: pathToId(fileItem.path),
|
||
fileItem,
|
||
profileId,
|
||
previewUrl: '',
|
||
fileContent: '',
|
||
originalContent: '',
|
||
isEditMode: false,
|
||
rendered: '',
|
||
loaded: false
|
||
})
|
||
|
||
// ========== 持久化 ==========
|
||
|
||
/** 从 localStorage 恢复会话 */
|
||
const restoreSession = (): RestoredSession => {
|
||
const unsavedMap = new Map<string, UnsavedEntry>()
|
||
const profileMap = new Map<string, string | undefined>()
|
||
let activePath: string | null = null
|
||
const paths: string[] = []
|
||
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY)
|
||
if (!raw) return { paths, activePath, profileMap, unsavedMap }
|
||
|
||
const persisted: PersistedTab[] = JSON.parse(raw)
|
||
if (!Array.isArray(persisted)) return { paths, activePath, profileMap, unsavedMap }
|
||
|
||
for (const p of persisted) {
|
||
if (!p.path) continue
|
||
paths.push(p.path)
|
||
profileMap.set(pathToId(p.path), p.profileId)
|
||
if (p.active) activePath = p.path
|
||
if (p.unsavedContent !== undefined) {
|
||
unsavedMap.set(pathToId(p.path), {
|
||
content: p.unsavedContent,
|
||
original: p.originalContent || '',
|
||
editMode: p.isEditMode || false
|
||
})
|
||
}
|
||
}
|
||
} catch {
|
||
localStorage.removeItem(STORAGE_KEY)
|
||
}
|
||
|
||
return { paths, activePath, profileMap, unsavedMap }
|
||
}
|
||
|
||
/** 保存会话到 localStorage */
|
||
const persistSession = () => {
|
||
const persisted: PersistedTab[] = tabs.value.map(tab => {
|
||
const hasUnsaved = isDirty(tab)
|
||
// 限制存储大小,超过 100KB 的内容不存入 localStorage
|
||
const canSave = hasUnsaved && tab.fileContent.length <= 100_000
|
||
return {
|
||
path: tab.fileItem.path,
|
||
active: tab.id === activeTabId.value,
|
||
profileId: tab.profileId,
|
||
unsavedContent: canSave ? tab.fileContent : undefined,
|
||
originalContent: canSave ? tab.originalContent : undefined,
|
||
isEditMode: canSave ? tab.isEditMode : undefined
|
||
}
|
||
})
|
||
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted))
|
||
} catch {
|
||
// localStorage 满了,忽略
|
||
}
|
||
}
|
||
|
||
/** 恢复未保存内容到 tab */
|
||
const applyUnsavedContent = (tab: PreviewTab, unsavedMap: Map<string, UnsavedEntry>) => {
|
||
const unsaved = unsavedMap.get(tab.id)
|
||
if (unsaved) {
|
||
tab.fileContent = unsaved.content
|
||
tab.originalContent = unsaved.original
|
||
tab.isEditMode = unsaved.editMode
|
||
tab.loaded = true // 标记为已加载,避免重新从磁盘读取覆盖
|
||
}
|
||
}
|
||
|
||
/** 添加或激活 tab,返回 { tab, isNew } */
|
||
const addTab = (fileItem: FileItem, profileId?: string): { tab: PreviewTab; isNew: boolean } => {
|
||
const id = pathToId(fileItem.path)
|
||
const existing = tabs.value.find(t => t.id === id)
|
||
if (existing) {
|
||
activeTabId.value = id
|
||
return { tab: existing, isNew: false }
|
||
}
|
||
|
||
// 超出上限,移除非活跃的最早 tab
|
||
if (tabs.value.length >= MAX_TABS) {
|
||
const victimIdx = tabs.value.findIndex(t => t.id !== activeTabId.value && t.id !== id)
|
||
if (victimIdx !== -1) tabs.value.splice(victimIdx, 1)
|
||
}
|
||
|
||
const tab = createTab(fileItem, profileId)
|
||
tabs.value.push(tab)
|
||
activeTabId.value = tab.id
|
||
return { tab, isNew: true }
|
||
}
|
||
|
||
/** 切换 tab */
|
||
const switchTab = (tabId: string) => {
|
||
if (tabs.value.find(t => t.id === tabId)) {
|
||
activeTabId.value = tabId
|
||
}
|
||
}
|
||
|
||
/** 关闭 tab,返回关闭后应激活的 tabId */
|
||
const removeTab = (tabId: string): string | null => {
|
||
const idx = tabs.value.findIndex(t => t.id === tabId)
|
||
if (idx === -1) return activeTabId.value
|
||
|
||
tabs.value.splice(idx, 1)
|
||
|
||
if (activeTabId.value === tabId) {
|
||
const nextIdx = Math.min(idx, tabs.value.length - 1)
|
||
const nextTab = tabs.value[nextIdx] || null
|
||
activeTabId.value = nextTab?.id || null
|
||
}
|
||
|
||
return activeTabId.value
|
||
}
|
||
|
||
/** 缓存当前 tab 的状态(切换前调用) */
|
||
const cacheCurrentTab = (state: {
|
||
previewUrl: string
|
||
fileContent: string
|
||
originalContent: string
|
||
isEditMode: boolean
|
||
rendered: string
|
||
}) => {
|
||
if (!activeTabId.value) return
|
||
const tab = tabs.value.find(t => t.id === activeTabId.value)
|
||
if (tab) {
|
||
tab.previewUrl = state.previewUrl
|
||
tab.fileContent = state.fileContent
|
||
tab.originalContent = state.originalContent
|
||
tab.isEditMode = state.isEditMode
|
||
tab.rendered = state.rendered
|
||
tab.loaded = true
|
||
}
|
||
}
|
||
|
||
/** 清空所有 tab */
|
||
const clearAll = () => {
|
||
tabs.value = []
|
||
activeTabId.value = null
|
||
}
|
||
|
||
// 监听 tabs/activeTabId 变化,防抖持久化
|
||
let _persistTimer: ReturnType<typeof setTimeout> | null = null
|
||
watch([tabs, activeTabId], () => {
|
||
if (_persistTimer) clearTimeout(_persistTimer)
|
||
_persistTimer = setTimeout(persistSession, 1000)
|
||
}, { deep: true })
|
||
|
||
return {
|
||
tabs,
|
||
activeTabId,
|
||
activeTab,
|
||
addTab,
|
||
switchTab,
|
||
removeTab,
|
||
cacheCurrentTab,
|
||
clearAll,
|
||
persistSession,
|
||
restoreSession,
|
||
applyUnsavedContent
|
||
}
|
||
}
|