Files
DevFlow/src/components/project/FilePreview.vue
T

1096 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>