优化: 文件预览大纲提取 + 预览/图标/滚动条打磨

This commit is contained in:
lxy
2026-08-10 02:23:53 +08:00
parent 40224d603a
commit 2473dcfe68
10 changed files with 905 additions and 58 deletions
+28
View File
@@ -60,6 +60,26 @@ export interface ReadFileResult {
truncated: boolean
}
/** fileOutline 返回的单个顶层符号。 */
export interface OutlineSymbol {
/** 符号名(如匿名 impl 可能为空) */
name: string
/** 定义节点 kind(function_item / class_declaration 等) */
kind: string
/** 定义起始行号(1-based) */
line: number
/** 签名(到首个 { 或 ; 为止) */
signature: string
}
/** fileOutline 返回结构。 */
export interface FileOutlineResult {
path: string
symbols: OutlineSymbol[]
/** 是否为支持语言(不支持时 symbols 为空) */
supported: boolean
}
/** Git 改动文件项(对齐后端 GitChangedFile)。 */
export interface GitChangedFile {
status: string
@@ -149,6 +169,14 @@ export const moduleApi = {
return invoke('read_module_file', { moduleId, filePath })
},
/**
* 提取文件符号概览(顶层定义符号列表,供预览面板做符号树)。
* 不支持的语言返 supported=false + 空 symbols。
*/
fileOutline(moduleId: string, filePath: string): Promise<FileOutlineResult> {
return invoke('file_outline', { moduleId, filePath })
},
/** 查询工程内文件元信息(不读内容,前端检测外部变化用)。返回 { path, size, modified_at }。 */
getModuleFileMeta(moduleId: string, filePath: string): Promise<{ path: string; size: number; modified_at: number }> {
return invoke('get_module_file_meta', { moduleId, filePath })
+28 -5
View File
@@ -772,7 +772,7 @@ async function onRemoveModule() {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px 8px 0;
padding: 4px 4px 4px 0;
scrollbar-width: thin;
}
@@ -854,19 +854,42 @@ async function onRemoveModule() {
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 4px;
height: 4px;
width: 5px;
height: 5px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: var(--df-border);
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
.explorer-tree ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-tree ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-tree ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-tree ::-webkit-scrollbar-track {
background: transparent;
}
/* 窄窗口自适应 */
@media (max-width: 900px) {
.explorer-sidebar {
+425 -15
View File
@@ -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: 10px;
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 {
@@ -619,6 +862,173 @@ onUnmounted(() => {
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);
File diff suppressed because one or more lines are too long
+19
View File
@@ -774,4 +774,23 @@ watch(() => props.refreshKey, () => {
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
}
@keyframes df-spin { to { transform: rotate(360deg); } }
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
.git-changes ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.git-changes ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.git-changes ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.git-changes ::-webkit-scrollbar-track {
background: transparent;
}
</style>
+11
View File
@@ -15,7 +15,18 @@ export default {
selectFileHint: '← Select a file on the left to preview',
loadingFile: 'Loading file…',
binaryNotSupported: 'Binary file preview not supported',
binaryInfo: 'Binary file',
pathLabel: 'Path',
sizeLabel: 'Size',
typeLabel: 'Type',
binaryLabel: 'Note',
truncated: 'File too large, only first 1MB shown',
symbols: 'Outline',
outlineGroupType: 'Types',
outlineGroupFn: 'Functions',
outlineGroupVar: 'Variables',
outlineGroupMod: 'Modules',
outlineGroupOther: 'Others',
// Errors
loadFailed: 'Failed to load',
// Diff
+11
View File
@@ -15,7 +15,18 @@ export default {
selectFileHint: '← 选择左侧文件查看预览',
loadingFile: '加载文件中…',
binaryNotSupported: '二进制文件不支持预览',
binaryInfo: '二进制文件',
pathLabel: '路径',
sizeLabel: '大小',
typeLabel: '类型',
binaryLabel: '说明',
truncated: '文件过大,仅显示前 1MB',
symbols: '大纲',
outlineGroupType: '类型',
outlineGroupFn: '函数',
outlineGroupVar: '变量',
outlineGroupMod: '模块',
outlineGroupOther: '其他',
// 错误
loadFailed: '加载失败',
// Diff