Files
DevFlow/src/components/project/FilePreview.vue
绝尘 dfed588b54 新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载)
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 时间显示: 提交历史显示相对时间/具体日期
- 面包屑导航不丢预览: 导航时不改变当前预览文件
- 刷新保留选中文件: 只清树缓存不清预览
- 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量
- 自适应布局: 弹性 flex 布局,窄窗口适配
- 滚动条优化: 内容区内部滚动,不溢出页面层
- 多工程管理: 工具栏添加工程按钮+弹窗表单
- 审批加载超时缩短: 130s 降至 30s
- 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
2026-06-29 19:31:55 +08:00

581 lines
16 KiB
Vue

<template>
<!--
文件内容预览
- 文本/代码: highlight.js 语法高亮(按文件后缀自动选语言),git 变更可切 diff 视图
- 图片: <img> 展示( convertFileSrc)
- 二进制: 提示"二进制文件不支持预览"
- 顶部: 文件路径 + 大小 + Git 状态 + Diff 切换按钮
-->
<div class="file-preview">
<!-- 顶部元信息 -->
<div class="preview-header">
<div class="preview-meta-left">
<span class="preview-path" :title="filePath ?? ''">{{ filePath || '—' }}</span>
<span v-if="gitStatus" class="git-badge" :class="gitStatusClass(gitStatus)">
{{ gitStatusLabel(gitStatus) }}
</span>
</div>
<div class="preview-meta-right">
<span v-if="fileSize !== null" class="preview-size">{{ formatSize(fileSize) }}</span>
<span v-if="truncated" class="preview-truncated"> {{ $t('fileExplorer.truncated') }}</span>
<!-- Diff 切换按钮( git 有变更时显示) -->
<button
v-if="gitStatus"
class="diff-toggle-btn"
:class="{ active: showDiff }"
@click="toggleDiff"
:title="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
>
{{ showDiff ? '📄' : '📝' }}
</button>
</div>
</div>
<!-- 内容区 -->
<div class="preview-body">
<!-- 未选文件占位 -->
<div v-if="!filePath" class="preview-placeholder">
{{ $t('fileExplorer.selectFileHint') }}
</div>
<!-- 加载中 -->
<div v-else-if="loading" class="preview-loading">
<span class="spinner"></span>
{{ $t('fileExplorer.loadingFile') }}
</div>
<!-- 错误 -->
<div v-else-if="error" class="preview-error"> {{ error }}</div>
<!-- 二进制 -->
<div v-else-if="isBinary" class="preview-binary">
<span class="binary-icon">📄</span>
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
</div>
<!-- 图片 -->
<div v-else-if="isImage" class="preview-image">
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
</div>
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
<!-- Diff 视图(切到 diff 模式时显示) -->
<div v-else-if="showDiff && diffContent" 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>
<!-- 文本/代码(highlight.js 语法高亮 + 行号; diff 模式时显示) -->
<div v-else class="preview-code-wrap">
<div class="preview-line-numbers" ref="lineNumRef">
<div v-for="n in lineCount" :key="n" class="preview-line-num" :class="{ 'is-active': n === activeLine }">{{ n }}</div>
</div>
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from 'vue'
import { moduleApi } from '@/api/module'
import hljs from 'highlight.js/lib/common'
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
const props = defineProps<{
moduleId: string
filePath: string | null
moduleRootPath: string
gitStatus?: 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)
/** 行号显示 — 根据 highlight.js 输出的 HTML 行数计算。 */
const lineCount = computed(() => {
if (!htmlContent.value) return 0
// highlight.js 用 \n 分隔行,计算行数
const lines = htmlContent.value.split('\n')
return lines.length
})
/** 当前激活行(预留:后续可点击行号跳转/选中高亮)。 */
const activeLine = ref<number | null>(null)
const lineNumRef = ref<HTMLElement | null>(null)
/** Diff 显示控制 */
const showDiff = ref(false)
const diffContent = ref('')
const diffLoading = ref(false)
interface DiffLine {
type: 'add' | 'del' | 'ctx' | 'hdr'
prefix: string
text: string
oldNum: string
newNum: string
}
const diffLines = computed<DiffLine[]>(() => {
if (!diffContent.value) return []
const lines: DiffLine[] = []
for (const raw of diffContent.value.split('\n')) {
if (raw.startsWith('@@')) {
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: '' })
} else if (raw.startsWith('-')) {
lines.push({ type: 'del', prefix: '-', text: raw.slice(1), oldNum: '', newNum: '' })
} else if (raw.startsWith('\\')) {
// No newline at end of file 等元信息
lines.push({ type: 'ctx', prefix: '', text: raw, oldNum: '', newNum: '' })
} else {
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
}
}
return lines
})
function toggleDiff() {
showDiff.value = !showDiff.value
if (showDiff.value && !diffContent.value) {
loadDiff()
}
}
async function loadDiff() {
if (!props.moduleId || !props.filePath) return
diffLoading.value = true
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath)
diffContent.value = res.diff || ''
} catch {
diffContent.value = ''
} finally {
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)
const isImage = computed(() => {
if (!props.filePath) return false
const lower = props.filePath.toLowerCase()
return IMAGE_EXT.some((ext) => lower.endsWith(ext))
})
/** 拉文件内容 + 高亮渲染。 */
async function loadFile() {
if (!props.filePath) {
content.value = ''
htmlContent.value = ''
imageUrl.value = null
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
}
try {
const res = await moduleApi.readModuleFile(props.moduleId, props.filePath)
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')
const abs = joinPath(props.moduleRootPath, props.filePath)
imageUrl.value = convertFileSrc(abs)
} catch {
imageUrl.value = null
}
} else {
content.value = res.content
// 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
}
} catch {
// 高亮失败 → 转义纯文本(防 XSS)
htmlContent.value = escapeHtml(res.content)
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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], () => {
showDiff.value = false
diffContent.value = ''
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;
}
.preview-placeholder,
.preview-loading,
.preview-error,
.preview-binary {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
height: 100%;
color: var(--df-text-dim);
font-size: 13px;
min-height: 200px;
}
.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);
}
.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: auto;
tab-size: 2;
flex: 1;
min-width: 0;
min-height: 0;
}
.preview-code code { font-family: inherit; }
/* 行号 + 代码并排容器 */
.preview-code-wrap {
display: flex;
overflow: hidden;
min-height: 0;
flex: 1 1 0;
}
/* 行号列 */
.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;
overflow: hidden;
}
.preview-line-num {
padding: 0 10px 0 8px;
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
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);
}
/* 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>