- 窗口分离: 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
581 lines
16 KiB
Vue
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
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>
|