1096 lines
34 KiB
Vue
1096 lines
34 KiB
Vue
<template>
|
||
<!--
|
||
文件内容预览。
|
||
- 文本/代码: highlight.js 语法高亮(按文件后缀自动选语言),git 变更可切 diff 视图
|
||
- 图片: <img> 展示(走 convertFileSrc)
|
||
- 二进制: 提示"二进制文件不支持预览"
|
||
- 顶部: 文件路径 + 大小 + Git 状态 + Diff 切换按钮
|
||
-->
|
||
<div class="file-preview">
|
||
<!-- 顶部元信息 -->
|
||
<div class="preview-header">
|
||
<div class="preview-meta-left">
|
||
<span class="preview-path" :title="filePath ?? ''">{{ filePath || '—' }}</span>
|
||
<span v-if="gitStatus" class="git-badge" :class="gitStatusClass(gitStatus)">
|
||
{{ gitStatusLabel(gitStatus) }}
|
||
</span>
|
||
</div>
|
||
<div class="preview-meta-right">
|
||
<span v-if="fileSize !== null" class="preview-size">{{ formatSize(fileSize) }}</span>
|
||
<span v-if="truncated" class="preview-truncated">⚠ {{ $t('fileExplorer.truncated') }}</span>
|
||
<!-- Diff 切换按钮(仅 git 有变更时显示) -->
|
||
<button
|
||
v-if="gitStatus"
|
||
class="diff-toggle-btn"
|
||
:class="{ active: showDiff }"
|
||
@click="toggleDiff"
|
||
:title="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
|
||
>
|
||
{{ showDiff ? '📄' : '📝' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 内容区 -->
|
||
<div class="preview-body">
|
||
<!-- 未选文件占位 -->
|
||
<div v-if="!filePath" class="preview-placeholder">
|
||
<div class="preview-placeholder-icon">📄</div>
|
||
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
|
||
</div>
|
||
|
||
<!-- 加载中(外部 diff 存在时让位于 diff 视图,历史提交的文件可能不在当前工作树) -->
|
||
<div v-else-if="loading && !isExternalDiff" class="preview-loading">
|
||
<span class="spinner"></span>
|
||
{{ $t('fileExplorer.loadingFile') }}
|
||
</div>
|
||
|
||
<!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
|
||
<div v-else-if="error && !isExternalDiff" class="preview-error">⚠ {{ error }}</div>
|
||
|
||
<!-- 二进制:展示文件信息(路径/大小/类型),替代"不支持预览"空态 -->
|
||
<div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
|
||
<span class="binary-icon">🗂️</span>
|
||
<p class="binary-title">{{ $t('fileExplorer.binaryInfo') }}</p>
|
||
<div class="binary-meta">
|
||
<div class="binary-meta-row">
|
||
<span class="binary-meta-label">{{ $t('fileExplorer.pathLabel') }}</span>
|
||
<span class="binary-meta-value">{{ filePath }}</span>
|
||
</div>
|
||
<div class="binary-meta-row">
|
||
<span class="binary-meta-label">{{ $t('fileExplorer.sizeLabel') }}</span>
|
||
<span class="binary-meta-value">{{ formatSize(fileSize ?? 0) }}</span>
|
||
</div>
|
||
<div class="binary-meta-row">
|
||
<span class="binary-meta-label">{{ $t('fileExplorer.typeLabel') }}</span>
|
||
<span class="binary-meta-value">{{ fileType }}</span>
|
||
</div>
|
||
<div class="binary-meta-row">
|
||
<span class="binary-meta-label">{{ $t('fileExplorer.binaryLabel') }}</span>
|
||
<span class="binary-meta-value">{{ $t('fileExplorer.binaryNotSupported') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图片(外部 diff 存在时让位) -->
|
||
<div v-else-if="isImage && !isExternalDiff" class="preview-image">
|
||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||
</div>
|
||
|
||
<!-- Diff 视图(外部注入的 diff 优先展示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
||
<div v-else-if="showDiff && diffViewContent" class="preview-diff">
|
||
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
||
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
|
||
<span class="diff-line-prefix">{{ ln.prefix }}</span>
|
||
<span class="diff-line-text">{{ ln.text }}</span>
|
||
</div>
|
||
<div v-if="diffLoading" class="diff-loading">
|
||
<span class="spinner"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮;mermaid 块渲染后转成图) -->
|
||
<div v-else-if="isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd"></div>
|
||
|
||
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
||
<div v-else ref="codeScrollRef" class="preview-code-scroll">
|
||
<div class="preview-line-numbers" aria-hidden="true">
|
||
<div v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div>
|
||
</div>
|
||
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
|
||
</div>
|
||
|
||
<!-- 符号概览:悬浮右上角(折叠态为 ☰ 按钮,点击弹出分组面板) -->
|
||
<div v-if="showOutline" class="outline-overlay">
|
||
<button
|
||
v-if="outlineCollapsed"
|
||
class="outline-overlay-btn"
|
||
:title="$t('fileExplorer.symbols')"
|
||
@click="outlineCollapsed = false"
|
||
>
|
||
☰ <span class="outline-overlay-count">{{ outlineSymbols.length }}</span>
|
||
</button>
|
||
<div v-else class="preview-outline">
|
||
<div class="preview-outline-head" @click="outlineCollapsed = true">
|
||
<span class="preview-outline-title">☰ {{ $t('fileExplorer.symbols') }}</span>
|
||
<span class="preview-outline-count">{{ outlineSymbols.length }}</span>
|
||
<span class="preview-outline-spacer"></span>
|
||
<span class="preview-outline-toggle">✕</span>
|
||
</div>
|
||
<div class="preview-outline-body">
|
||
<!-- 分组展示:类型/函数/变量/模块,组内按行号(DFS 已保序) -->
|
||
<div v-for="grp in outlineGroups" :key="grp.key" class="outline-group">
|
||
<div class="outline-group-head">
|
||
<span class="outline-group-label" :class="'grp-' + grp.key">{{ $t(grp.labelKey) }}</span>
|
||
<span class="outline-group-count">{{ grp.items.length }}</span>
|
||
</div>
|
||
<div
|
||
v-for="sym in grp.items"
|
||
:key="sym.line"
|
||
class="outline-item"
|
||
:class="['grp-' + grp.key, { 'is-active': activeOutlineLine === sym.line }]"
|
||
:title="sym.signature"
|
||
@click="gotoOutlineLine(sym.line)"
|
||
>
|
||
<span class="outline-kind">{{ outlineKindLabel(sym.kind) }}</span>
|
||
<span class="outline-name">{{ sym.name || sym.signature || '?' }}</span>
|
||
<span class="outline-line">{{ sym.line }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
|
||
import { moduleApi, type OutlineSymbol } from '@/api/module'
|
||
import hljs from 'highlight.js/lib/common'
|
||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||
import { escapeHtml } from '@/utils/html'
|
||
|
||
const props = defineProps<{
|
||
moduleId: string
|
||
filePath: string | null
|
||
moduleRootPath: string
|
||
gitStatus?: string
|
||
/** 外部注入的 diff(如 GitChanges 变更/提交历史点文件得到的该文件 diff,含历史提交的 diff)。
|
||
* 非空时优先渲染此 diff(宽预览区),不发起重复的 git diff 请求。 */
|
||
externalDiff?: string
|
||
}>()
|
||
|
||
const { } = useMarkdown()
|
||
const content = ref('')
|
||
const htmlContent = ref('')
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const isBinary = ref(false)
|
||
const fileSize = ref<number | null>(null)
|
||
const truncated = ref(false)
|
||
const imageUrl = ref<string | null>(null)
|
||
|
||
/** 符号概览(fileOutline 结果;仅支持语言 + 非 diff 视图时展示)。 */
|
||
const outlineSupported = ref(false)
|
||
const outlineSymbols = ref<OutlineSymbol[]>([])
|
||
const outlineCollapsed = ref(true)
|
||
const activeOutlineLine = ref(-1)
|
||
const codeScrollRef = ref<HTMLElement | null>(null)
|
||
|
||
/** 文件类型(从扩展名推断,用于二进制文件信息展示)。 */
|
||
const fileType = computed(() => {
|
||
if (!props.filePath) return '—'
|
||
const name = props.filePath.split('/').pop() || props.filePath
|
||
const dot = name.lastIndexOf('.')
|
||
const ext = dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
|
||
return ext.length <= 6 ? ext : 'FILE'
|
||
})
|
||
|
||
/** 行号显示 — 按源文本真实行数计算(非 highlight.js 渲染 HTML 行数)。
|
||
* 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位。
|
||
* 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染。 */
|
||
const lineCount = computed(() => {
|
||
if (!content.value) return 0
|
||
const text = content.value
|
||
// 末尾换行不计为新一行的可视行号(渲染时 <pre> 也不会显示空行)。
|
||
const norm = text.endsWith('\n') ? text.slice(0, -1) : text
|
||
if (norm === '') return 1
|
||
return norm.split('\n').length
|
||
})
|
||
|
||
/** Diff 显示控制 */
|
||
const showDiff = ref(false)
|
||
const diffContent = ref('')
|
||
const diffLoading = ref(false)
|
||
|
||
/** 是否存在外部注入的 diff(非空即视为外部 diff 模式:默认展示 diff,且 diff 优先级最高)。 */
|
||
const isExternalDiff = computed(() => !!props.externalDiff && props.externalDiff.trim().length > 0)
|
||
|
||
/** 实际展示的 diff 内容:外部注入优先,否则用本组件拉取的 git diff。 */
|
||
const diffViewContent = computed(() => (isExternalDiff.value ? props.externalDiff! : diffContent.value))
|
||
|
||
/** 外部 diff 注入时默认切到 diff 视图(点文件即看变化,无需再点 📝);清空则回到内容视图。 */
|
||
watch(() => props.externalDiff, (val) => {
|
||
showDiff.value = !!(val && val.trim().length > 0)
|
||
})
|
||
|
||
/** 请求序号守卫:loadFile/loadDiff 各自单调递增,await 返回后校验仍是"最新 seq"才写状态,
|
||
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)。
|
||
* 注:loadFile 与 loadDiff 分用两个计数器 —— 若共用一个,loadDiff 自增会让在途的 loadFile
|
||
* 变为 stale,其 finally 不再复位 loading,导致内容加载态卡死。分开后各自独立互不干扰;
|
||
* 文件切换(watch)时额外自增 diffReqSeq,使在途 diff 请求对旧文件失效。 */
|
||
let fileReqSeq = 0 // loadFile 请求序号(单调递增)
|
||
let diffReqSeq = 0 // loadDiff 请求序号(单调递增)
|
||
|
||
interface DiffLine {
|
||
type: 'add' | 'del' | 'ctx' | 'hdr'
|
||
prefix: string
|
||
text: string
|
||
oldNum: string
|
||
newNum: string
|
||
}
|
||
|
||
/** 解析 @@ -a,b +c,d @@ 头,返回旧/新行起始号 */
|
||
function parseHunkHeader(line: string): { oldStart: number; newStart: number } {
|
||
// 格式: @@ -10,7 +10,8 @@
|
||
const m = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/)
|
||
if (!m) return { oldStart: 1, newStart: 1 }
|
||
return { oldStart: parseInt(m[1], 10), newStart: parseInt(m[2], 10) }
|
||
}
|
||
|
||
const diffLines = computed<DiffLine[]>(() => {
|
||
if (!diffViewContent.value) return []
|
||
const lines: DiffLine[] = []
|
||
let oldNum = 0
|
||
let newNum = 0
|
||
for (const raw of diffViewContent.value.split('\n')) {
|
||
if (raw.startsWith('@@')) {
|
||
const h = parseHunkHeader(raw)
|
||
oldNum = h.oldStart
|
||
newNum = h.newStart
|
||
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
|
||
} else if (raw.startsWith('+')) {
|
||
lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: String(newNum++) })
|
||
} else if (raw.startsWith('-')) {
|
||
lines.push({ type: 'del', prefix: '-', text: raw.slice(1), oldNum: String(oldNum++), newNum: '' })
|
||
} else if (raw.startsWith('\\')) {
|
||
// No newline at end of file 等元信息
|
||
lines.push({ type: 'ctx', prefix: '', text: raw, oldNum: '', newNum: '' })
|
||
} else {
|
||
// 空串(diff 头前的空行)或 context 行(以空格开头)
|
||
const text = raw.startsWith(' ') ? raw.slice(1) : raw
|
||
lines.push({ type: 'ctx', prefix: ' ', text, oldNum: String(oldNum++), newNum: String(newNum++) })
|
||
}
|
||
}
|
||
return lines
|
||
})
|
||
|
||
function toggleDiff() {
|
||
showDiff.value = !showDiff.value
|
||
// 外部 diff 已由调用方提供,无需再发 git diff;仅内部模式在首次切到 diff 时才拉取
|
||
if (showDiff.value && !diffContent.value && !isExternalDiff.value) {
|
||
loadDiff()
|
||
}
|
||
}
|
||
|
||
async function loadDiff() {
|
||
if (!props.moduleId || !props.filePath) return
|
||
const seq = ++diffReqSeq // 捕获本次请求序号
|
||
diffLoading.value = true
|
||
try {
|
||
const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath)
|
||
if (seq !== diffReqSeq) return // 旧响应晚到,丢弃不覆盖
|
||
diffContent.value = res.diff || ''
|
||
} catch {
|
||
if (seq !== diffReqSeq) return
|
||
diffContent.value = ''
|
||
} finally {
|
||
if (seq === diffReqSeq) diffLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 外部文件变更 — 通过 df-data-changed 事件接收 AI 工具写入通知,被动触发重载。
|
||
* 无轮询,零性能开销。外部编辑器修改需用户点击刷新按钮。 */
|
||
|
||
/** 文件后缀 → highlight.js 语言名映射。 */
|
||
const EXT_LANG: Record<string, string> = {
|
||
rs: 'rust', ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||
vue: 'xml', html: 'xml', xml: 'xml', css: 'css', scss: 'scss',
|
||
json: 'json', toml: 'ini', yaml: 'yaml', yml: 'yaml',
|
||
md: 'markdown', py: 'python', go: 'go', java: 'java',
|
||
sh: 'bash', sql: 'sql',
|
||
}
|
||
|
||
/** 从文件路径推断 highlight.js 语言 class。 */
|
||
const hljsClass = computed(() => {
|
||
if (!props.filePath) return 'language-plaintext'
|
||
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
|
||
const lang = EXT_LANG[ext] || 'plaintext'
|
||
return `language-${lang}`
|
||
})
|
||
|
||
/** 图片后缀白名单。 */
|
||
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']
|
||
|
||
/** Markdown 后缀判定。 */
|
||
const isMarkdown = computed(() => {
|
||
if (!props.filePath) return false
|
||
return props.filePath.toLowerCase().endsWith('.md')
|
||
})
|
||
|
||
/** Markdown 渲染(复用 useRendered,含 marked + DOMPurify + 代码高亮)。 */
|
||
const { rendered: renderedMd, ensureLoaded: ensureMdLoaded } = useRendered(() => content.value)
|
||
|
||
// ═══ Mermaid 渲染:markdown 里的 ```mermaid 块渲染为图(按需动态 import,不增主 bundle)═══
|
||
const previewMdRef = ref<HTMLElement | null>(null)
|
||
let mermaidInstance: any = null
|
||
let mermaidSeq = 0
|
||
async function renderMermaidBlocks() {
|
||
const el = previewMdRef.value
|
||
if (!el) return
|
||
const targetPath = props.filePath // 捕获渲染目标文件,防跨文件滞后注入
|
||
const blocks = el.querySelectorAll<HTMLElement>('pre code.language-mermaid')
|
||
if (blocks.length === 0) return
|
||
if (!mermaidInstance) {
|
||
const mod = await import('mermaid')
|
||
mermaidInstance = mod.default
|
||
// securityLevel 'strict':mermaid 自行转义渲染输出(禁 raw HTML/URL 注入),防 XSS。
|
||
// 代价:node 内嵌 HTML label(如 <br/>/HTML 实体)在 'strict' 下被当纯文本显示,
|
||
// 原 'loose' 能渲染的这类图例会回归为文本(安全优先,属预期收紧)。
|
||
mermaidInstance.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'strict' })
|
||
}
|
||
// 懒加载 mermaid 期间文件可能已切换(旧 DOM 已卸载),直接丢弃本轮
|
||
if (props.filePath !== targetPath) return
|
||
for (const block of Array.from(blocks)) {
|
||
if (block.closest('.mermaid-rendered')) continue
|
||
const code = (block.textContent ?? '').trim()
|
||
if (!code) continue
|
||
try {
|
||
const { svg } = await mermaidInstance.render(`mermaid-file-${++mermaidSeq}`, code)
|
||
if (props.filePath !== targetPath) return // 渲染期间文件已切换,丢弃旧图不注入
|
||
const holder = document.createElement('div')
|
||
holder.className = 'mermaid-rendered'
|
||
holder.innerHTML = svg
|
||
block.closest('pre')?.replaceWith(holder)
|
||
} catch (e) {
|
||
if (props.filePath !== targetPath) return
|
||
console.error('[FilePreview] mermaid 渲染失败:', e)
|
||
}
|
||
}
|
||
}
|
||
watch(renderedMd, async () => {
|
||
await nextTick()
|
||
await renderMermaidBlocks()
|
||
}, { immediate: true })
|
||
|
||
const isImage = computed(() => {
|
||
if (!props.filePath) return false
|
||
const lower = props.filePath.toLowerCase()
|
||
return IMAGE_EXT.some((ext) => lower.endsWith(ext))
|
||
})
|
||
|
||
/** 符号概览面板是否展示:支持语言 + 有符号 + 非 diff 视图。 */
|
||
const showOutline = computed(() =>
|
||
outlineSupported.value && outlineSymbols.value.length > 0 && !showDiff.value && !!props.filePath
|
||
)
|
||
|
||
/** outline 符号 kind → 短标签(顶部 chips 显示)。 */
|
||
const KIND_LABELS: Record<string, string> = {
|
||
function_item: 'fn', function_declaration: 'fn', function_definition: 'def',
|
||
method_declaration: 'fn', method_definition: 'fn',
|
||
struct_item: 'struct', class_declaration: 'class', class_definition: 'class',
|
||
enum_item: 'enum', enum_declaration: 'enum', impl_item: 'impl',
|
||
trait_item: 'trait', interface_declaration: 'interface', mod_item: 'mod',
|
||
const_item: 'const', static_item: 'static', type_item: 'type',
|
||
macro_definition: 'macro', variable_declarator: 'let', type_declaration: 'type',
|
||
constructor_declaration: 'ctor', record_declaration: 'record',
|
||
annotation_type_declaration: '@interface',
|
||
}
|
||
function outlineKindLabel(kind: string): string {
|
||
return KIND_LABELS[kind] || kind
|
||
}
|
||
|
||
/** kind → 展示分组(对齐 IDE 大纲语义:类型/函数/变量/模块)。 */
|
||
const KIND_GROUPS: Record<string, string> = {
|
||
// 类型
|
||
struct_item: 'type', class_declaration: 'type', class_definition: 'type',
|
||
enum_item: 'type', enum_declaration: 'type', trait_item: 'type',
|
||
interface_declaration: 'type', type_item: 'type', type_declaration: 'type',
|
||
impl_item: 'type', record_declaration: 'type', annotation_type_declaration: 'type',
|
||
// 函数
|
||
function_item: 'fn', function_declaration: 'fn', function_definition: 'fn',
|
||
method_declaration: 'fn', method_definition: 'fn', constructor_declaration: 'fn',
|
||
macro_definition: 'fn',
|
||
// 变量
|
||
const_item: 'var', static_item: 'var', variable_declarator: 'var',
|
||
// 模块
|
||
mod_item: 'mod',
|
||
}
|
||
/** 分组展示顺序。 */
|
||
const GROUP_ORDER = ['type', 'fn', 'var', 'mod', 'other']
|
||
/** 分组 → i18n key(模板 $t 解析)。 */
|
||
const GROUP_LABEL_KEY: Record<string, string> = {
|
||
type: 'fileExplorer.outlineGroupType',
|
||
fn: 'fileExplorer.outlineGroupFn',
|
||
var: 'fileExplorer.outlineGroupVar',
|
||
mod: 'fileExplorer.outlineGroupMod',
|
||
other: 'fileExplorer.outlineGroupOther',
|
||
}
|
||
function kindGroup(kind: string): string {
|
||
return KIND_GROUPS[kind] || 'other'
|
||
}
|
||
|
||
/** 按分组聚合 outline 符号(组序固定,组内按行号)。 */
|
||
const outlineGroups = computed(() => {
|
||
const buckets = new Map<string, OutlineSymbol[]>()
|
||
for (const sym of outlineSymbols.value) {
|
||
const g = kindGroup(sym.kind)
|
||
if (!buckets.has(g)) buckets.set(g, [])
|
||
buckets.get(g)!.push(sym)
|
||
}
|
||
return GROUP_ORDER
|
||
.filter((g) => buckets.has(g))
|
||
.map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! }))
|
||
})
|
||
|
||
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12.5px × 1.55)。 */
|
||
function gotoOutlineLine(line: number) {
|
||
activeOutlineLine.value = line
|
||
const el = codeScrollRef.value
|
||
if (!el) return
|
||
const lineHeight = 12.5 * 1.55
|
||
const topPad = 14 // 对齐 .preview-code / .preview-line-numbers 的 padding-top
|
||
el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' })
|
||
}
|
||
|
||
/** 拉取文件符号概览(独立 try/catch:outline 失败不阻断文件内容加载)。 */
|
||
async function loadOutline(seq: number) {
|
||
if (!props.moduleId || !props.filePath) {
|
||
outlineSupported.value = false
|
||
outlineSymbols.value = []
|
||
return
|
||
}
|
||
try {
|
||
const res = await moduleApi.fileOutline(props.moduleId, props.filePath)
|
||
if (seq !== fileReqSeq) return // 文件已切换,丢弃旧 outline
|
||
outlineSupported.value = res.supported
|
||
outlineSymbols.value = res.symbols || []
|
||
} catch {
|
||
if (seq !== fileReqSeq) return
|
||
outlineSupported.value = false
|
||
outlineSymbols.value = []
|
||
}
|
||
}
|
||
|
||
/** 拉文件内容 + 高亮渲染。 */
|
||
async function loadFile() {
|
||
const seq = ++fileReqSeq // 捕获本次请求序号(文件切换会使旧请求失效)
|
||
if (!props.filePath) {
|
||
content.value = ''
|
||
htmlContent.value = ''
|
||
imageUrl.value = null
|
||
outlineSupported.value = false
|
||
outlineSymbols.value = []
|
||
activeOutlineLine.value = -1
|
||
return
|
||
}
|
||
loading.value = true
|
||
error.value = null
|
||
// Markdown 文件触发预热(确保 marked 就绪,useRendered computed 据此渲染)
|
||
if (props.filePath.toLowerCase().endsWith('.md')) {
|
||
ensureMdLoaded()
|
||
}
|
||
if (imageUrl.value) {
|
||
URL.revokeObjectURL(imageUrl.value)
|
||
imageUrl.value = null
|
||
}
|
||
// 重置上一文件的符号概览(新文件结果到达后再填充)
|
||
outlineSupported.value = false
|
||
outlineSymbols.value = []
|
||
activeOutlineLine.value = -1
|
||
try {
|
||
// 内容与符号概览并行拉取(readModuleFile 与 fileOutline 同时发起)
|
||
const [res] = await Promise.all([
|
||
moduleApi.readModuleFile(props.moduleId, props.filePath),
|
||
loadOutline(seq),
|
||
])
|
||
if (seq !== fileReqSeq) return // 旧响应晚到,丢弃不覆盖新文件内容
|
||
fileSize.value = res.size
|
||
truncated.value = res.truncated
|
||
isBinary.value = res.is_binary
|
||
if (res.is_binary) {
|
||
content.value = ''
|
||
htmlContent.value = ''
|
||
return
|
||
}
|
||
if (isImage.value) {
|
||
content.value = ''
|
||
htmlContent.value = ''
|
||
try {
|
||
const { convertFileSrc } = await import('@tauri-apps/api/core')
|
||
if (seq !== fileReqSeq) return // import 期间文件已切换,不写入旧图
|
||
const abs = joinPath(props.moduleRootPath, props.filePath)
|
||
imageUrl.value = convertFileSrc(abs)
|
||
} catch {
|
||
if (seq === fileReqSeq) imageUrl.value = null
|
||
}
|
||
} else {
|
||
content.value = res.content
|
||
// highlight.js 语法高亮:已知语言精确高亮,未知语言自动检测
|
||
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
|
||
const lang = EXT_LANG[ext]
|
||
// 先显纯文本(首屏立即),再异步高亮——大文件 hljs 高亮耗时,阻塞同步渲染会卡顿。
|
||
// 大文件跳过 highlightAuto(自动检测慢):仅精确语言高亮,未知语言纯文本(可读,速度优先)。
|
||
content.value = res.content
|
||
const raw = res.content
|
||
const isLarge = raw.length > 50_000
|
||
const hljsTask = () => {
|
||
if (fileReqSeq !== seq) return // 文件已切换,丢弃旧高亮
|
||
try {
|
||
if (lang && hljs.getLanguage(lang)) {
|
||
htmlContent.value = hljs.highlight(raw, { language: lang }).value
|
||
} else if (!isLarge) {
|
||
// 小文件未知语言才自动检测;大文件未知语言纯文本(避免 highlightAuto 卡顿)
|
||
htmlContent.value = hljs.highlightAuto(raw).value
|
||
} else {
|
||
htmlContent.value = escapeHtml(raw)
|
||
}
|
||
} catch {
|
||
htmlContent.value = escapeHtml(raw)
|
||
}
|
||
}
|
||
// 高亮延后到空闲时执行(小圈不再转):rAF 让出首帧,50KB+ 用双 rAF 延后更彻底。
|
||
requestAnimationFrame(() => {
|
||
if (isLarge) {
|
||
// 大文件再让一帧,确保首屏文本已渲染,高亮完全后台
|
||
requestAnimationFrame(hljsTask)
|
||
} else {
|
||
hljsTask()
|
||
}
|
||
})
|
||
}
|
||
} catch (e) {
|
||
if (seq !== fileReqSeq) return // 旧请求报错也不覆盖新状态
|
||
error.value = e instanceof Error ? e.message : String(e)
|
||
} finally {
|
||
if (seq === fileReqSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
function joinPath(root: string, rel: string): string {
|
||
const sep = root.includes('\\') ? '\\' : '/'
|
||
const normRel = rel.split('/').join(sep)
|
||
if (root.endsWith(sep)) return root + normRel
|
||
return root + sep + normRel
|
||
}
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||
}
|
||
|
||
function gitStatusClass(status: string): string {
|
||
const x = status.trim()
|
||
if (x === '??') return 'git-untracked'
|
||
if (x.startsWith('A')) return 'git-added'
|
||
if (x.startsWith('M')) return 'git-modified'
|
||
if (x.startsWith('D')) return 'git-deleted'
|
||
return 'git-other'
|
||
}
|
||
|
||
function gitStatusLabel(status: string): string {
|
||
const x = status.trim()
|
||
if (x === '??') return 'U'
|
||
if (x.startsWith('A')) return 'A'
|
||
if (x.startsWith('M')) return 'M'
|
||
if (x.startsWith('D')) return 'D'
|
||
return x.charAt(0) || '?'
|
||
}
|
||
|
||
watch(() => [props.moduleId, props.filePath], () => {
|
||
diffReqSeq++ // 文件切换,使在途 diff 请求失效(旧文件 diff 晚到不写入)
|
||
showDiff.value = false
|
||
diffContent.value = ''
|
||
diffLoading.value = false
|
||
loadFile()
|
||
}, { immediate: true })
|
||
|
||
onUnmounted(() => {
|
||
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.file-preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: var(--df-bg-card);
|
||
border-left: 0.5px solid var(--df-border);
|
||
}
|
||
|
||
.preview-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 8px 14px;
|
||
border-bottom: 0.5px solid var(--df-border);
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.preview-meta-left,
|
||
.preview-meta-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.preview-path {
|
||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||
color: var(--df-text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 460px;
|
||
}
|
||
|
||
.preview-size { color: var(--df-text-dim); flex-shrink: 0; }
|
||
.preview-truncated { color: #f0a020; flex-shrink: 0; }
|
||
|
||
.git-badge {
|
||
flex-shrink: 0;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 1px 5px;
|
||
border-radius: 8px;
|
||
min-width: 14px;
|
||
text-align: center;
|
||
}
|
||
|
||
.git-modified { background: rgba(255, 165, 0, 0.18); color: #f0a020; }
|
||
.git-added { background: rgba(60, 180, 80, 0.18); color: #4caf50; }
|
||
.git-untracked { background: rgba(150, 150, 150, 0.18); color: #999; }
|
||
.git-deleted { background: rgba(220, 60, 60, 0.18); color: #e05050; }
|
||
.git-other { background: rgba(100, 150, 220, 0.18); color: #6a9adc; }
|
||
|
||
.preview-body {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
padding: 0;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative; /* 符号悬浮层定位锚点 */
|
||
}
|
||
|
||
.preview-placeholder,
|
||
.preview-loading,
|
||
.preview-error,
|
||
.preview-binary {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 14px;
|
||
height: 100%;
|
||
color: var(--df-text-dim);
|
||
font-size: 13px;
|
||
min-height: 200px;
|
||
padding: 24px;
|
||
}
|
||
|
||
.binary-icon {
|
||
font-size: 40px;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.binary-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--df-text);
|
||
margin: 0;
|
||
}
|
||
|
||
.binary-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
min-width: 280px;
|
||
max-width: 90%;
|
||
}
|
||
|
||
.binary-meta-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.binary-meta-label {
|
||
color: var(--df-text-dim);
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
min-width: 36px;
|
||
}
|
||
|
||
.binary-meta-value {
|
||
color: var(--df-text-secondary);
|
||
font-family: var(--df-font-mono, Consolas, monospace);
|
||
font-size: 12.5px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.preview-placeholder-icon {
|
||
font-size: 48px;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.preview-placeholder-text {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.preview-error { color: #e05050; }
|
||
.binary-icon { font-size: 32px; opacity: 0.5; }
|
||
|
||
.preview-image {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
padding: 16px;
|
||
height: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
.preview-image img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: var(--df-radius-sm, 4px);
|
||
}
|
||
|
||
/* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */
|
||
.preview-md {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
padding: 14px 16px;
|
||
}
|
||
|
||
.preview-code {
|
||
margin: 0;
|
||
padding: 14px 16px;
|
||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
white-space: pre;
|
||
overflow: visible;
|
||
tab-size: 2;
|
||
min-width: 0;
|
||
}
|
||
|
||
.preview-code code { font-family: inherit; }
|
||
|
||
/* 行号 + 代码共享同一个滚动容器 */
|
||
.preview-code-scroll {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* 行号列 — sticky 固定在左侧,跟随滚动容器一起滚 */
|
||
.preview-line-numbers {
|
||
flex-shrink: 0;
|
||
padding: 14px 0;
|
||
min-width: 44px;
|
||
text-align: right;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border-right: 0.5px solid var(--df-border);
|
||
user-select: none;
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 1;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.preview-line-num {
|
||
padding: 0 10px 0 8px;
|
||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
color: var(--df-text-dim);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.preview-line-num.is-active {
|
||
color: var(--df-accent);
|
||
opacity: 1;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Diff 视图 — 在自身区域滚动,不溢出到父级 */
|
||
.preview-diff {
|
||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
overflow: auto;
|
||
min-height: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.spinner {
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid var(--df-border, #444);
|
||
border-top-color: var(--df-accent, #3a8);
|
||
border-radius: 50%;
|
||
animation: df-spin 0.8s linear infinite;
|
||
display: inline-block;
|
||
}
|
||
|
||
/* Diff 切换条 */
|
||
.diff-toggle-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-sm, 4px);
|
||
background: transparent;
|
||
color: var(--df-text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.diff-toggle-btn:hover {
|
||
background: var(--df-bg);
|
||
color: var(--df-text);
|
||
}
|
||
|
||
.diff-toggle-btn.active {
|
||
background: var(--df-accent);
|
||
color: #fff;
|
||
border-color: var(--df-accent);
|
||
}
|
||
|
||
/* 符号概览悬浮层:右上角按钮 + 弹出分组面板 */
|
||
.outline-overlay {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
z-index: 20;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
/* 折叠态:右上角小 ☰ 按钮(带符号数徽标) */
|
||
.outline-overlay-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 9px;
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
color: var(--df-text-secondary);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.outline-overlay-btn:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--df-text);
|
||
border-color: var(--df-accent);
|
||
}
|
||
|
||
.outline-overlay-count {
|
||
background: var(--df-accent);
|
||
color: #fff;
|
||
border-radius: 7px;
|
||
padding: 0 5px;
|
||
font-size: 9px;
|
||
line-height: 14px;
|
||
}
|
||
|
||
/* 展开态:悬浮面板(右上角弹出,固定宽 + 限高滚动 + 阴影) */
|
||
.preview-outline {
|
||
width: 280px;
|
||
max-height: 320px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: rgba(20, 22, 34, 0.95);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: 10px;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 浅色主题:悬浮面板用浅色底 */
|
||
[data-theme='light'] .preview-outline {
|
||
background: rgba(255, 255, 255, 0.97);
|
||
}
|
||
|
||
.preview-outline-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
font-size: 11px;
|
||
color: var(--df-text-dim);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
border-bottom: 0.5px solid var(--df-border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.preview-outline-head:hover { color: var(--df-text); }
|
||
|
||
.preview-outline-title { font-weight: 600; }
|
||
|
||
.preview-outline-count {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 8px;
|
||
padding: 0 6px;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.preview-outline-spacer { flex: 1; }
|
||
|
||
.preview-outline-toggle { font-size: 11px; opacity: 0.7; }
|
||
|
||
.preview-outline-body {
|
||
padding: 0 0 8px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.outline-group { padding: 0 10px; }
|
||
|
||
.outline-group-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 2px 3px;
|
||
font-size: 10px;
|
||
color: var(--df-text-dim);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.outline-group + .outline-group {
|
||
border-top: 0.5px solid var(--df-border);
|
||
margin-top: 3px;
|
||
}
|
||
|
||
.outline-group-count {
|
||
background: rgba(255, 255, 255, 0.07);
|
||
border-radius: 6px;
|
||
padding: 0 5px;
|
||
font-size: 9px;
|
||
}
|
||
|
||
.outline-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 6px;
|
||
border-radius: 6px;
|
||
font-size: 11.5px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
color: var(--df-text-secondary);
|
||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.outline-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--df-text); }
|
||
|
||
.outline-item.is-active {
|
||
background: rgba(255, 255, 255, 0.09);
|
||
color: var(--df-text);
|
||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||
}
|
||
|
||
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */
|
||
.outline-item.grp-type .outline-kind { color: #4FC3F7; }
|
||
.outline-item.grp-fn .outline-kind { color: #81C784; }
|
||
.outline-item.grp-var .outline-kind { color: #FFB74D; }
|
||
.outline-item.grp-mod .outline-kind { color: #BA68C8; }
|
||
.outline-item.grp-other .outline-kind { color: var(--df-text-dim); }
|
||
|
||
.outline-kind {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
flex-shrink: 0;
|
||
min-width: 30px;
|
||
text-align: right;
|
||
}
|
||
|
||
.outline-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.outline-line {
|
||
flex-shrink: 0;
|
||
min-width: 24px;
|
||
text-align: right;
|
||
font-size: 10px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* Diff 视图 */
|
||
.preview-diff {
|
||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
overflow: auto;
|
||
height: 100%;
|
||
}
|
||
|
||
.diff-line {
|
||
display: flex;
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.diff-line-num {
|
||
width: 36px;
|
||
flex-shrink: 0;
|
||
text-align: right;
|
||
padding-right: 8px;
|
||
color: var(--df-text-dim);
|
||
opacity: 0.4;
|
||
user-select: none;
|
||
}
|
||
|
||
.diff-line-prefix {
|
||
width: 16px;
|
||
flex-shrink: 0;
|
||
text-align: center;
|
||
user-select: none;
|
||
}
|
||
|
||
.diff-line-text {
|
||
flex: 1;
|
||
white-space: pre;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.diff-add {
|
||
background: rgba(40, 160, 70, 0.12);
|
||
}
|
||
|
||
.diff-add .diff-line-prefix { color: #4caf50; }
|
||
|
||
.diff-del {
|
||
background: rgba(220, 60, 60, 0.12);
|
||
}
|
||
|
||
.diff-del .diff-line-prefix { color: #e05050; }
|
||
|
||
.diff-hdr {
|
||
background: rgba(60, 140, 220, 0.08);
|
||
color: var(--df-text-dim);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.diff-ctx {
|
||
color: var(--df-text);
|
||
}
|
||
|
||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
|
||
<!-- highlight.js github-dark 主题(全局,非 scoped) -->
|
||
<style src="highlight.js/styles/github-dark.css"></style>
|