新增:多文件预览Tab系统+脏标记+关闭确认+路径黑名单优化
- useMultiPreview composable管理多Tab状态、会话持久化 - 面包屑状态dot移除 - 放开Program Files目录访问限制
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
|
||||
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
||||
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
||||
<span :class="['dot', state]" />
|
||||
<span class="label">{{ label }}</span>
|
||||
|
||||
<div v-if="showMenu" class="menu" @click.stop>
|
||||
|
||||
@@ -360,6 +360,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 多文件预览浮动 Tab 栏 -->
|
||||
<div
|
||||
v-if="previewTabs.length > 1"
|
||||
class="preview-tabs"
|
||||
>
|
||||
<div
|
||||
v-for="tab in previewTabs"
|
||||
:key="tab.id"
|
||||
class="preview-tab"
|
||||
:class="{ active: tab.id === activeTabId }"
|
||||
@click="emit('switchTab', tab.id)"
|
||||
@mousedown.middle.prevent="emit('closeTab', tab.id)"
|
||||
>
|
||||
<span class="tab-icon">
|
||||
{{ getFileTypeIcon(tab.fileItem.name) }}
|
||||
<i v-if="isDirty(tab)" class="tab-dirty"></i>
|
||||
</span>
|
||||
<span class="tab-name" :title="tab.fileItem.name">{{ tab.fileItem.name }}</span>
|
||||
<div class="tab-actions">
|
||||
<button
|
||||
class="tab-close"
|
||||
@click.stop="emit('closeTab', tab.id)"
|
||||
title="关闭"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -373,6 +401,8 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
import { getFileCategory } from '@/utils/fileTypeHelpers'
|
||||
import { isDirty } from '../composables/useMultiPreview'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||
@@ -435,10 +465,26 @@ interface Emits {
|
||||
(e: 'imageLoad', dimensions: string): void
|
||||
(e: 'imageError'): void
|
||||
(e: 'openLocalFile', link: string): void
|
||||
(e: 'switchTab', tabId: string): void
|
||||
(e: 'closeTab', tabId: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 多 tab 数据
|
||||
const previewTabs = computed(() => props.config.previewTabs || [])
|
||||
const activeTabId = computed(() => props.config.activeTabId || null)
|
||||
|
||||
// 文件类型图标(基于已有分类逻辑)
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
image: '🖼️', video: '🎬', audio: '🎵', pdf: '📕',
|
||||
html: '🌐', markdown: '📝', text: '⚡', binary: '📦',
|
||||
unknown: '📄'
|
||||
}
|
||||
const getFileTypeIcon = (filename: string): string => {
|
||||
return CATEGORY_ICONS[getFileCategory(filename)] || '📄'
|
||||
}
|
||||
|
||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||
function resolveHtmlPreviewBase(): string {
|
||||
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
||||
@@ -868,6 +914,7 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-editor-panel:fullscreen {
|
||||
@@ -1430,4 +1477,129 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
|
||||
color: var(--hljs-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ========== 多文件预览浮动 Tab 栏 ========== */
|
||||
|
||||
.preview-tabs {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
z-index: 20;
|
||||
padding: 6px 4px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.preview-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 56px;
|
||||
padding: 6px 4px 4px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: color-mix(in srgb, var(--color-bg-3) 50%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
|
||||
border-right: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.preview-tabs:hover .preview-tab {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-tab:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.preview-tab.active {
|
||||
background: var(--color-fill-2);
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.preview-tab.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: rgb(var(--primary-6));
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-dirty {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -14px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgb(var(--warning-6));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-2);
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-tab.active .tab-name {
|
||||
color: rgb(var(--primary-6));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 2px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.preview-tab:hover .tab-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-4);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab-close:hover:not(:disabled) {
|
||||
background: var(--color-fill-3);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 多文件预览 Tab 管理 Composable
|
||||
* 管理预览 Tab 列表、激活状态、内容缓存、会话持久化
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface PreviewTab {
|
||||
/** 唯一标识(基于路径) */
|
||||
id: string
|
||||
/** 文件信息 */
|
||||
fileItem: FileItem
|
||||
/** 缓存的预览 URL */
|
||||
previewUrl: string
|
||||
/** 缓存的文件内容 */
|
||||
fileContent: string
|
||||
/** 缓存的原始内容 */
|
||||
originalContent: string
|
||||
/** 缓存的编辑模式状态 */
|
||||
isEditMode: boolean
|
||||
/** 缓存的渲染内容 (HTML/Markdown) */
|
||||
rendered: string
|
||||
/** 是否已加载过内容 */
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
/** 持久化存储的精简结构 */
|
||||
interface PersistedTab {
|
||||
path: string
|
||||
active: boolean
|
||||
/** 未保存的内容(有修改时才存) */
|
||||
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
|
||||
unsavedMap: Map<string, UnsavedEntry>
|
||||
}
|
||||
|
||||
function pathToId(path: string): string {
|
||||
return path.replace(/\\/g, '/').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): PreviewTab => ({
|
||||
id: pathToId(fileItem.path),
|
||||
fileItem,
|
||||
previewUrl: '',
|
||||
fileContent: '',
|
||||
originalContent: '',
|
||||
isEditMode: false,
|
||||
rendered: '',
|
||||
loaded: false
|
||||
})
|
||||
|
||||
// ========== 持久化 ==========
|
||||
|
||||
/** 从 localStorage 恢复会话 */
|
||||
const restoreSession = (): RestoredSession => {
|
||||
const unsavedMap = new Map<string, UnsavedEntry>()
|
||||
let activePath: string | null = null
|
||||
const paths: string[] = []
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { paths, activePath, unsavedMap }
|
||||
|
||||
const persisted: PersistedTab[] = JSON.parse(raw)
|
||||
if (!Array.isArray(persisted)) return { paths, activePath, unsavedMap }
|
||||
|
||||
for (const p of persisted) {
|
||||
if (!p.path) continue
|
||||
paths.push(p.path)
|
||||
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, unsavedMap }
|
||||
}
|
||||
|
||||
/** 保存会话到 localStorage */
|
||||
const persistSession = () => {
|
||||
const persisted: PersistedTab[] = tabs.value.map(tab => {
|
||||
const hasUnsaved = tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent
|
||||
// 限制存储大小,超过 100KB 的内容不存入 localStorage
|
||||
const canSave = hasUnsaved && tab.fileContent.length <= 100_000
|
||||
return {
|
||||
path: tab.fileItem.path,
|
||||
active: tab.id === activeTabId.value,
|
||||
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): { 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
:config="sidebarConfig"
|
||||
@open-favorite="handleOpenFavorite"
|
||||
@remove-favorite="handleRemoveFavorite"
|
||||
@toggle-pin="handleTogglePin"
|
||||
@toggle-pin="handleToggleFavPin"
|
||||
@long-press-start="handleLongPressStart"
|
||||
@long-press-cancel="handleLongPressCancel"
|
||||
@drag-start="handleDragStart"
|
||||
@@ -75,6 +75,8 @@
|
||||
@image-load="handleImageLoad"
|
||||
@image-error="handleImageError"
|
||||
@open-local-file="handleOpenLocalFile"
|
||||
@switch-tab="switchToTab"
|
||||
@close-tab="closeTab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,6 +132,7 @@ import { usePathNavigation } from './composables/usePathNavigation'
|
||||
import { useFilePreview } from './composables/useFilePreview'
|
||||
import { useFileEdit } from './composables/useFileEdit'
|
||||
import { useCommonPaths } from './composables/useCommonPaths'
|
||||
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
||||
|
||||
// 导入工具函数
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
@@ -311,6 +314,9 @@ const { fileContent, originalContent, isEditMode, fileContentHeight, contentChan
|
||||
currentDirectory: filePath
|
||||
})
|
||||
|
||||
// 多文件预览 Tab
|
||||
const multiPreview = useMultiPreview()
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
const favoritePaths = computed(() => favorites.value.map(f => f.path))
|
||||
@@ -413,7 +419,10 @@ const fileEditorPanelConfig = computed(() => {
|
||||
currentImageDimensions: currentImageDimensions.value,
|
||||
currentFileExtension,
|
||||
isBinaryFile: isBinaryFileRef.value,
|
||||
fileMtime: selectedFileItem.value?.modified_time || ''
|
||||
fileMtime: selectedFileItem.value?.modified_time || '',
|
||||
// 多 tab
|
||||
previewTabs: multiPreview.tabs.value,
|
||||
activeTabId: multiPreview.activeTabId.value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -580,7 +589,7 @@ const handleRemoveFavorite = (path: string) => {
|
||||
removeFav(path)
|
||||
}
|
||||
|
||||
const handleTogglePin = (path: string) => {
|
||||
const handleToggleFavPin = (path: string) => {
|
||||
togglePin(path)
|
||||
}
|
||||
|
||||
@@ -610,24 +619,14 @@ const handleDragEnd = () => {
|
||||
|
||||
// 文件列表事件
|
||||
const handleFileClick = async (file: FileItem) => {
|
||||
// 正常文件系统浏览
|
||||
if (file.isDir) {
|
||||
// 目录:使用 navigate 函数,确保历史记录正确更新
|
||||
await navigate(file.path)
|
||||
} else {
|
||||
// 文件:选中并读取内容
|
||||
selectedFileItem.value = file
|
||||
loadFileContent(file.path)
|
||||
openFileAsTab(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDoubleClick = async (file: FileItem) => {
|
||||
if (file.isDir) {
|
||||
await navigate(file.path)
|
||||
} else {
|
||||
selectFile(file.path)
|
||||
}
|
||||
}
|
||||
const handleFileDoubleClick = handleFileClick
|
||||
|
||||
const handleToggleFavorite = (file: FileItem) => {
|
||||
toggleFav(file)
|
||||
@@ -1064,40 +1063,110 @@ const isMediaPreviewable = (filename: string): boolean => {
|
||||
isPdfFile(filename)
|
||||
}
|
||||
|
||||
const selectFile = async (path: string) => {
|
||||
// 后端已统一返回 / 路径,直接比较
|
||||
const normalizedPath = path.toLowerCase()
|
||||
/** 激活 tab:设置选中项 + 加载或恢复内容 */
|
||||
const activateTab = async (tab: PreviewTab) => {
|
||||
selectedFileItem.value = tab.fileItem
|
||||
if (tab.loaded) {
|
||||
restoreTabState(tab)
|
||||
} else {
|
||||
await loadFileContent(tab.fileItem.path)
|
||||
tab.loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试在当前文件列表中查找
|
||||
const file = fileList.value.find(f => {
|
||||
const normalizedFilePath = f.path.toLowerCase()
|
||||
return normalizedFilePath === normalizedPath
|
||||
/** 文件 → 添加到 tab 并激活 */
|
||||
const openFileAsTab = async (file: FileItem) => {
|
||||
cacheCurrentTabState()
|
||||
const { tab } = multiPreview.addTab(file)
|
||||
await activateTab(tab)
|
||||
}
|
||||
|
||||
/** 切换 tab */
|
||||
const switchToTab = async (tabId: string) => {
|
||||
cacheCurrentTabState()
|
||||
multiPreview.switchTab(tabId)
|
||||
const tab = multiPreview.activeTab.value
|
||||
if (tab) await activateTab(tab)
|
||||
}
|
||||
|
||||
/** 关闭 tab */
|
||||
const closeTab = (tabId: string) => {
|
||||
const tab = multiPreview.tabs.value.find(t => t.id === tabId)
|
||||
|
||||
if (tab && isDirty(tab)) {
|
||||
Modal.confirm({
|
||||
title: '未保存的修改',
|
||||
content: `「${tab!.fileItem.name}」有未保存的修改,关闭后将丢失。`,
|
||||
okText: '关闭',
|
||||
cancelText: '取消',
|
||||
onOk: () => doCloseTab(tabId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
doCloseTab(tabId)
|
||||
}
|
||||
|
||||
const doCloseTab = (tabId: string) => {
|
||||
const newActiveId = multiPreview.removeTab(tabId)
|
||||
if (newActiveId) {
|
||||
const tab = multiPreview.activeTab.value
|
||||
if (tab) activateTab(tab)
|
||||
} else {
|
||||
selectedFileItem.value = null
|
||||
clearContent()
|
||||
}
|
||||
}
|
||||
|
||||
/** 缓存当前 tab 的编辑状态 */
|
||||
const cacheCurrentTabState = () => {
|
||||
multiPreview.cacheCurrentTab({
|
||||
previewUrl: previewUrl.value,
|
||||
fileContent: fileContent.value,
|
||||
originalContent: originalContent.value,
|
||||
isEditMode: isEditMode.value,
|
||||
rendered: computeRendered.value
|
||||
})
|
||||
}
|
||||
|
||||
/** 活跃 tab 内容实时同步(脏标记用,完整缓存由 cacheCurrentTabState 负责) */
|
||||
watch([fileContent, originalContent], () => {
|
||||
const tab = multiPreview.activeTab.value
|
||||
if (tab) {
|
||||
tab.fileContent = fileContent.value
|
||||
tab.originalContent = originalContent.value
|
||||
}
|
||||
})
|
||||
|
||||
/** 从 tab 缓存恢复编辑状态 */
|
||||
const restoreTabState = (tab: PreviewTab) => {
|
||||
previewUrl.value = tab.previewUrl
|
||||
fileContent.value = tab.fileContent
|
||||
originalContent.value = tab.originalContent
|
||||
isEditMode.value = tab.isEditMode
|
||||
fileVersion.value++
|
||||
}
|
||||
|
||||
const selectFile = async (path: string) => {
|
||||
const normalizedPath = path.toLowerCase()
|
||||
|
||||
const file = fileList.value.find(f => f.path.toLowerCase() === normalizedPath)
|
||||
|
||||
if (file) {
|
||||
// 在当前列表中找到的文件
|
||||
selectedFileItem.value = file
|
||||
await openFileAsTab(file)
|
||||
} else {
|
||||
// 不在当前列表中的文件(如收藏夹中的其他目录文件)
|
||||
// 构造一个基本的 FileItem 对象
|
||||
const fileName = getFileName(path)
|
||||
selectedFileItem.value = {
|
||||
await openFileAsTab({
|
||||
path,
|
||||
name: fileName,
|
||||
isDir: false,
|
||||
size: 0,
|
||||
modified_time: '',
|
||||
is_favorite: isFavorite(path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载文件内容
|
||||
await loadFileContent(path)
|
||||
|
||||
// 记住上次打开的文件
|
||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
|
||||
}
|
||||
|
||||
const loadFileContent = async (path: string) => {
|
||||
try {
|
||||
updatePreviewUrl(path)
|
||||
@@ -1309,7 +1378,31 @@ onMounted(async () => {
|
||||
await loadDirectory(startPath)
|
||||
}
|
||||
|
||||
// 恢复上次打开的文件
|
||||
// 恢复多文件预览会话
|
||||
const session = multiPreview.restoreSession()
|
||||
if (session.paths.length > 0 && !connectionManager.isRemote()) {
|
||||
for (const path of session.paths) {
|
||||
const name = path.split(/[/\\]/).pop() || path
|
||||
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' })
|
||||
if (path === session.activePath || path.replace(/\\/g, '/').toLowerCase() === (session.activePath || '').replace(/\\/g, '/').toLowerCase()) {
|
||||
multiPreview.activeTabId.value = tab.id
|
||||
}
|
||||
multiPreview.applyUnsavedContent(tab, session.unsavedMap)
|
||||
}
|
||||
|
||||
// 加载激活 tab 的内容
|
||||
const active = multiPreview.activeTab.value
|
||||
if (active) {
|
||||
selectedFileItem.value = active.fileItem
|
||||
if (active.loaded) {
|
||||
restoreTabState(active)
|
||||
} else {
|
||||
await loadFileContent(active.fileItem.path)
|
||||
active.loaded = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 无会话记录,回退到旧逻辑:恢复上次打开的单个文件
|
||||
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
||||
if (lastFile) {
|
||||
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
@@ -1322,6 +1415,7 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘快捷键
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
@@ -1335,6 +1429,9 @@ onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('click', hideContextMenu)
|
||||
window.removeEventListener('paste', handlePaste)
|
||||
// 应用关闭时立即持久化当前会话
|
||||
cacheCurrentTabState()
|
||||
multiPreview.persistSession()
|
||||
})
|
||||
|
||||
// 键盘快捷键
|
||||
|
||||
@@ -203,6 +203,10 @@ export interface FileEditorPanelConfig {
|
||||
isBinaryFile: boolean
|
||||
/** 文件修改时间(用于检测外部变更) */
|
||||
fileMtime: string
|
||||
/** 多文件预览 Tab 列表 */
|
||||
previewTabs?: any[]
|
||||
/** 当前激活的 Tab ID */
|
||||
activeTabId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ export const STORAGE_KEYS = {
|
||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||
MULTI_PREVIEW_TABS: 'app-filesystem-multi-preview-tabs', // 多文件预览 tab 会话
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
|
||||
@@ -123,12 +123,9 @@ func (v *DefaultPathValidator) checkWindowsSystemPaths(path string) *ValidationE
|
||||
if len(lowerPath) >= 3 && lowerPath[1] == ':' {
|
||||
driveLetter := lowerPath[0:1]
|
||||
|
||||
// 检查系统关键目录(仅保留最关键的系统目录)
|
||||
// 检查系统核心目录
|
||||
forbiddenDirs := []string{
|
||||
driveLetter + ":\\windows",
|
||||
driveLetter + ":\\program files",
|
||||
driveLetter + ":\\program files (x86)",
|
||||
driveLetter + ":\\program files (arm)",
|
||||
driveLetter + ":\\system volume information",
|
||||
driveLetter + ":\\boot",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user