<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>
