@@ -48,10 +48,28 @@
<!-- 错误 ( 外部 diff 存在时同样让位 : 历史文件当前树不存在不应遮住 diff ) -- >
< div v-else-if = "error && !isExternalDiff" class="preview-error" > ⚠ {{ error }} < / div >
< ! - - 二进制 ( 外部 diff 存在时让位 ) - - >
< ! - - 二进制 : 展示文件信息 ( 路径 / 大小 / 类型 ) , 替代 " 不支持预览 " 空态 - - >
< div v-else-if = "isBinary && !isExternalDiff" class="preview-binary" >
< span class = "binary-icon" > 📄 < / span >
< p > { { $t ( 'fileExplorer.binaryNotSupported ' ) } } < / p >
< 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 存在时让位 ) -- >
@@ -76,19 +94,60 @@
< div v-else-if = "isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd" > < / div >
< ! - - 文本 / 代码 ( highlight.js 语法高亮 + 行号 ; 非 diff 模式时显示 ) - - >
< div v-else class = "preview-code-scroll" >
< 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 } from '@/api/module'
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'
@@ -113,6 +172,22 @@ 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 会多出空串,需按实际换行符计数对齐渲染。 */
@@ -296,6 +371,99 @@ const isImage = computed(() => {
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 // 捕获本次请求序号(文件切换会使旧请求失效)
@@ -303,6 +471,9 @@ async function loadFile() {
content . value = ''
htmlContent . value = ''
imageUrl . value = null
outlineSupported . value = false
outlineSymbols . value = [ ]
activeOutlineLine . value = - 1
return
}
loading . value = true
@@ -315,8 +486,16 @@ async function loadFile() {
URL . revokeObjectURL ( imageUrl . value )
imageUrl . value = null
}
// 重置上一文件的符号概览(新文件结果到达后再填充)
outlineSupported . value = false
outlineSymbols . value = [ ]
activeOutlineLine . value = - 1
try {
const res = await moduleApi . readModuleFile ( props . moduleId , props . filePath )
// 内容与符号概览并行拉取(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
@@ -342,16 +521,35 @@ async function loadFile() {
// highlight.js 语法高亮:已知语言精确高亮,未知语言自动检测
const ext = props . filePath . split ( '.' ) . pop ( ) ? . toLowerCase ( ) ? ? ''
const lang = EXT _LANG [ ext ]
try {
if ( lang && hljs . getLanguage ( lang ) ) {
htmlContent . value = hljs . highlight ( res . content , { language : lang } ) . value
} else {
htmlContent . value = hljs . highlightAuto ( res . content ) . value
// 先显纯文本(首屏立即),再异步高亮——大文件 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 )
}
} catch {
// 高亮失败 → 转义纯文本(防 XSS)
htmlContent . value = escapeHtml ( res . content )
}
// 高亮延后到空闲时执行(小圈不再转):rAF 让出首帧,50KB+ 用双 rAF 延后更彻底。
requestAnimationFrame ( ( ) => {
if ( isLarge ) {
// 大文件再让一帧,确保首屏文本已渲染,高亮完全后台
requestAnimationFrame ( hljsTask )
} else {
hljsTask ( )
}
} )
}
} catch ( e ) {
if ( seq !== fileReqSeq ) return // 旧请求报错也不覆盖新状态
@@ -468,6 +666,7 @@ onUnmounted(() => {
min - height : 0 ;
display : flex ;
flex - direction : column ;
position : relative ; /* 符号悬浮层定位锚点 */
}
. preview - placeholder ,
@@ -478,11 +677,55 @@ onUnmounted(() => {
flex - direction : column ;
align - items : center ;
justify - content : center ;
gap : 10 px ;
gap : 14 px ;
height : 100 % ;
color : var ( -- df - text - dim ) ;
font - size : 13 px ;
min - height : 200 px ;
padding : 24 px ;
}
. binary - icon {
font - size : 40 px ;
opacity : 0.8 ;
}
. binary - title {
font - size : 15 px ;
font - weight : 600 ;
color : var ( -- df - text ) ;
margin : 0 ;
}
. binary - meta {
display : flex ;
flex - direction : column ;
gap : 6 px ;
background : rgba ( 255 , 255 , 255 , 0.04 ) ;
border - radius : 8 px ;
padding : 12 px 16 px ;
min - width : 280 px ;
max - width : 90 % ;
}
. binary - meta - row {
display : flex ;
gap : 8 px ;
align - items : baseline ;
}
. binary - meta - label {
color : var ( -- df - text - dim ) ;
font - size : 12 px ;
flex - shrink : 0 ;
min - width : 36 px ;
}
. binary - meta - value {
color : var ( -- df - text - secondary ) ;
font - family : var ( -- df - font - mono , Consolas , monospace ) ;
font - size : 12.5 px ;
word - break : break - all ;
}
. preview - placeholder - icon {
@@ -619,6 +862,173 @@ onUnmounted(() => {
border - color : var ( -- df - accent ) ;
}
/* 符号概览悬浮层:右上角按钮 + 弹出分组面板 */
. outline - overlay {
position : absolute ;
top : 8 px ;
right : 8 px ;
z - index : 20 ;
display : flex ;
justify - content : flex - end ;
}
/* 折叠态:右上角小 ☰ 按钮(带符号数徽标) */
. outline - overlay - btn {
display : inline - flex ;
align - items : center ;
gap : 5 px ;
padding : 4 px 9 px ;
border : 0.5 px solid var ( -- df - border ) ;
border - radius : 8 px ;
background : rgba ( 255 , 255 , 255 , 0.04 ) ;
color : var ( -- df - text - secondary ) ;
font - size : 12 px ;
cursor : pointer ;
transition : all 0.15 s ;
}
. 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 : 7 px ;
padding : 0 5 px ;
font - size : 9 px ;
line - height : 14 px ;
}
/* 展开态:悬浮面板(右上角弹出,固定宽 + 限高滚动 + 阴影) */
. preview - outline {
width : 280 px ;
max - height : 320 px ;
display : flex ;
flex - direction : column ;
background : rgba ( 20 , 22 , 34 , 0.95 ) ;
border : 0.5 px solid var ( -- df - border ) ;
border - radius : 10 px ;
box - shadow : 0 8 px 24 px 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 : 8 px ;
padding : 6 px 12 px ;
font - size : 11 px ;
color : var ( -- df - text - dim ) ;
cursor : pointer ;
user - select : none ;
border - bottom : 0.5 px 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 : 8 px ;
padding : 0 6 px ;
font - size : 10 px ;
}
. preview - outline - spacer { flex : 1 ; }
. preview - outline - toggle { font - size : 11 px ; opacity : 0.7 ; }
. preview - outline - body {
padding : 0 0 8 px ;
overflow - y : auto ;
flex : 1 ;
}
. outline - group { padding : 0 10 px ; }
. outline - group - head {
display : flex ;
align - items : center ;
gap : 6 px ;
padding : 6 px 2 px 3 px ;
font - size : 10 px ;
color : var ( -- df - text - dim ) ;
letter - spacing : 0.04 em ;
}
. outline - group + . outline - group {
border - top : 0.5 px solid var ( -- df - border ) ;
margin - top : 3 px ;
}
. outline - group - count {
background : rgba ( 255 , 255 , 255 , 0.07 ) ;
border - radius : 6 px ;
padding : 0 5 px ;
font - size : 9 px ;
}
. outline - item {
display : flex ;
align - items : center ;
gap : 6 px ;
padding : 2 px 6 px ;
border - radius : 6 px ;
font - size : 11.5 px ;
cursor : pointer ;
white - space : nowrap ;
color : var ( -- df - text - secondary ) ;
font - family : var ( -- df - font - mono , 'Cascadia Code' , Consolas , monospace ) ;
transition : background 0.15 s ;
}
. 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 2 px 0 0 var ( -- df - accent ) ;
}
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */
. outline - item . grp - type . outline - kind { color : # 4 FC3F7 ; }
. outline - item . grp - fn . outline - kind { color : # 81 C784 ; }
. 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 : 9 px ;
font - weight : 600 ;
flex - shrink : 0 ;
min - width : 30 px ;
text - align : right ;
}
. outline - name {
flex : 1 ;
overflow : hidden ;
text - overflow : ellipsis ;
}
. outline - line {
flex - shrink : 0 ;
min - width : 24 px ;
text - align : right ;
font - size : 10 px ;
opacity : 0.5 ;
}
/* Diff 视图 */
. preview - diff {
font - family : var ( -- df - font - mono , 'Cascadia Code' , Consolas , monospace ) ;